Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions PROTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 2 additions & 0 deletions tests/test_no_raise_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
12 changes: 7 additions & 5 deletions tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
126 changes: 126 additions & 0 deletions tests/test_write_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` 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) ----------------------------------------------


Expand Down Expand Up @@ -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 <n>` (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()
Expand Down Expand Up @@ -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 <name> --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:
Expand Down
66 changes: 66 additions & 0 deletions write_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
Loading