From 75f31921bcb69fd00c4f93453d950b3dcfd1131e Mon Sep 17 00:00:00 2001 From: GitHub CI Date: Mon, 6 Jul 2026 00:18:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(read-tools):=20github=5Fpr=5Fdiff=20?= =?UTF-8?q?=E2=80=94=20a=20PR's=20full=20unified=20diff=20(v0.1.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial review workflow's diff source (delivery-loop plan M1/F1.1, protoAgent ADR 0077): review-finder subagents read the change itself, not just the PR metadata github_get_pr returns. `gh pr diff` via run_gh, default-repo fallback, max_chars truncation, readable Error degradation — same contract as the other read tools. Co-Authored-By: Claude Fable 5 --- protoagent.plugin.yaml | 2 +- pyproject.toml | 2 +- read_tools.py | 23 ++++++++++++++++++ tests/test_read_tools.py | 51 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index a2405ff..5cd1078 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.5 +version: 0.1.6 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 8f364a1..ef8e888 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "github-plugin" -version = "0.1.5" +version = "0.1.6" 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 9cb4cc9..61f6b3e 100644 --- a/read_tools.py +++ b/read_tools.py @@ -162,6 +162,28 @@ async def github_get_commit_diff(ref: str, repo: str = "", max_chars: int = 8000 diff = diff[:max_chars] + f"\n… (truncated at {max_chars} chars)" return f"Commit {repo}@{ref}:\n\n{diff}" + @tool + async def github_pr_diff(number: int, repo: str = "", max_chars: int = 12000) -> str: + """Fetch a pull request's full unified diff — for reviewing the change itself. + + Args: + repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. + number: PR number. + max_chars: Truncate the diff at this many characters (default 12000). + """ + repo = resolve_repo(repo, default_repo) or "" + if err := bad_repo(repo): + return err + rc, out, serr = await run_gh(["pr", "diff", str(number), "--repo", repo]) + if gh_err := check_gh_error(rc, serr): + return gh_err + diff = out.strip() + if not diff: + return f"No diff for {repo}#{number} (empty PR or diff unavailable)." + if len(diff) > max_chars: + diff = diff[:max_chars] + f"\n… (truncated at {max_chars} chars)" + return f"PR {repo}#{number} diff:\n\n{diff}" + @tool async def github_ci_runs(repo: str = "", branch: str = "", limit: int = 15) -> str: """List recent GitHub Actions runs for a repo — for CI triage. @@ -302,6 +324,7 @@ async def github_repo_contents(repo: str = "", path: str = "", ref: str = "") -> github_get_issue, github_list_issues, github_get_commit_diff, + github_pr_diff, github_ci_runs, github_run_failure, github_read_file, diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index a91b3ec..18dc543 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -196,6 +196,57 @@ async def test_get_pr_surfaces_the_branch_in_its_output(): assert "feat/bd-3a4 -> main" in result +# ── github_pr_diff (the review workflow's diff source) ─────────────────────────── +def _pr_diff_tool(default_repo=""): + for t in get_read_tools(default_repo): + if t.name == "github_pr_diff": + return t + raise AssertionError("github_pr_diff tool not found") + + +@pytest.mark.asyncio +async def test_pr_diff_returns_diff_via_gh_pr_diff(): + mock = AsyncMock(return_value=(0, "diff --git a/x b/x\n+added", "")) + with patch("ghplugin.read_tools.run_gh", mock): + result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 42}) + argv = mock.call_args.args[0] + assert argv[:3] == ["pr", "diff", "42"] + assert "--repo" in argv and "owner/name" in argv + assert "diff --git a/x b/x" in result + assert result.startswith("PR owner/name#42 diff:") + + +@pytest.mark.asyncio +async def test_pr_diff_truncates_at_max_chars(): + mock = AsyncMock(return_value=(0, "x" * 500, "")) + with patch("ghplugin.read_tools.run_gh", mock): + result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 1, "max_chars": 100}) + assert "truncated at 100 chars" in result + + +@pytest.mark.asyncio +async def test_pr_diff_empty_returns_note(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, " ", ""))): + result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 7}) + assert "No diff for owner/name#7" in result + + +@pytest.mark.asyncio +async def test_pr_diff_invalid_repo_returns_bad_repo_error(): + mock = AsyncMock(return_value=(0, "nope", "")) + with patch("ghplugin.read_tools.run_gh", mock): + result = await _pr_diff_tool().ainvoke({"repo": "bad", "number": 1}) + assert result.startswith("Error:") + mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_pr_diff_gh_error_is_surfaced(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(1, "", "gh: not found"))): + result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 1}) + assert result.startswith("Error") + + # ── default_repo fallback (omit repo → use the configured default) ─────────────── def _list_issues_tool(default_repo=""): for t in get_read_tools(default_repo):