From 1566414f8f131eb01901d3e31bdaf7cc18a2cbc7 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 16 Oct 2026 19:50:52 -0700 Subject: [PATCH] feat: github_edit_issue (title/body) and github_create_label write tools --- PROTO.md | 11 +-- README.md | 12 ++-- tests/test_no_raise_sweep.py | 2 + tests/test_register.py | 12 ++-- tests/test_write_tools.py | 126 +++++++++++++++++++++++++++++++++++ write_tools.py | 66 ++++++++++++++++++ 6 files changed, 215 insertions(+), 14 deletions(-) diff --git a/PROTO.md b/PROTO.md index f78e30c..3f19fec 100644 --- a/PROTO.md +++ b/PROTO.md @@ -41,7 +41,7 @@ gh_cli.py # vendored async `gh` runner: binary resolution (PATH + status.py # the first-run probe: compute_status / summarize_status / report_gaps (setup-gap seam) projects.py # repo sources: host projects: registry (ADR 0095) + checkout `origin` remote parsing read_tools.py # 16 read tools (6 ported core + file/contents/pr-file/path-exists/pr-diff + status + list_prs/comments/search + review_threads) -write_tools.py # 10 write tools (create/edit/merge/close/comment/labels/assignees + reply/resolve review threads) — gated +write_tools.py # 12 write tools (create/edit issue+pr/merge/close/comment/labels+create-label/assignees + reply/resolve review threads) — gated review_tools.py # 3 verdict tools (comment/approve/request-changes, guarded) — gated gh_issue.py # /issue chat command logic + repo resolution (resolve_repo, default_repo_error) api.py # routers — public PAGES (view/new-issue) + gated data routes (config/status/issues/prs/issue) @@ -88,7 +88,7 @@ data routes, the `token` secret — reads through them per call. Never capture a value at register time: an `onboard_project` mid-session, or a token pasted in Settings, must be seen by the very next call. -## 5. Tools (all implemented — 16 read / 10 write / 3 review = 29) +## 5. Tools (all implemented — 16 read / 12 write / 3 review = 31) Each tool mocks `run_gh` in its test and asserts the exact argv + readable errors. `tests/test_no_raise_sweep.py` additionally invokes EVERY registered tool against a @@ -142,9 +142,12 @@ the label. The section regexes are in LOCKSTEP with `.github/workflows/issue-gat in protoAgent — change both, `test_section_regexes_match_protoagents_ci_gate` pins a sample per alternative) / `github_comment` / `github_create_pr` (return the new URL), `github_edit_pr` (`gh pr edit` + `gh pr ready [--undo]`), +`github_edit_issue` (`gh issue edit` title/body — refuses with an Error when neither is given), `github_merge_pr` (`gh pr merge` — **refuses without `confirm=true`**, offers `dry_run`), -`github_close` (close/reopen issue|pr), and `github_set_labels` / `github_set_assignees` -(`gh {issue,pr} edit --add/--remove-{label,assignee}`). Issue-vs-PR ops take `kind`. +`github_close` (close/reopen issue|pr), `github_set_labels` / `github_set_assignees` +(`gh {issue,pr} edit --add/--remove-{label,assignee}`), and `github_create_label` +(`gh label create` — never `--force`; an existing label is reported, not overwritten). +Issue-vs-PR ops take `kind`. **Review (gated on `github.write`)** — `github_review_comment` (always allowed), `github_review_approve` / `github_review_request_changes` (refused while CI is pending diff --git a/README.md b/README.md index 3f9cdfe..e29a1e1 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,20 @@ issue or PR), `github_review_threads` (a PR's resolvable review threads with the (is `gh` installed / signed in, as whom, which default repo — the self-diagnosis probe the model calls when another tool errors). -**Write** (only when `github.write: true`, 10): `github_create_issue` (body-**gated** — +**Write** (only when `github.write: true`, 12): `github_create_issue` (body-**gated** — the same Problem / repro / acceptance sections the `/issue` command requires; a thin body gets the scaffold back, never posted), `github_comment`, `github_create_pr`, -`github_edit_pr`, `github_merge_pr` (`confirm`-guarded), `github_close`, -`github_set_labels`, `github_set_assignees`, `github_reply_thread` (answer a review -thread in place, optionally resolving it) and `github_resolve_thread` (resolve / unresolve). +`github_edit_pr`, `github_edit_issue` (change an issue's title/body), `github_merge_pr` +(`confirm`-guarded), `github_close`, `github_set_labels`, `github_create_label` (add a +label, never `--force`; an existing one is reported, not overwritten), +`github_set_assignees`, `github_reply_thread` (answer a review thread in place, +optionally resolving it) and `github_resolve_thread` (resolve / unresolve). **Review** (also behind `github.write`, 3): `github_review_comment`, `github_review_approve`, `github_review_request_changes` — the formal verdict tools, with the CI-terminal and self-review guards enforced inside the tool (ADR 0078). -29 tools in all, every one covered by the no-raise sweep. +31 tools in all, every one covered by the no-raise sweep. Plus the **user-only `/issue` chat command** (file an issue from the composer on any agent, without the model) and two console views — the read-only Issues/PRs **board** diff --git a/tests/test_no_raise_sweep.py b/tests/test_no_raise_sweep.py index c7d8092..8d31f26 100644 --- a/tests/test_no_raise_sweep.py +++ b/tests/test_no_raise_sweep.py @@ -47,9 +47,11 @@ "github_comment": {"repo": "o/n", "number": 1, "body": "b"}, "github_create_pr": {"repo": "o/n", "head": "h", "title": "t"}, "github_edit_pr": {"repo": "o/n", "number": 1, "title": "t", "state": "ready"}, + "github_edit_issue": {"repo": "o/n", "number": 1, "title": "t"}, "github_merge_pr": {"repo": "o/n", "number": 1, "confirm": True}, "github_close": {"repo": "o/n", "number": 1}, "github_set_labels": {"repo": "o/n", "number": 1, "add": "bug"}, + "github_create_label": {"repo": "o/n", "name": "bug"}, "github_set_assignees": {"repo": "o/n", "number": 1, "add": "kj"}, "github_reply_thread": {"thread_id": "PRRT_abcdef123", "body": "b", "resolve": True}, "github_resolve_thread": {"thread_id": "PRRT_abcdef123"}, diff --git a/tests/test_register.py b/tests/test_register.py index f689541..b9bb3d2 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -33,9 +33,11 @@ "github_comment", "github_create_pr", "github_edit_pr", + "github_edit_issue", "github_merge_pr", "github_close", "github_set_labels", + "github_create_label", "github_set_assignees", "github_reply_thread", "github_resolve_thread", @@ -189,15 +191,15 @@ async def test_configured_default_repo_skips_the_picker_computation(make_registr def test_inventory_counts_match_the_docs(make_registry): - """README / PROTO.md say 16 read / 10 write / 3 review = 29. Recount from what + """README / PROTO.md say 16 read / 12 write / 3 review = 31. Recount from what register() actually produces so the docs can't drift from the code again.""" reg = make_registry({"write": True}) register(reg) names = set(reg.tool_names) review = {"github_review_comment", "github_review_approve", "github_review_request_changes"} - assert len(READ_TOOLS) == 16 and len(WRITE_TOOLS) == 10 and len(review) == 3 - assert names == READ_TOOLS | WRITE_TOOLS | review and len(names) == 29 + assert len(READ_TOOLS) == 16 and len(WRITE_TOOLS) == 12 and len(review) == 3 + assert names == READ_TOOLS | WRITE_TOOLS | review and len(names) == 31 root = Path(__file__).resolve().parent.parent - assert "16 read / 10 write / 3 review = 29" in (root / "PROTO.md").read_text(), "PROTO.md inventory stale" + assert "16 read / 12 write / 3 review = 31" in (root / "PROTO.md").read_text(), "PROTO.md inventory stale" readme = (root / "README.md").read_text() - assert "**Read** (always, 16)" in readme and "29 tools in all" in readme, "README inventory stale" + assert "**Read** (always, 16)" in readme and "31 tools in all" in readme, "README inventory stale" diff --git a/tests/test_write_tools.py b/tests/test_write_tools.py index da7b434..6710a81 100644 --- a/tests/test_write_tools.py +++ b/tests/test_write_tools.py @@ -331,6 +331,64 @@ async def test_edit_pr_requires_a_change_and_validates_state(): fake.assert_not_called() +# --- github_edit_issue ------------------------------------------------------- + + +async def test_edit_issue_title_only(): + """Title only → `gh issue edit ` with --title and NO --body.""" + tool = _by_name("github_edit_issue") + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/3\n", "")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "number": 3, "title": "New title"}) + assert out == "https://github.com/o/n/issues/3" + args = fake.call_args.args[0] + assert args[:5] == ["issue", "edit", "3", "--repo", "o/n"] + assert args[args.index("--title") + 1] == "New title" + assert "--body" not in args + + +async def test_edit_issue_body_only(): + """Body only → --body and NO --title.""" + tool = _by_name("github_edit_issue") + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/4", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "number": 4, "body": "Rewritten body"}) + args = fake.call_args.args[0] + assert args[:5] == ["issue", "edit", "4", "--repo", "o/n"] + assert args[args.index("--body") + 1] == "Rewritten body" + assert "--title" not in args + + +async def test_edit_issue_title_and_body(): + """Both → both flags, one `gh issue edit` call.""" + tool = _by_name("github_edit_issue") + fake = AsyncMock(return_value=(0, "https://github.com/o/n/issues/5", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "number": 5, "title": "T", "body": "B"}) + args = fake.call_args.args[0] + assert args[:5] == ["issue", "edit", "5", "--repo", "o/n"] + assert args[args.index("--title") + 1] == "T" + assert args[args.index("--body") + 1] == "B" + + +async def test_edit_issue_requires_a_change_and_runs_nothing(): + """Neither title nor body → an Error string, and gh is never invoked.""" + tool = _by_name("github_edit_issue") + fake = AsyncMock() + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "number": 1}) + assert out.startswith("Error: nothing to change") + fake.assert_not_called() + + +async def test_edit_issue_gh_failure_returns_check_gh_error(): + tool = _by_name("github_edit_issue") + fake = AsyncMock(return_value=(1, "", "could not edit issue: forbidden")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "number": 1, "title": "t"}) + assert out == "Error (gh exit 1): could not edit issue: forbidden" + + # --- github_merge_pr (guarded) ---------------------------------------------- @@ -402,6 +460,16 @@ async def test_reopen_ignores_comment_and_uses_reopen(): assert "--comment" not in args +async def test_reopen_issue_invokes_gh_issue_reopen(): + """github_close(reopen=true, kind="issue") → `gh issue reopen ` (confirming the + issue's ask explicitly, kind spelled out).""" + tool = _by_name("github_close") + fake = AsyncMock(return_value=(0, "", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "number": 5, "reopen": True, "kind": "issue"}) + assert fake.call_args.args[0] == ["issue", "reopen", "5", "--repo", "o/n"] + + async def test_close_validates_kind(): tool = _by_name("github_close") fake = AsyncMock() @@ -445,14 +513,72 @@ async def test_set_assignees_pr_kind_add_and_remove(): assert [args[i + 1] for i, a in enumerate(args) if a == "--remove-assignee"] == ["carol"] +# --- github_create_label ----------------------------------------------------- + + +async def test_create_label_minimal_argv(): + """`gh label create --repo …` with no color/description flags when omitted.""" + tool = _by_name("github_create_label") + fake = AsyncMock(return_value=(0, "", "")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "name": "needs-triage"}) + args = fake.call_args.args[0] + assert args == ["label", "create", "needs-triage", "--repo", "o/n"] + assert out == "Created label 'needs-triage' in o/n." + + +async def test_create_label_with_color_and_description(): + """Optional color/description become --color / --description flags.""" + tool = _by_name("github_create_label") + fake = AsyncMock(return_value=(0, "", "")) + with patch("ghplugin.write_tools.run_gh", fake): + await tool.ainvoke({"repo": "o/n", "name": "bug", "color": "E99695", "description": "Something broke"}) + args = fake.call_args.args[0] + assert args[:5] == ["label", "create", "bug", "--repo", "o/n"] + assert args[args.index("--color") + 1] == "E99695" + assert args[args.index("--description") + 1] == "Something broke" + assert "--force" not in args + + +async def test_create_label_already_exists_is_readable_not_an_error_trace(): + """An existing label (gh fails with an "already exists" stderr) returns a readable + message, not the generic `Error (gh exit N): …` trace.""" + tool = _by_name("github_create_label") + fake = AsyncMock(return_value=(1, "", 'label with name "bug" already exists')) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "name": "bug"}) + assert "already exists" in out and "o/n" in out + assert not out.startswith("Error (gh exit") + + +async def test_create_label_requires_a_name(): + tool = _by_name("github_create_label") + fake = AsyncMock() + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "name": " "}) + assert out.startswith("Error:") and "name" in out + fake.assert_not_called() + + +async def test_create_label_gh_failure_returns_check_gh_error(): + """A non-"already exists" failure still goes through check_gh_error.""" + tool = _by_name("github_create_label") + fake = AsyncMock(return_value=(1, "", "could not create label: forbidden")) + with patch("ghplugin.write_tools.run_gh", fake): + out = await tool.ainvoke({"repo": "o/n", "name": "bug"}) + assert out == "Error (gh exit 1): could not create label: forbidden" + + async def test_new_write_tools_bad_repo_short_circuits(): """Every new write tool validates the repo before shelling out.""" fake = AsyncMock() cases = [ ("github_edit_pr", {"repo": "x", "number": 1, "title": "t"}), + ("github_edit_issue", {"repo": "x", "number": 1, "title": "t"}), ("github_merge_pr", {"repo": "x", "number": 1, "confirm": True}), ("github_close", {"repo": "x", "number": 1}), ("github_set_labels", {"repo": "x", "number": 1, "add": "bug"}), + ("github_create_label", {"repo": "x", "name": "bug"}), ("github_set_assignees", {"repo": "x", "number": 1, "add": "alice"}), ] for name, kwargs in cases: diff --git a/write_tools.py b/write_tools.py index b0888dc..ba34a96 100644 --- a/write_tools.py +++ b/write_tools.py @@ -12,11 +12,15 @@ - github_comment — `gh issue comment` (works for PRs too — a PR is an issue). - github_create_pr — `gh pr create` (returns the new PR URL). - github_edit_pr — `gh pr edit` (+ `gh pr ready`) to change title/body/draft. + - github_edit_issue — `gh issue edit` to change an issue's title and/or body + (refuses when neither is given, never posting an empty edit). - github_merge_pr — `gh pr merge`. IRREVERSIBLE, so it refuses without an explicit ``confirm=true`` and offers a ``dry_run`` preview (defence-in-depth on top of the per-agent write gate). - github_close — close/reopen an issue or PR (`gh {issue,pr} close|reopen`). - github_set_labels — add/remove labels (`gh {issue,pr} edit --add/--remove-label`). + - github_create_label — `gh label create` a new label (never `--force`; an existing + label is reported as such, not overwritten). - github_set_assignees — add/remove assignees (`gh {issue,pr} edit --add/--remove-assignee`). - github_reply_thread — reply INSIDE a PR review thread (GraphQL addPullRequestReviewThreadReply), optionally resolving it in the same call. @@ -252,6 +256,33 @@ async def github_edit_pr(number: int, repo: str = "", title: str = "", body: str url = url or out.strip() return url or f"Edited PR #{number} in {repo}." + @tool + async def github_edit_issue(number: int, repo: str = "", title: str = "", body: str = "") -> str: + """Edit a GitHub issue's title and/or body. + + Args: + repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. + number: Issue number. + title: New title (omit to leave unchanged). + body: New body / description, Markdown (omit to leave unchanged). + + Returns the issue URL (gh's stdout), or an Error when neither title nor body is given. + """ + repo = resolve_repo(repo, default_repo) or "" + if err := bad_repo(repo): + return err + if not (title or body): + return "Error: nothing to change — pass title and/or body." + args = ["issue", "edit", str(number), "--repo", repo] + if title: + args += ["--title", title] + if body: + args += ["--body", body] + rc, out, serr = await run_gh(args) + if gh_err := check_gh_error(rc, serr, repo=repo): + return gh_err + return out.strip() or f"Edited issue #{number} in {repo}." + @tool async def github_merge_pr( number: int, @@ -360,6 +391,39 @@ async def github_set_labels( return gh_err return out.strip() or f"Updated labels on {kind} #{number} in {repo}." + @tool + async def github_create_label(name: str, repo: str = "", color: str = "", description: str = "") -> str: + """Create a new label in a repository (`gh label create`). Does NOT overwrite an + existing label — no ``--force`` — so a label that already exists is reported as + such, not an error trace, and its color/description are left untouched. Use this + before ``github_set_labels`` when the label you want to apply doesn't exist yet. + + Args: + repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. + name: The label name. + color: Optional 6-character hex color (no leading ``#``); gh picks a random one if omitted. + description: Optional label description. + + Returns a short confirmation, or a readable "already exists" message. + """ + repo = resolve_repo(repo, default_repo) or "" + if err := bad_repo(repo): + return err + if not (name or "").strip(): + return "Error: label name is required." + args = ["label", "create", name, "--repo", repo] + if color: + args += ["--color", color] + if description: + args += ["--description", description] + rc, out, serr = await run_gh(args) + low = (serr or "").lower() + if rc != 0 and ("already exists" in low or "already_exists" in low): + return f"Label {name!r} already exists in {repo} — not overwritten (github_create_label never forces)." + if gh_err := check_gh_error(rc, serr, repo=repo): + return gh_err + return out.strip() or f"Created label {name!r} in {repo}." + @tool async def github_set_assignees( number: int, repo: str = "", add: str = "", remove: str = "", kind: str = "issue" @@ -451,9 +515,11 @@ async def github_resolve_thread(thread_id: str, unresolve: bool = False) -> str: github_comment, github_create_pr, github_edit_pr, + github_edit_issue, github_merge_pr, github_close, github_set_labels, + github_create_label, github_set_assignees, github_reply_thread, github_resolve_thread,