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
15 changes: 10 additions & 5 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,19 @@ def register(registry) -> None:
cfg = registry.config or {}
write_enabled = bool(cfg.get("write", False))

# The default repo the tools fall back to when their `repo` arg is omitted — the
# configured `default_repo`, else the first of `repos` (same resolution as /issue and
# the board). Tools are rebuilt on a config reload, so capturing it here is live enough.
from .gh_issue import effective_default_repo

default_repo = effective_default_repo(cfg.get("default_repo", ""), cfg.get("repos", []))

# READ tools — always available (they return an error string if `gh`/auth is missing).
n_read = 0
try:
from .read_tools import get_read_tools

read = get_read_tools()
read = get_read_tools(default_repo)
for t in read:
registry.register_tool(t)
n_read = len(read)
Expand All @@ -50,7 +57,7 @@ def register(registry) -> None:
try:
from .write_tools import get_write_tools

write = get_write_tools()
write = get_write_tools(default_repo)
for t in write:
registry.register_tool(t)
n_write = len(write)
Expand All @@ -63,9 +70,7 @@ def register(registry) -> None:
issue_cmd = False
if hasattr(registry, "register_chat_command"):
try:
from .gh_issue import effective_default_repo, run_issue_command

default_repo = effective_default_repo(cfg.get("default_repo", ""), cfg.get("repos", []))
from .gh_issue import run_issue_command

async def _issue(rest: str, session_id: str) -> str:
"""File a GitHub issue (user-only). Usage: /issue <title> [--bug|--feature] [--repo owner/name]."""
Expand Down
3 changes: 2 additions & 1 deletion gh_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def bad_repo(repo: str) -> str | None:
"""Validate an `owner/name` repo slug; return an Error string if invalid, else None."""
if not repo or not REPO_RE.match(repo):
return (
f"Error: 'repo' must be 'owner/name' (got {repo!r}). Pass it explicitly — there is no default repository."
f"Error: no usable repo (got {repo!r}). Pass repo='owner/name', or set a default "
"in Settings ▸ GitHub (github.default_repo / repos) so it can be omitted."
)
return None

Expand Down
51 changes: 32 additions & 19 deletions read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

Six are ported from protoAgent's tools/github_tools.py (PRs, issues, diffs, CI). Two
new ones (`github_read_file`, `github_repo_contents`) are STUBBED — the team builds
them out (see the TODOs). Each tool requires an explicit `owner/name` repo and
degrades to a readable `Error: ...` string when `gh`/auth is unavailable.
them out (see the TODOs). Each tool takes an `owner/name` repo — or falls back to the
configured default when it's omitted — and degrades to a readable `Error: ...` string
when `gh`/auth is unavailable.
"""

from __future__ import annotations
Expand All @@ -14,6 +15,7 @@
from langchain_core.tools import tool

from .gh_cli import bad_repo, check_gh_error, run_gh
from .gh_issue import resolve_repo

# Error-relevant lines to surface from a failed CI log (github_run_failure).
_CI_ERR_RE = re.compile(
Expand All @@ -23,15 +25,19 @@
)


def get_read_tools() -> list:
def get_read_tools(default_repo: str = "") -> list:
"""Build the read tools. ``default_repo`` (``owner/name``) is used whenever a tool's
``repo`` arg is omitted, so an agent with one configured repo needn't repeat it."""

@tool
async def github_get_pr(repo: str, number: int) -> str:
async def github_get_pr(number: int, repo: str = "") -> str:
"""Fetch a GitHub pull request: title, state, author, body, and changed files.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
number: PR number.
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
rc, out, serr = await run_gh(
Expand Down Expand Up @@ -60,13 +66,14 @@ async def github_get_pr(repo: str, number: int) -> str:
)

@tool
async def github_get_issue(repo: str, number: int) -> str:
async def github_get_issue(number: int, repo: str = "") -> str:
"""Fetch a GitHub issue: title, state, author, labels, and body.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
number: Issue number.
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
rc, out, serr = await run_gh(
Expand All @@ -86,14 +93,15 @@ async def github_get_issue(repo: str, number: int) -> str:
)

