From 72e4ae79e2afa93db09eb74463fc9e940d128db1 Mon Sep 17 00:00:00 2001 From: Caleb Burke Date: Mon, 21 Sep 2026 01:30:21 -0700 Subject: [PATCH] gitea issue helper --- CLAUDE.md | 17 ++++++++++ bin/dev | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..86b81fd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# Sophia + +## Dev helper (`bin/dev`) + +`bin/dev` (on PATH via direnv as `dev`) wraps common project tasks. Run `dev bash-completions` to list commands. + +### Creating issues + +Use `dev issue` to create Gitea issues instead of calling the API or `tea` directly: + +``` +dev issue "" [-b "<body>"] [-l <label>]... [-a <user>]... [--open] +``` + +- Labels are repo label names (`bug`, `enhancement`, `question`, ...). +- The Gitea API cannot add an issue to a project. After creating an issue, tell the user its URL so they can add it to the "sophia" project (issue sidebar > Projects), or pass `--open`. +- Only create issues when the user asks; issues are visible on the remote. diff --git a/bin/dev b/bin/dev index b1dd9b3..dfb676f 100755 --- a/bin/dev +++ b/bin/dev @@ -19,6 +19,7 @@ WAIT_FOR_PROCESS = "wait_for_process" class DevHelper: SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese") + PROJECT_NAME = "sophia" def __init__(self): self.last_exit_status = 0 @@ -61,6 +62,40 @@ class DevHelper: flag = "" if exists else "-b " self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True) + ## + # Creates an issue on this project's Gitea repo. + # dev issue <title> [-b|--body <text>] [-l|--label <name>]... [-a|--assignee <user>]... [-o|--open] + # The body is read from stdin if --body is not given and stdin is piped. + # Gitea's API cannot add issues to a project (only the web UI can), so the + # issue URL is printed and --open opens it in the browser to do so. + def issue(self, *args, **kwargs): + title, body, labels, assignees, open_in_browser = self._parse_issue_args(args) + + repo = self._origin_repo() + if repo is None: + raise RuntimeError("could not determine the Gitea repo from the origin remote") + + payload = {"title": title, "body": body} + if labels: + label_ids = self._fetch_label_ids(repo) + unknown = [label for label in labels if label not in label_ids] + if unknown: + raise ValueError(f"Unknown label(s): {', '.join(unknown)} (available: {', '.join(sorted(label_ids))})") + payload["labels"] = [label_ids[label] for label in labels] + if assignees: + payload["assignees"] = assignees + + issue = self._tea_api(f"repos/{repo}/issues", payload) + print(f"Created issue #{issue['number']}: {issue['title']}") + print(issue["html_url"]) + print(f"Add it to the project from the issue sidebar: Projects > {self.PROJECT_NAME}") + + if open_in_browser: + opener = shutil.which("xdg-open") or shutil.which("open") + if opener is None: + raise RuntimeError("xdg-open not found on PATH") + subprocess.Popen([opener, issue["html_url"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def renderdoc(self, *args, **kwargs): qrenderdoc = self._find_qrenderdoc() self._exec_in_root(qrenderdoc, replace=True) @@ -126,6 +161,63 @@ class DevHelper: raise RuntimeError(f"failed to fetch issue title from {endpoint}") return json.loads(result.stdout)["title"] + def _parse_issue_args(self, args): + title = None + body = None + labels = [] + assignees = [] + open_in_browser = False + + args = list(args) + while args: + arg = args.pop(0) + if arg in ("-b", "--body", "-l", "--label", "-a", "--assignee"): + if not args: + raise ValueError(f"{arg} requires a value") + value = args.pop(0) + if arg in ("-b", "--body"): + body = value + elif arg in ("-l", "--label"): + labels.append(value) + else: + assignees.append(value) + elif arg in ("-o", "--open"): + open_in_browser = True + elif arg.startswith("-"): + raise ValueError(f"Unknown option: {arg}") + elif title is None: + title = arg + else: + title = f"{title} {arg}" + + if not title: + raise ValueError("Must provide an issue title. Usage: dev issue <title> [-b <body>] [-l <label>]... [-a <user>]... [-o]") + if body is None: + body = "" if sys.stdin.isatty() else sys.stdin.read() + return title, body, labels, assignees, open_in_browser + + def _tea_api(self, endpoint, payload=None): + if shutil.which("tea") is None: + raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)") + + command = ["tea", "api", endpoint] + if payload is not None: + command += ["-d", "@-"] + print(f"Running: {' '.join(command)}") + result = subprocess.run( + command, + input=json.dumps(payload) if payload is not None else None, + stdout=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"tea api request failed: {endpoint}") + return json.loads(result.stdout) + + def _fetch_label_ids(self, repo): + labels = self._tea_api(f"repos/{repo}/labels?limit=100") + return {label["name"]: label["id"] for label in labels} + 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():