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.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
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.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"

Expand Down
5 changes: 3 additions & 2 deletions read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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]}"
Expand Down
48 changes: 48 additions & 0 deletions tests/test_read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug-of-title>" 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):
Expand Down
Loading