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
28 changes: 24 additions & 4 deletions PROTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ gh_cli.py # vendored async `gh` runner: binary resolution (PATH +
# injection (config secret > env), check_gh_error CLASSIFICATION, bad_repo
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 # 12 read tools (6 ported core + file/contents/pr-file/path-exists/pr-diff + github_status)
read_tools.py # 15 read tools (6 ported core + file/contents/pr-file/path-exists/pr-diff + status + list_prs/comments/search)
write_tools.py # 8 write tools (create/edit/merge/close/comment/labels/assignees) — 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)
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 — 12 read / 8 write / 3 review = 23)
## 5. Tools (all implemented — 15 read / 8 write / 3 review = 26)

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 All @@ -102,10 +102,30 @@ sweep covers it automatically (it enumerates `register()`'s output).
— a file at a PR's head; `github_repo_contents` — directory listing, says "is a file —
use github_read_file" on a file; `github_path_exists` — the EXISTS/MISSING probe), and
`github_status` — is `gh` installed / authenticated / as whom / which default repo, the
self-diagnosis tool the model calls when another tool errors (no `write` gate).
self-diagnosis tool the model calls when another tool errors (no `write` gate), and the
PM verbs (v0.7.0): `github_list_prs` (reuses `api.fetch_prs`, so the tool and the board
can't disagree — draft / review decision / merge state per row), `github_issue_comments`
(`gh issue view --json comments`; works on PRs; newest `limit` in chronological order,
bodies ≤ 1000 chars), `github_search_issues` (`gh search issues --repo … -- <query>`: flags first, the query
LAST after `--` so a leading qualifier like `-label:bug` isn't read as a flag; `state:
all` = no `--state` flag, gh only knows open|closed — documented as "dedupe before
filing").
`github_get_pr` carries the merge-readiness picture: `reviewDecision`, `mergeable`,
`mergeStateStatus`, `statusCheckRollup` summarised (N pass / fail / pending + the
failing names — both the CheckRun and StatusContext shapes), `latestReviews` (ONE per
reviewer, their most recent — so bot COMMENTED reviews can't bury a human's
CHANGES_REQUESTED; older gh falls back to the NEWEST `reviews`), `isDraft`; total output
bounded at 12k chars (`github_issue_comments` too).

**Write (gated on `github.write`)** —
`github_create_issue` / `github_comment` / `github_create_pr` (return the new URL),
`github_create_issue` (**body-gated**: the SAME `missing_sections` gate the `/issue`
command enforces — a thin body, or a `bug` without repro / a `feature` without a
direction-or-acceptance section, is refused with the scaffold and never posted; `kind`
also adds the type label via `labels_for`, and a `generic` call whose labels carry
`bug` / `enhancement` is gated as that kind because protoAgent's CI issue gate keys on
the label. The section regexes are in LOCKSTEP with `.github/workflows/issue-gate.yml`
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_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`
Expand Down
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,28 @@ read-only in-tree `github` plugin.

## Tools (all implemented)

**Read** (always, 12): `github_get_pr`, `github_get_issue`, `github_list_issues`,
`github_get_commit_diff`, `github_pr_diff`, `github_ci_runs`, `github_run_failure`,
`github_read_file`, `github_read_pr_file`, `github_repo_contents`, `github_path_exists`,
and `github_status` (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`, 8): `github_create_issue`, `github_comment`,
`github_create_pr`, `github_edit_pr`, `github_merge_pr` (`confirm`-guarded),
`github_close`, `github_set_labels`, `github_set_assignees`.
**Read** (always, 15): `github_get_pr` (the merge-readiness picture: review decision,
mergeability, a checks summary with the failing check names, the reviews),
`github_list_prs` (the PR board: draft / review decision / merge state per row),
`github_get_issue`, `github_list_issues`, `github_issue_comments` (the thread on an
issue or PR), `github_search_issues` (dedupe **before** filing), `github_get_commit_diff`,
`github_pr_diff`, `github_ci_runs`, `github_run_failure`, `github_read_file`,
`github_read_pr_file`, `github_repo_contents`, `github_path_exists`, and `github_status`
(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`, 8): `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`.

**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).

26 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**
and the **New issue** form — both of which show a **setup card** when `gh` is missing
Expand Down
25 changes: 13 additions & 12 deletions api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
from __future__ import annotations

import asyncio
import json

from .gh_cli import bad_repo, check_gh_error, resolve_gh, run_gh
from .gh_cli import bad_repo, check_gh_error, parse_json, resolve_gh, run_gh

# The JSON fields we ask `gh` for — kept lean: enough for a board row + the detail link.
_ISSUE_FIELDS = "number,title,state,author,labels,url,createdAt,comments"
_PR_FIELDS = "number,title,state,author,labels,url,createdAt,isDraft,headRefName,reviewDecision"
# `baseRefName` + `mergeStateStatus` are for `github_list_prs` (read_tools reuses fetch_prs);
# the board rows simply ignore them.
_PR_FIELDS = (
"number,title,state,author,labels,url,createdAt,isDraft,headRefName,baseRefName,reviewDecision,mergeStateStatus"
)


def gh_available() -> bool:
Expand Down Expand Up @@ -57,16 +60,16 @@ async def fetch_issues(repo: str, state: str = "open", limit: int = 30) -> dict:
)
if gh_err := check_gh_error(rc, serr, repo=repo):
return {"error": gh_err}
try:
return {"items": json.loads(out or "[]")}
except json.JSONDecodeError:
return {"error": f"Error: could not parse gh output: {out[:200]}"}
items, perr = parse_json(out or "[]", list) # a non-list body is an error, never a crash
return {"error": perr} if perr else {"items": items}


async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict:
"""List pull requests for ``repo`` as ``{"items": [...]}`` (or ``{"error": "..."}``).

Each item is the raw `gh pr list --json` row (adds isDraft/headRefName/reviewDecision).
Each item is the raw `gh pr list --json` row (adds isDraft/headRefName/baseRefName/
reviewDecision/mergeStateStatus). Shared by the board's /prs route and the
`github_list_prs` tool, so the two can never disagree about a PR's state.
"""
if err := bad_repo(repo):
return {"error": err}
Expand All @@ -79,10 +82,8 @@ async def fetch_prs(repo: str, state: str = "open", limit: int = 30) -> dict:
)
if gh_err := check_gh_error(rc, serr, repo=repo):
return {"error": gh_err}
try:
return {"items": json.loads(out or "[]")}
except json.JSONDecodeError:
return {"error": f"Error: could not parse gh output: {out[:200]}"}
items, perr = parse_json(out or "[]", list) # a non-list body is an error, never a crash
return {"error": perr} if perr else {"items": items}


