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
2 changes: 1 addition & 1 deletion protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.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"

Expand Down
23 changes: 23 additions & 0 deletions read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions tests/test_read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading