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):