From 169ff4a2773ad8ea9a81baa1fb998fc80e3cf52f Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Wed, 16 Sep 2026 01:59:18 -0700 Subject: [PATCH 1/3] Made console command registry a better usage pattern --- engine/include/sophia/application.hpp | 3 + .../console/console_command_registry.hpp | 35 ++++++++++ engine/include/sophia/ui/debug_ui.hpp | 3 +- engine/src/application.cpp | 2 +- .../src/console/console_command_registry.cpp | 31 +++++++++ engine/src/ui/debug_ui.cpp | 5 +- engine/src/ui/panels/console_panel.cpp | 66 ++++++++++++++----- engine/src/ui/panels/console_panel.hpp | 19 ++---- 8 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 engine/include/sophia/console/console_command_registry.hpp create mode 100644 engine/src/console/console_command_registry.cpp diff --git a/engine/include/sophia/application.hpp b/engine/include/sophia/application.hpp index afdba72..4870fcb 100644 --- a/engine/include/sophia/application.hpp +++ b/engine/include/sophia/application.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,8 @@ class Application { Camera2D camera; std::vector args; + ConsoleCommandRegistry consoleCommandRegistry; + void drawMesh(MeshHandle handle, const glm::mat4& transform); private: diff --git a/engine/include/sophia/console/console_command_registry.hpp b/engine/include/sophia/console/console_command_registry.hpp new file mode 100644 index 0000000..3d83645 --- /dev/null +++ b/engine/include/sophia/console/console_command_registry.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +namespace sophia { + +class ConsoleCommandRegistry { + public: + using Handler = std::function&)>; + + struct CommandInfo { + std::string name; + std::string help; + }; + + void registerCommand(std::string name, std::string help, Handler handler); + + bool dispatch(const std::string& name, const std::vector& args, + std::string& outResult) const; + + std::vector list() const; + + private: + struct Command { + std::string help; + Handler handler; + }; + + std::map commands; +}; + +} // namespace sophia diff --git a/engine/include/sophia/ui/debug_ui.hpp b/engine/include/sophia/ui/debug_ui.hpp index 09b4c17..0037196 100644 --- a/engine/include/sophia/ui/debug_ui.hpp +++ b/engine/include/sophia/ui/debug_ui.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -10,7 +11,7 @@ class DebugUI { DebugUI(const DebugUI&) = delete; DebugUI& operator=(const DebugUI&) = delete; - explicit DebugUI(PanelRegistry& registry); + DebugUI(PanelRegistry& registry, ConsoleCommandRegistry& commands); ~DebugUI() = default; void show(); diff --git a/engine/src/application.cpp b/engine/src/application.cpp index f3c7852..1123bcc 100644 --- a/engine/src/application.cpp +++ b/engine/src/application.cpp @@ -206,7 +206,7 @@ Application::Application(const Config& config) canvas{this}, args{std::move(commandLineArgs)}, assetRegistry{impl->getDevice()}, - _debugUI{this->panelRegistry} {} + _debugUI{this->panelRegistry, this->consoleCommandRegistry} {} Application::~Application() = default; diff --git a/engine/src/console/console_command_registry.cpp b/engine/src/console/console_command_registry.cpp new file mode 100644 index 0000000..fc12aab --- /dev/null +++ b/engine/src/console/console_command_registry.cpp @@ -0,0 +1,31 @@ +#include + +namespace sophia { + +void ConsoleCommandRegistry::registerCommand(std::string name, std::string help, + Handler handler) { + commands[std::move(name)] = Command{std::move(help), std::move(handler)}; +} + +bool ConsoleCommandRegistry::dispatch(const std::string& name, + const std::vector& args, + std::string& outResult) const { + auto it = commands.find(name); + if (it == commands.end()) { + return false; + } + outResult = it->second.handler(args); + return true; +} + +std::vector ConsoleCommandRegistry::list() + const { + std::vector result; + result.reserve(commands.size()); + for (const auto& [name, command] : commands) { + result.push_back(CommandInfo{name, command.help}); + } + return result; +} + +} // namespace sophia diff --git a/engine/src/ui/debug_ui.cpp b/engine/src/ui/debug_ui.cpp index fe3a19a..f52cf17 100644 --- a/engine/src/ui/debug_ui.cpp +++ b/engine/src/ui/debug_ui.cpp @@ -6,11 +6,12 @@ namespace sophia { -DebugUI::DebugUI(PanelRegistry& registry) : registry{registry} { +DebugUI::DebugUI(PanelRegistry& registry, ConsoleCommandRegistry& commands) + : registry{registry} { // #ifndef NDEBUG this->panelRegistryHandle = registry.emplace(registry); this->frameStatsHandle = registry.emplace(); - this->consoleHandle = registry.emplace(); + this->consoleHandle = registry.emplace(commands); // #endif // !NDEBUG } diff --git a/engine/src/ui/panels/console_panel.cpp b/engine/src/ui/panels/console_panel.cpp index b9962dc..4e9f192 100644 --- a/engine/src/ui/panels/console_panel.cpp +++ b/engine/src/ui/panels/console_panel.cpp @@ -1,6 +1,7 @@ #include "console_panel.hpp" #include +#include namespace sophia { @@ -11,10 +12,6 @@ constexpr ImVec4 COLOR_ERROR = ImVec4(1.0f, 0.4f, 0.4f, 1.0f); constexpr ImVec4 COLOR_INFO = ImVec4(0.4f, 0.7f, 1.0f, 1.0f); } // namespace -void ConsolePanel::registerDefaultCommands() { - log("Console ready. Type 'help' for a list of commands."); -} - void ConsolePanel::log(const std::string& message) { entries.push_back(Entry{message, COLOR_DEFAULT}); scrollToBottom = true; @@ -22,6 +19,16 @@ void ConsolePanel::log(const std::string& message) { void ConsolePanel::clear() { entries.clear(); } +std::vector ConsolePanel::tokenize(const std::string& command) { + std::istringstream stream(command); + std::vector tokens; + std::string token; + while (stream >> token) { + tokens.push_back(std::move(token)); + } + return tokens; +} + void ConsolePanel::execute(const std::string& command) { entries.push_back(Entry{"> " + command, COLOR_COMMAND}); scrollToBottom = true; @@ -31,19 +38,47 @@ void ConsolePanel::execute(const std::string& command) { history.end()); history.push_back(command); - if (command == "help") { - entries.push_back( - Entry{"Available commands: help, clear, history", COLOR_INFO}); - } else if (command == "clear") { + std::vector tokens = tokenize(command); + if (tokens.empty()) { + return; + } + + const std::string& name = tokens.front(); + const std::vector args(tokens.begin() + 1, tokens.end()); + + if (name == "help") { + entries.push_back(Entry{"help - List available commands", COLOR_INFO}); + entries.push_back(Entry{"clear - Clear the console output", COLOR_INFO}); + entries.push_back(Entry{"history - Show command history", COLOR_INFO}); + for (const auto& info : consoleCommandRegistry.list()) { + entries.push_back(Entry{info.name + " - " + info.help, COLOR_INFO}); + } + return; + } + + if (name == "clear") { clear(); - } else if (command == "history") { + return; + } + + if (name == "history") { for (const std::string& past : history) { entries.push_back(Entry{past, COLOR_DEFAULT}); } - } else if (commandCallback) { - commandCallback(*this, command); - } else { - entries.push_back(Entry{"Unknown command: '" + command + "'", COLOR_ERROR}); + return; + } + + std::string result; + try { + if (consoleCommandRegistry.dispatch(name, args, result)) { + if (!result.empty()) { + entries.push_back(Entry{result, COLOR_INFO}); + } + } else { + entries.push_back(Entry{"Unknown command: '" + name + "'", COLOR_ERROR}); + } + } catch (const std::exception& e) { + entries.push_back(Entry{e.what(), COLOR_ERROR}); } } @@ -81,8 +116,6 @@ void ConsolePanel::onUpdate(const FrameInfo&) { if (ImGui::SmallButton("Clear")) { clear(); } - ImGui::SameLine(); - ImGui::Checkbox("Auto-scroll", &autoScroll); ImGui::Separator(); @@ -97,8 +130,7 @@ void ConsolePanel::onUpdate(const FrameInfo&) { ImGui::PopStyleColor(); } - if (scrollToBottom || - (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY())) { + if (scrollToBottom) { ImGui::SetScrollHereY(1.0f); } scrollToBottom = false; diff --git a/engine/src/ui/panels/console_panel.hpp b/engine/src/ui/panels/console_panel.hpp index 0afb993..c35ef5f 100644 --- a/engine/src/ui/panels/console_panel.hpp +++ b/engine/src/ui/panels/console_panel.hpp @@ -2,7 +2,7 @@ #include -#include +#include #include #include #include @@ -12,39 +12,34 @@ namespace sophia { class ConsolePanel : public Panel { public: - using CommandCallback = - std::function; - - ConsolePanel() : Panel{"Console"} { registerDefaultCommands(); } + explicit ConsolePanel(ConsoleCommandRegistry& consoleCommandRegistry) + : Panel{"Console"}, consoleCommandRegistry{consoleCommandRegistry} { + log("Console ready. Type 'help' for a list of commands."); + } void onUpdate(const FrameInfo& frameInfo) override; void log(const std::string& message); void clear(); - void setCommandCallback(CommandCallback callback) { - this->commandCallback = std::move(callback); - } - private: struct Entry { std::string text; ImVec4 color; }; - void registerDefaultCommands(); void execute(const std::string& command); int textEditCallback(struct ImGuiInputTextCallbackData* data); + static std::vector tokenize(const std::string& command); std::vector entries; std::vector history; int historyPos = -1; char inputBuffer[256] = {}; - bool autoScroll = true; bool scrollToBottom = false; - CommandCallback commandCallback; + ConsoleCommandRegistry& consoleCommandRegistry; }; } // namespace sophia From dffb7b797240d4d721d0f8aa7d8952cf5478327f Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Wed, 16 Sep 2026 02:28:42 -0700 Subject: [PATCH 2/3] Changes: - editor project - build system fixes - dummy data editor panels mock --- CMakeLists.txt | 3 ++- editor/CMakeLists.txt | 26 ++++++++++++++++++ editor/main.cpp | 25 +++++++++++++++++ editor/panels/asset_browser_panel.cpp | 27 +++++++++++++++++++ editor/panels/asset_browser_panel.hpp | 30 +++++++++++++++++++++ editor/panels/inspector_panel.cpp | 36 +++++++++++++++++++++++++ editor/panels/inspector_panel.hpp | 25 +++++++++++++++++ editor/panels/scene_hierarchy_panel.cpp | 34 +++++++++++++++++++++++ editor/panels/scene_hierarchy_panel.hpp | 32 ++++++++++++++++++++++ engine/CMakeLists.txt | 1 + testbed/CMakeLists.txt | 11 +++----- 11 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 editor/CMakeLists.txt create mode 100644 editor/main.cpp create mode 100644 editor/panels/asset_browser_panel.cpp create mode 100644 editor/panels/asset_browser_panel.hpp create mode 100644 editor/panels/inspector_panel.cpp create mode 100644 editor/panels/inspector_panel.hpp create mode 100644 editor/panels/scene_hierarchy_panel.cpp create mode 100644 editor/panels/scene_hierarchy_panel.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index ec4e2aa..56431db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,5 +39,6 @@ endif() add_subdirectory(third_party) add_subdirectory(engine) +add_subdirectory(editor) add_subdirectory(testbed) -add_subdirectory(examples) \ No newline at end of file +add_subdirectory(examples) diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt new file mode 100644 index 0000000..151e498 --- /dev/null +++ b/editor/CMakeLists.txt @@ -0,0 +1,26 @@ +set(NAME editor) +set(ENGINE_NAME ${PROJECT_NAME}) + +file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS *.cpp src/*.cpp) + +add_library(${NAME}_deps INTERFACE) + +target_link_libraries(${NAME}_deps INTERFACE + ${ENGINE_NAME} +) + +target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) +set_property(TARGET ${PROJECT_NAME} PROPERTY VS_DEBUGGER_WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}/build") + +add_executable(editor ${SOURCES}) + +target_link_libraries(editor PRIVATE ${NAME}_deps) + +target_include_directories(editor PRIVATE + "${CMAKE_SOURCE_DIR}/engine/include" + "${CMAKE_SOURCE_DIR}/external/imgui" +) + +target_compile_definitions(editor PRIVATE + EDITOR_SHADER_DIR="${CMAKE_CURRENT_SOURCE_DIR}/shaders" +) diff --git a/editor/main.cpp b/editor/main.cpp new file mode 100644 index 0000000..58d939c --- /dev/null +++ b/editor/main.cpp @@ -0,0 +1,25 @@ +#include +#include + +#include "panels/asset_browser_panel.hpp" +#include "panels/inspector_panel.hpp" +#include "panels/scene_hierarchy_panel.hpp" + +class Editor : public sophia::Application { + public: + Editor() : Application{{.width = 750, .height = 1000, .name = "Editor"}} {} + + void onCreate() override { + std::cout << "Editor created" << std::endl; + + debugUI().show(); + + panels().emplace(); + panels().emplace(); + panels().emplace(); + } + + void onUpdate(const sophia::FrameInfo& frameInfo) override {} +}; + +SOPHIA_MAIN(Editor) diff --git a/editor/panels/asset_browser_panel.cpp b/editor/panels/asset_browser_panel.cpp new file mode 100644 index 0000000..5371e4f --- /dev/null +++ b/editor/panels/asset_browser_panel.cpp @@ -0,0 +1,27 @@ +#include "asset_browser_panel.hpp" + +#include + +void AssetBrowserPanel::onUpdate(const sophia::FrameInfo&) { + ImGui::Begin("Asset Browser"); + + ImGui::BeginChild("Folders", ImVec2(150, 0), true); + for (int i = 0; i < static_cast(folders.size()); i++) { + if (ImGui::Selectable(folders[static_cast(i)].name.c_str(), + selectedFolder == i)) { + selectedFolder = i; + } + } + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("Files", ImVec2(0, 0), true); + const Folder& folder = folders[static_cast(selectedFolder)]; + for (const std::string& file : folder.files) { + ImGui::Selectable(file.c_str()); + } + ImGui::EndChild(); + + ImGui::End(); +} diff --git a/editor/panels/asset_browser_panel.hpp b/editor/panels/asset_browser_panel.hpp new file mode 100644 index 0000000..fc9577f --- /dev/null +++ b/editor/panels/asset_browser_panel.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +class AssetBrowserPanel : public sophia::Panel { + public: + AssetBrowserPanel() : Panel{"Asset Browser"} {} + + void onUpdate(const sophia::FrameInfo& frameInfo) override; + + private: + struct Folder { + std::string name; + std::vector files; + }; + + std::vector folders = { + {"Models", {"player_capsule.fbx", "ground_plane.fbx", "enemy_grunt.fbx"}}, + {"Textures", + {"brick_diffuse.png", "grass_albedo.png", "player_diffuse.png"}}, + {"Materials", {"player_mat.mat", "ground_mat.mat"}}, + {"Scripts", {"PlayerController.lua", "EnemyAI.lua"}}, + {"Scenes", {"MainMenu.scene", "Level_01.scene"}}, + }; + + int selectedFolder = 0; +}; diff --git a/editor/panels/inspector_panel.cpp b/editor/panels/inspector_panel.cpp new file mode 100644 index 0000000..0910b10 --- /dev/null +++ b/editor/panels/inspector_panel.cpp @@ -0,0 +1,36 @@ +#include "inspector_panel.hpp" + +#include + +#include + +void InspectorPanel::onUpdate(const sophia::FrameInfo&) { + ImGui::Begin("Inspector"); + + char nameBuf[128]; + std::snprintf(nameBuf, sizeof(nameBuf), "%s", name.c_str()); + if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf))) { + name = nameBuf; + } + ImGui::Text("Tag: %s", tag.c_str()); + + ImGui::Separator(); + + if (ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::DragFloat3("Position", &position.x, 0.1f); + ImGui::DragFloat3("Rotation", &rotation.x, 0.1f); + ImGui::DragFloat3("Scale", &scale.x, 0.1f); + } + + if (ImGui::CollapsingHeader("Mesh Renderer", + ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Text("Mesh: %s", meshName.c_str()); + ImGui::Text("Material: %s", materialName.c_str()); + } + + if (ImGui::CollapsingHeader("Script", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Text("%s", scriptName.c_str()); + } + + ImGui::End(); +} diff --git a/editor/panels/inspector_panel.hpp b/editor/panels/inspector_panel.hpp new file mode 100644 index 0000000..a316315 --- /dev/null +++ b/editor/panels/inspector_panel.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +class InspectorPanel : public sophia::Panel { + public: + InspectorPanel() : Panel{"Inspector"} {} + + void onUpdate(const sophia::FrameInfo& frameInfo) override; + + private: + std::string name = "Player"; + std::string tag = "Player"; + + glm::vec3 position{0.0f, 1.0f, 0.0f}; + glm::vec3 rotation{0.0f, 0.0f, 0.0f}; + glm::vec3 scale{1.0f, 1.0f, 1.0f}; + + std::string meshName = "player_capsule.fbx"; + std::string materialName = "player_mat"; + std::string scriptName = "PlayerController.lua"; +}; diff --git a/editor/panels/scene_hierarchy_panel.cpp b/editor/panels/scene_hierarchy_panel.cpp new file mode 100644 index 0000000..14d663c --- /dev/null +++ b/editor/panels/scene_hierarchy_panel.cpp @@ -0,0 +1,34 @@ +#include "scene_hierarchy_panel.hpp" + +#include + +void SceneHierarchyPanel::drawNode(const Node& node) { + if (node.children.empty()) { + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf | + ImGuiTreeNodeFlags_NoTreePushOnOpen | + ImGuiTreeNodeFlags_SpanAvailWidth; + if (node.name == selected) { + flags |= ImGuiTreeNodeFlags_Selected; + } + ImGui::TreeNodeEx(node.name.c_str(), flags); + if (ImGui::IsItemClicked()) { + selected = node.name; + } + return; + } + + if (ImGui::TreeNodeEx(node.name.c_str(), ImGuiTreeNodeFlags_SpanAvailWidth)) { + for (const Node& child : node.children) { + drawNode(child); + } + ImGui::TreePop(); + } +} + +void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) { + ImGui::Begin("Scene Hierarchy"); + for (const Node& node : nodes) { + drawNode(node); + } + ImGui::End(); +} diff --git a/editor/panels/scene_hierarchy_panel.hpp b/editor/panels/scene_hierarchy_panel.hpp new file mode 100644 index 0000000..78d70c1 --- /dev/null +++ b/editor/panels/scene_hierarchy_panel.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +class SceneHierarchyPanel : public sophia::Panel { + public: + SceneHierarchyPanel() : Panel{"Scene Hierarchy"} {} + + void onUpdate(const sophia::FrameInfo& frameInfo) override; + + private: + struct Node { + std::string name; + std::vector children; + }; + + void drawNode(const Node& node); + + std::vector nodes = { + {"Main Camera", {}}, + {"Directional Light", {}}, + {"Ground", {}}, + {"Player", {}}, + {"Enemies", {{"Enemy_01", {}}, {"Enemy_02", {}}, {"Enemy_03", {}}}}, + {"Props", {{"Crate_01", {}}, {"Crate_02", {}}}}, + }; + + std::string selected; +}; diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index aa601d4..41f862c 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -19,6 +19,7 @@ target_include_directories(${ENGINE_NAME} PUBLIC target_link_libraries(${ENGINE_NAME} PUBLIC vulkan + shaderc glfw glm imgui diff --git a/testbed/CMakeLists.txt b/testbed/CMakeLists.txt index f03f0a6..abf111d 100644 --- a/testbed/CMakeLists.txt +++ b/testbed/CMakeLists.txt @@ -3,15 +3,10 @@ set(ENGINE_NAME ${PROJECT_NAME}) file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS *.cpp src/*.cpp) -add_library(deps INTERFACE) +add_library(${NAME}_deps INTERFACE) -target_link_libraries(deps INTERFACE +target_link_libraries(${NAME}_deps INTERFACE ${ENGINE_NAME} - vulkan - shaderc - glfw - glm - imgui ) target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) @@ -19,7 +14,7 @@ set_property(TARGET ${PROJECT_NAME} PROPERTY VS_DEBUGGER_WORKING_DIRECTORY "${CM add_executable(testbed main.cpp) -target_link_libraries(testbed PRIVATE deps) +target_link_libraries(testbed PRIVATE ${NAME}_deps) target_include_directories(testbed PRIVATE "${CMAKE_SOURCE_DIR}/engine/include" From e72ada5b40a3fe42ca94652ecfe011a754989a2a Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Sat, 19 Sep 2026 00:56:40 -0700 Subject: [PATCH 3/3] branch-from command --- bin/dev | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/bin/dev b/bin/dev index 5fd67aa..b1dd9b3 100755 --- a/bin/dev +++ b/bin/dev @@ -1,13 +1,16 @@ #!/usr/bin/env python3 +import json import os import re +import shlex import shutil import struct import subprocess import sys COMMAND_TO_METHOD = { + "branch-from": "branch_from", "bash-completions": "bash_completions", } METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()} @@ -36,6 +39,28 @@ class DevHelper: command = f"rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug" self._exec_in_root(command, replace=True) + ## + # Checks out a branch named after a Gitea issue URL. + # If the issue is from this project's repo, the branch name will be: + # issue-/ + # If the issue is from another repo (presumably upstream), the branch name will be: + # -issue-/ + def branch_from(self, *args, **kwargs): + issue_url = args[0] if args else None + if issue_url is None: + raise ValueError("Must provide a Gitea issue URL.") + + base_url, issue_repo, issue_number = self._parse_issue_url(issue_url) + issue_title = self._fetch_issue_title(base_url, issue_repo, issue_number) + branch_name = self._format_branch_name(issue_repo, issue_number, issue_title) + + exists = subprocess.run( + ["git", "-C", self._project_root(), "rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"], + stdout=subprocess.DEVNULL, + ).returncode == 0 + flag = "" if exists else "-b " + self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True) + def renderdoc(self, *args, **kwargs): qrenderdoc = self._find_qrenderdoc() self._exec_in_root(qrenderdoc, replace=True) @@ -84,6 +109,39 @@ class DevHelper: print(" ".join(completions)) # Private helpers + def _parse_issue_url(self, url): + match = re.match(r"^(https?://[^/]+)/([^/]+)/([^/]+)/issues/(\d+)", url) + if match is None: + raise ValueError(f"Not a Gitea issue URL: {url}") + return match[1], f"{match[2]}/{match[3]}", match[4] + + def _fetch_issue_title(self, base_url, repo, issue_number): + if shutil.which("tea") is None: + raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") + + endpoint = f"{base_url}/api/v1/repos/{repo}/issues/{issue_number}" + print(f"Running: tea api {endpoint}") + result = subprocess.run(["tea", "api", endpoint], stdout=subprocess.PIPE, text=True) + if result.returncode != 0: + raise RuntimeError(f"failed to fetch issue title from {endpoint}") + return json.loads(result.stdout)["title"] + + def _format_branch_name(self, issue_repo, issue_number, issue_title): + formatted_title = re.sub(r"-+", "-", re.sub(r"[^a-z0-9\-]", "", re.sub(r"\s+", "-", issue_title.lower().strip()))) + if issue_repo == self._origin_repo(): + return f"issue-{issue_number}/{formatted_title}" + issue_owner = issue_repo.split("/")[0] + return f"{issue_owner}-issue-{issue_number}/{formatted_title}" + + def _origin_repo(self): + result = subprocess.run( + ["git", "-C", self._project_root(), "remote", "get-url", "origin"], + stdout=subprocess.PIPE, + text=True, + ) + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", result.stdout.strip()) + return match[1] if match else None + def _find_glslc(self): glslc = shutil.which("glslc") if glslc is None: