Skip to content

Commit 2d14ba1

Browse files
mabry1985GitHub CIclaude
authored
feat(read-tools): github_pr_diff — a PR's full unified diff (v0.1.6) (#20)
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: GitHub CI <ci@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 65b18eb commit 2d14ba1

4 files changed

Lines changed: 76 additions & 2 deletions

File tree

‎protoagent.plugin.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Keep `version` in lockstep with pyproject.toml (tests/test_version.py asserts it).
33
id: github
44
name: GitHub (read/write tools)
5-
version: 0.1.5
5+
version: 0.1.6
66
description: >-
77
Read AND write GitHub tools over the `gh` CLI, with PER-AGENT write gating. The
88
read tools (PRs, issues, diffs, CI, repo files/contents) are always on; the write

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "github-plugin"
3-
version = "0.1.5"
3+
version = "0.1.6"
44
description = "Read/write GitHub tools for protoAgent over the gh CLI, with per-agent write gating."
55
requires-python = ">=3.11"
66

‎read_tools.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ async def github_get_commit_diff(ref: str, repo: str = "", max_chars: int = 8000
162162
diff = diff[:max_chars] + f"\n… (truncated at {max_chars} chars)"
163163
return f"Commit {repo}@{ref}:\n\n{diff}"
164164

165+
@tool
166+
async def github_pr_diff(number: int, repo: str = "", max_chars: int = 12000) -> str:
167+
"""Fetch a pull request's full unified diff — for reviewing the change itself.
168+
169+
Args:
170+
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
171+
number: PR number.
172+
max_chars: Truncate the diff at this many characters (default 12000).
173+
"""
174+
repo = resolve_repo(repo, default_repo) or ""
175+
if err := bad_repo(repo):
176+
return err
177+
rc, out, serr = await run_gh(["pr", "diff", str(number), "--repo", repo])
178+
if gh_err := check_gh_error(rc, serr):
179+
return gh_err
180+
diff = out.strip()
181+
if not diff:
182+
return f"No diff for {repo}#{number} (empty PR or diff unavailable)."
183+
if len(diff) > max_chars:
184+
diff = diff[:max_chars] + f"\n… (truncated at {max_chars} chars)"
185+
return f"PR {repo}#{number} diff:\n\n{diff}"
186+
165187
@tool
166188
async def github_ci_runs(repo: str = "", branch: str = "", limit: int = 15) -> str:
167189
"""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 = "") ->
302324
github_get_issue,
303325
github_list_issues,
304326
github_get_commit_diff,
327+
github_pr_diff,
305328
github_ci_runs,
306329
github_run_failure,
307330
github_read_file,

‎tests/test_read_tools.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,57 @@ async def test_get_pr_surfaces_the_branch_in_its_output():
196196
assert "feat/bd-3a4 -> main" in result
197197

198198

199+
# ── github_pr_diff (the review workflow's diff source) ───────────────────────────
200+
def _pr_diff_tool(default_repo=""):
201+
for t in get_read_tools(default_repo):
202+
if t.name == "github_pr_diff":
203+
return t
204+
raise AssertionError("github_pr_diff tool not found")
205+
206+
207+
@pytest.mark.asyncio
208+
async def test_pr_diff_returns_diff_via_gh_pr_diff():
209+
mock = AsyncMock(return_value=(0, "diff --git a/x b/x\n+added", ""))
210+
with patch("ghplugin.read_tools.run_gh", mock):
211+
result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 42})
212+
argv = mock.call_args.args[0]
213+
assert argv[:3] == ["pr", "diff", "42"]
214+
assert "--repo" in argv and "owner/name" in argv
215+
assert "diff --git a/x b/x" in result
216+
assert result.startswith("PR owner/name#42 diff:")
217+
218+
219+
@pytest.mark.asyncio
220+
async def test_pr_diff_truncates_at_max_chars():
221+
mock = AsyncMock(return_value=(0, "x" * 500, ""))
222+
with patch("ghplugin.read_tools.run_gh", mock):
223+
result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 1, "max_chars": 100})
224+
assert "truncated at 100 chars" in result
225+
226+
227+
@pytest.mark.asyncio
228+
async def test_pr_diff_empty_returns_note():
229+
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, " ", ""))):
230+
result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 7})
231+
assert "No diff for owner/name#7" in result
232+
233+
234+
@pytest.mark.asyncio
235+
async def test_pr_diff_invalid_repo_returns_bad_repo_error():
236+
mock = AsyncMock(return_value=(0, "nope", ""))
237+
with patch("ghplugin.read_tools.run_gh", mock):
238+
result = await _pr_diff_tool().ainvoke({"repo": "bad", "number": 1})
239+
assert result.startswith("Error:")
240+
mock.assert_not_called()
241+
242+
243+
@pytest.mark.asyncio
244+
async def test_pr_diff_gh_error_is_surfaced():
245+
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(1, "", "gh: not found"))):
246+
result = await _pr_diff_tool().ainvoke({"repo": "owner/name", "number": 1})
247+
assert result.startswith("Error")
248+
249+
199250
# ── default_repo fallback (omit repo → use the configured default) ───────────────
200251
def _list_issues_tool(default_repo=""):
201252
for t in get_read_tools(default_repo):

0 commit comments

Comments
 (0)