From 87eb244d0adb000b75bd3ecc394d481688247ca1 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 4 Jul 2026 00:16:34 -0700 Subject: [PATCH] fix: github_get_pr never surfaced the PR's head branch (v0.1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer that wants to inspect a PR's actual files (github_read_file / github_repo_contents, both ref-keyed) has no other way to learn the real branch name — github_get_pr's --json field list didn't request headRefName/ baseRefName at all. Caught live: an agent trying to read a PR's files guessed a plausible branch name from the PR title instead of reading it (got "proto/feature/" instead of the real "feat/bd-3a4") and hit two 404s. Co-Authored-By: Claude Sonnet 5 --- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- read_tools.py | 5 +++-- tests/test_read_tools.py | 48 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 6b5335d..a2405ff 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -2,7 +2,7 @@ # Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it). id: github name: GitHub (read/write tools) -version: 0.1.4 +version: 0.1.5 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 diff --git a/pyproject.toml b/pyproject.toml index 8fb379d..8f364a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-plugin" -version = "0.1.4" +version = "0.1.5" description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating." requires-python = ">=3.11" diff --git a/read_tools.py b/read_tools.py index 125b96e..9cb4cc9 100644 --- a/read_tools.py +++ b/read_tools.py @@ -31,7 +31,7 @@ def get_read_tools(default_repo: str = "") -> list: @tool async def github_get_pr(number: int, repo: str = "") -> str: - """Fetch a GitHub pull request: title, state, author, body, and changed files. + """Fetch a GitHub pull request: title, state, author, body, branch, and changed files. Args: repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. @@ -48,7 +48,7 @@ async def github_get_pr(number: int, repo: str = "") -> str: "--repo", repo, "--json", - "number,title,state,author,body,additions,deletions,files,url", + "number,title,state,author,body,additions,deletions,files,url,headRefName,baseRefName", ] ) if gh_err := check_gh_error(rc, serr): @@ -60,6 +60,7 @@ async def github_get_pr(number: int, repo: str = "") -> str: files = ", ".join(f.get("path", "?") for f in (d.get("files") or [])[:20]) return ( f"PR #{d.get('number')} [{d.get('state')}] {d.get('title')}\n" + f"branch: {d.get('headRefName', '?')} -> {d.get('baseRefName', '?')}\n" f"by {(d.get('author') or {}).get('login', '?')} | " f"+{d.get('additions', 0)}/-{d.get('deletions', 0)} | {d.get('url')}\n" f"files: {files or '(none)'}\n\n{(d.get('body') or '').strip()[:2000]}" diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 33e40d1..a91b3ec 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -148,6 +148,54 @@ async def test_repo_contents_bad_json_returns_parse_error(): assert result.startswith("Error: could not parse gh output") +# ── github_get_pr: the head branch must be readable, not guessed ───────────────── +# A consumer that wants a file FROM the PR (github_read_file/github_repo_contents, +# both `ref`-keyed) has no other way to learn the real branch name — omitting it +# here means the caller has to guess one from the PR title, which is exactly how +# a real PR lookup once 404'd (guessed "proto/feature/" instead of +# the actual "feat/bd-3a4"). +def _get_pr_tool(): + for t in get_read_tools(): + if t.name == "github_get_pr": + return t + raise AssertionError("github_get_pr tool not found") + + +_PR_JSON = json.dumps( + { + "number": 38, + "title": "feat: fix the ephemeral label", + "state": "OPEN", + "author": {"login": "roxy"}, + "body": "fixed it", + "additions": 10, + "deletions": 2, + "files": [{"path": "view.py"}], + "url": "https://github.com/owner/name/pull/38", + "headRefName": "feat/bd-3a4", + "baseRefName": "main", + } +) + + +@pytest.mark.asyncio +async def test_get_pr_requests_head_and_base_ref(): + mock = AsyncMock(return_value=(0, _PR_JSON, "")) + with patch("ghplugin.read_tools.run_gh", mock): + await _get_pr_tool().ainvoke({"repo": "owner/name", "number": 38}) + args = mock.call_args.args[0] + json_arg = args[args.index("--json") + 1] + assert "headRefName" in json_arg + assert "baseRefName" in json_arg + + +@pytest.mark.asyncio +async def test_get_pr_surfaces_the_branch_in_its_output(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, _PR_JSON, ""))): + result = await _get_pr_tool().ainvoke({"repo": "owner/name", "number": 38}) + assert "feat/bd-3a4 -> main" in result + + # ── default_repo fallback (omit repo → use the configured default) ─────────────── def _list_issues_tool(default_repo=""): for t in get_read_tools(default_repo):