@tool
async def github_list_issues(repo: str, state: str = "open", limit: int = 20) -> str:
async def github_list_issues(repo: str = "", state: str = "open", limit: int = 20) -> str:
"""List GitHub issues for a repo.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
state: ``open`` | ``closed`` | ``all`` (default ``open``).
limit: Max issues to return (1-50, default 20).
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
if state not in ("open", "closed", "all"):
Expand Down Expand Up @@ -130,14 +138,15 @@ async def github_list_issues(repo: str, state: str = "open", limit: int = 20) ->
return "\n".join(lines)

@tool
async def github_get_commit_diff(repo: str, ref: str, max_chars: int = 8000) -> str:
async def github_get_commit_diff(ref: str, repo: str = "", max_chars: int = 8000) -> str:
"""Fetch a commit's metadata + unified diff.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
ref: Commit SHA (or ref) to inspect.
max_chars: Truncate the diff at this many characters (default 8000).
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
rc, out, serr = await run_gh(
Expand All @@ -153,16 +162,17 @@ async def github_get_commit_diff(repo: str, ref: str, max_chars: int = 8000) ->
return f"Commit {repo}@{ref}:\n\n{diff}"

@tool
async def github_ci_runs(repo: str, branch: str = "", limit: int = 15) -> str:
async def github_ci_runs(repo: str = "", branch: str = "", limit: int = 15) -> str:
"""List recent GitHub Actions runs for a repo — for CI triage.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
branch: Optional branch filter (e.g. ``main``).
limit: Max runs to return (capped at 50).

Feed a failing run's id to ``github_run_failure`` to see why it failed.
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
args = [
Expand Down Expand Up @@ -194,14 +204,15 @@ async def github_ci_runs(repo: str, branch: str = "", limit: int = 15) -> str:
return f"{repo} — {len(runs)} recent run(s):\n" + "\n".join(lines)

@tool
async def github_run_failure(repo: str, run_id: int, max_lines: int = 40) -> str:
async def github_run_failure(run_id: int, repo: str = "", max_lines: int = 40) -> str:
"""Explain why a GitHub Actions run failed — the error lines from its failed steps.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
run_id: The run id (``databaseId`` from ``github_ci_runs``).
max_lines: Cap on error lines returned (capped at 80).
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
cap = max(5, min(int(max_lines), 80))
Expand All @@ -226,11 +237,11 @@ async def github_run_failure(repo: str, run_id: int, max_lines: int = 40) -> str
# ── NEW read tools — STUBBED. Build these out (this is what lets an agent research
# any repo over `gh` without registering an fs project per repo). ────────────────
@tool
async def github_read_file(repo: str, path: str, ref: str = "") -> str:
async def github_read_file(path: str, repo: str = "", ref: str = "") -> str:
"""Read a single file's contents from a GitHub repo.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
path: Path to the file within the repo (e.g. ``docs/guide.md``).
ref: Optional branch / tag / SHA (default: the repo's default branch).

Expand All @@ -239,6 +250,7 @@ async def github_read_file(repo: str, path: str, ref: str = "") -> str:
`gh api .../contents/... --jq .content | base64 -d`. Validate repo with
bad_repo(); cap the returned size; return a readable Error on failure.
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
args = ["api", f"repos/{repo}/contents/{path}", "-H", "Accept: application/vnd.github.raw+json"]
Expand All @@ -252,14 +264,15 @@ async def github_read_file(repo: str, path: str, ref: str = "") -> str:
return out

@tool
async def github_repo_contents(repo: str, path: str = "", ref: str = "") -> str:
async def github_repo_contents(repo: str = "", path: str = "", ref: str = "") -> str:
"""List the contents (files + dirs) of a path in a GitHub repo.

Args:
repo: Repository as ``owner/name`` (required, no default).
repo: Repository as ``owner/name``. Omit to use the agent's configured default repo.
path: Directory path within the repo (default: repo root).
ref: Optional branch / tag / SHA (default: the repo's default branch).
"""
repo = resolve_repo(repo, default_repo) or ""
if err := bad_repo(repo):
return err
args = ["api", f"repos/{repo}/contents/{path}" if path else f"repos/{repo}/contents"]
Expand Down
43 changes: 41 additions & 2 deletions tests/test_read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ async def test_invalid_repo_returns_bad_repo_error():
mock = AsyncMock(return_value=(0, "nope", ""))
with patch("ghplugin.read_tools.run_gh", mock):
result = await _read_file_tool().ainvoke({"repo": "bad", "path": "README.md"})
assert result.startswith("Error: 'repo' must be")
assert result.startswith("Error: no usable repo")
mock.assert_not_called()


Expand Down Expand Up @@ -130,7 +130,7 @@ async def test_repo_contents_invalid_repo_returns_bad_repo_error():
mock = AsyncMock(return_value=(0, "[]", ""))
with patch("ghplugin.read_tools.run_gh", mock):
result = await _repo_contents_tool().ainvoke({"repo": "bad", "path": ""})
assert result.startswith("Error: 'repo' must be")
assert result.startswith("Error: no usable repo")
mock.assert_not_called()


Expand All @@ -146,3 +146,42 @@ async def test_repo_contents_bad_json_returns_parse_error():
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "not json", ""))):
result = await _repo_contents_tool().ainvoke({"repo": "owner/name", "path": ""})
assert result.startswith("Error: could not parse gh output")


# ── default_repo fallback (omit repo → use the configured default) ───────────────
def _list_issues_tool(default_repo=""):
for t in get_read_tools(default_repo):
if t.name == "github_list_issues":
return t
raise AssertionError("github_list_issues tool not found")


@pytest.mark.asyncio
async def test_omitted_repo_uses_configured_default(monkeypatch):
"""A tool called WITHOUT repo falls back to the factory's default_repo (#issue)."""
monkeypatch.delenv("GITHUB_DEFAULT_REPO", raising=False)
monkeypatch.delenv("GH_REPO", raising=False)
mock = AsyncMock(return_value=(0, "[]", ""))
with patch("ghplugin.read_tools.run_gh", mock):
await _list_issues_tool("owner/default").ainvoke({}) # no repo passed
argv = mock.call_args.args[0]
assert "--repo" in argv and "owner/default" in argv


@pytest.mark.asyncio
async def test_explicit_repo_overrides_default(monkeypatch):
monkeypatch.delenv("GITHUB_DEFAULT_REPO", raising=False)
monkeypatch.delenv("GH_REPO", raising=False)
mock = AsyncMock(return_value=(0, "[]", ""))
with patch("ghplugin.read_tools.run_gh", mock):
await _list_issues_tool("owner/default").ainvoke({"repo": "owner/explicit"})
argv = mock.call_args.args[0]
assert "owner/explicit" in argv and "owner/default" not in argv


@pytest.mark.asyncio
async def test_omitted_repo_no_default_errors(monkeypatch):
monkeypatch.delenv("GITHUB_DEFAULT_REPO", raising=False)
monkeypatch.delenv("GH_REPO", raising=False)
result = await _list_issues_tool("").ainvoke({}) # no repo, no default, no env
assert result.startswith("Error: no usable repo")
Loading
Loading