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 " "] [-l ]... [-a ]... [--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 [-b|--body ] [-l|--label ]... [-a|--assignee ]... [-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 [-b ] [-l ]... [-a ]... [-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():