def _repos(cfg: dict) -> list[str]:
Expand Down
8 changes: 6 additions & 2 deletions gh_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,12 @@ def parse_json(out: str, expect: type | tuple[type, ...] = dict) -> tuple[Any, s


def dicts(items) -> list[dict]:
"""Only the dict rows of a `gh --json` list — nulls/scalars in a row list are skipped."""
return [x for x in (items or []) if isinstance(x, dict)]
"""Only the dict rows of a `gh --json` list — nulls/scalars in a row list are skipped,
and a value that isn't a list at all (a nested scalar like ``"reviews": 42``) is ``[]``.
Total by design: a tool feeds it any nested field without a type check of its own."""
if not isinstance(items, list):
return []
return [x for x in items if isinstance(x, dict)]


# ── error classification ──────────────────────────────────────────────────────────
Expand Down
48 changes: 40 additions & 8 deletions gh_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
so a person can file from the composer on any agent — including a read-only one —
without the model being involved. The `github_create_issue` AGENT tool exists too
(write_tools.py), behind the per-agent `github.write` gate: a PM/coding agent with
write on can file autonomously; a research agent without it cannot. Both paths share
`file_issue` below, so the gate check and the `gh issue create` argv can't diverge.
This module is the pure, host-free logic.
write on can file autonomously; a research agent without it cannot. Both paths run
the SAME body gate (`missing_sections` / `infer_kind` / `labels_for` below), so what
the command refuses the tool refuses — and what passes here passes protoAgent's CI
issue gate too (the regexes are kept in lockstep with it). This module is the pure,
host-free logic.

`run_issue_command(rest, *, default_repo)` takes everything after the `/issue` token
(the host already matched it) and returns the reply string. The issue body is checked
Expand Down Expand Up @@ -36,12 +38,19 @@

log = logging.getLogger("protoagent.plugins.github")

# Section detectors — kept in lockstep with the host CI gate's regexes so the local
# check and the server-side gate can never disagree about what "conforms" means.
# Section detectors — kept in LOCKSTEP with protoAgent's CI issue gate
# (.github/workflows/issue-gate.yml: hasProblem / hasRepro / hasProposal / hasAcceptance)
# so the local check and the server-side gate can never disagree about what
# "conforms" means: a body this gate passes must pass CI, and vice versa. When the
# host's regexes change, change these — tests/test_gh_issue.py pins a sample per
# alternative.
_SECTION_RES = {
"problem": re.compile(r"problem|what'?s? wrong|motivation|background|context|summary", re.I),
"repro": re.compile(r"repro|reproduce|steps|evidence|expected|actual|observed", re.I),
"proposal": re.compile(r"propos|solution|approach|direction|fix|design", re.I),
"problem": re.compile(
r"problem|what'?s? wrong|motivation|background|context|summary|observed|symptom|idea|current behavior|\bwhat\b",
re.I,
),
"repro": re.compile(r"repro|reproduce|steps|evidence|expected|actual|observed|symptom|root cause", re.I),
"proposal": re.compile(r"propos|solution|approach|direction|fix|design|\bwork\b|plan", re.I),
"acceptance": re.compile(r"acceptance|done when|success criteria|definition of done", re.I),
}
# A heading (#..######) or a bold line (**…**) — same shape the gate matches.
Expand Down Expand Up @@ -86,6 +95,12 @@ def _scaffold(kind: str) -> str:
return {"bug": _BUG_SCAFFOLD, "feature": _FEATURE_SCAFFOLD}.get(kind, _GENERIC_SCAFFOLD)


def scaffold_for(kind: str) -> str:
"""The fill-in scaffold for an issue ``kind`` — what both the `/issue` command and
the `github_create_issue` tool hand back when the body fails the gate."""
return _scaffold(kind)


def missing_sections(body: str, kind: str) -> list[str]:
"""The gate-required sections absent from ``body`` for this issue ``kind``."""
miss: list[str] = []
Expand All @@ -102,6 +117,22 @@ def missing_sections(body: str, kind: str) -> list[str]:
return miss


def infer_kind(kind: str, labels: list[str] | None = None) -> str:
"""The gate ``kind`` to enforce: an explicit ``bug`` / ``feature`` wins; a
``generic`` request carrying a ``bug`` / ``enhancement`` LABEL is promoted to
that kind — because the CI gate keys on the labels, so a ``labels="bug"`` issue
filed without a repro section would be refused server-side anyway."""
k = (kind or "generic").strip().lower()
if k in ("bug", "feature"):
return k
low = {(lbl or "").strip().lower() for lbl in (labels or [])}
if "bug" in low:
return "bug"
if "enhancement" in low:
return "feature"
return "generic"


def labels_for(kind: str, extra: list[str] | None = None) -> list[str]:
"""Labels for an issue of this ``kind`` — the type label first (``bug`` /
``enhancement``), then any extras, de-duped in order."""
Expand Down Expand Up @@ -268,6 +299,7 @@ def _parse(rest: str, *, default_repo: str = "") -> IssueRequest | str:
i += 1

title = " ".join(title_parts).strip()
kind = infer_kind(kind, labels) # `--label bug` demands the repro section, like CI
labels = labels_for(kind, labels)
explicit_repo = repo
repo = resolve_repo(repo, default_repo)
Expand Down
7 changes: 4 additions & 3 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
# Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it).
id: github
name: GitHub (read/write tools)
version: 0.6.0
version: 0.7.0
description: >-
Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The
read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write
read tools (PRs — list + merge readiness, issues, comments, search-before-filing,
diffs, CI, repo files/contents) are always on; the write
tools (create/edit/merge/close issues & PRs, comment, labels, assignees) load ONLY
when `github.write: true` — so a research/Lead agent stays read-only while a
coding/PM agent gets write, purely by its own per-instance config (ADR 0019).
Merging is `confirm`-guarded. Supersedes the read-only in-tree `github` plugin.
Merging is `confirm`-guarded; issue creation is body-gated like `/issue`. Supersedes the read-only in-tree `github` plugin.
Tools degrade to a readable, classified error (not authenticated / repo not found /
rate-limited / `gh` missing) — and `github_status` + a setup card in the views say
exactly what to fix on a fresh machine.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "github-plugin"
version = "0.6.0"
version = "0.7.0"
description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating."
requires-python = ">=3.11"

Expand Down
Loading
Loading