diff --git a/__init__.py b/__init__.py index 01f0013..28de758 100644 --- a/__init__.py +++ b/__init__.py @@ -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) @@ -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) @@ -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 [--bug|--feature] [--repo owner/name].""" diff --git a/gh_cli.py b/gh_cli.py index 593c5d6..af68599 100644 --- a/gh_cli.py +++ b/gh_cli.py @@ -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 diff --git a/read_tools.py b/read_tools.py index c0f9adf..125b96e 100644 --- a/read_tools.py +++ b/read_tools.py @@ -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 @@ -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( @@ -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( @@ -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( @@ -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"): @@ -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( @@ -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 = [ @@ -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)) @@ -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). @@ -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"] @@ -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"] diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index 42e56b7..33e40d1 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -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() @@ -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() @@ -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") diff --git a/write_tools.py b/write_tools.py index 5616055..1e28d26 100644 --- a/write_tools.py +++ b/write_tools.py @@ -28,6 +28,7 @@ from langchain_core.tools import tool from .gh_cli import bad_repo, check_gh_error, run_gh +from .gh_issue import resolve_repo def _csv(value: str) -> list[str]: @@ -35,19 +36,23 @@ def _csv(value: str) -> list[str]: return [tok.strip() for tok in (value or "").split(",") if tok.strip()] -def get_write_tools() -> list: +def get_write_tools(default_repo: str = "") -> list: + """Build the write 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_create_issue(repo: str, title: str, body: str = "", labels: str = "") -> str: + async def github_create_issue(title: str, repo: str = "", body: str = "", labels: str = "") -> str: """Create a GitHub issue. Args: - repo: Repository as ``owner/name`` (required, no default). + repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. title: Issue title. body: Issue body (Markdown). labels: Optional comma-separated label names. Returns the new issue URL. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err args = ["issue", "create", "--repo", repo, "--title", title, "--body", body] @@ -61,16 +66,17 @@ async def github_create_issue(repo: str, title: str, body: str = "", labels: str return out.strip() @tool - async def github_comment(repo: str, number: int, body: str) -> str: + async def github_comment(number: int, body: str, repo: str = "") -> str: """Add a comment to a GitHub issue or pull request. 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 or PR number (a PR is an issue for commenting). body: The comment body (Markdown). Returns the new comment URL. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err args = ["issue", "comment", str(number), "--repo", repo, "--body", body] @@ -80,11 +86,11 @@ async def github_comment(repo: str, number: int, body: str) -> str: return out.strip() @tool - async def github_create_pr(repo: str, head: str, title: str, body: str = "", base: str = "main") -> str: + async def github_create_pr(head: str, title: str, repo: str = "", body: str = "", base: str = "main") -> str: """Open a pull request. Args: - repo: Repository as ``owner/name`` (required, no default). + repo: Repository as ``owner/name``. Omit to use the agent's configured default repo. head: The branch with the changes. title: PR title. body: PR body (Markdown). @@ -92,6 +98,7 @@ async def github_create_pr(repo: str, head: str, title: str, body: str = "", bas Returns the new PR URL. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err args = [ @@ -114,11 +121,11 @@ async def github_create_pr(repo: str, head: str, title: str, body: str = "", bas return out.strip() @tool - async def github_edit_pr(repo: str, number: int, title: str = "", body: str = "", state: str = "") -> str: + async def github_edit_pr(number: int, repo: str = "", title: str = "", body: str = "", state: str = "") -> str: """Edit a pull request's title/body and/or its draft state. 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. title: New title (omit to leave unchanged). body: New body / description (omit to leave unchanged). @@ -127,6 +134,7 @@ async def github_edit_pr(repo: str, number: int, title: str = "", body: str = "" Returns the PR URL (or a short summary of what changed). """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err if not (title or body or state): @@ -154,8 +162,8 @@ async def github_edit_pr(repo: str, number: int, title: str = "", body: str = "" @tool async def github_merge_pr( - repo: str, number: int, + repo: str = "", method: str = "squash", delete_branch: bool = False, dry_run: bool = False, @@ -164,7 +172,7 @@ async def github_merge_pr( """Merge a pull request. IRREVERSIBLE — guarded. 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. method: ``squash`` (default), ``merge``, or ``rebase``. delete_branch: Delete the head branch after merging (default False). @@ -174,6 +182,7 @@ async def github_merge_pr( Returns the merge result, a dry-run preview, or a refusal asking for confirm. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err if method not in ("squash", "merge", "rebase"): @@ -197,11 +206,13 @@ async def github_merge_pr( return out.strip() or f"Merged PR #{number} in {repo} via {method}." @tool - async def github_close(repo: str, number: int, kind: str = "issue", reopen: bool = False, comment: str = "") -> str: + async def github_close( + number: int, repo: str = "", kind: str = "issue", reopen: bool = False, comment: str = "" + ) -> str: """Close (or reopen) an issue or pull request. 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 or PR number. kind: ``issue`` (default) or ``pr`` — they use different gh subcommands. reopen: If true, reopen instead of close. @@ -209,6 +220,7 @@ async def github_close(repo: str, number: int, kind: str = "issue", reopen: bool Returns a short confirmation. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err if kind not in ("issue", "pr"): @@ -223,11 +235,13 @@ async def github_close(repo: str, number: int, kind: str = "issue", reopen: bool return out.strip() or f"{'Reopened' if reopen else 'Closed'} {kind} #{number} in {repo}." @tool - async def github_set_labels(repo: str, number: int, add: str = "", remove: str = "", kind: str = "issue") -> str: + async def github_set_labels( + number: int, repo: str = "", add: str = "", remove: str = "", kind: str = "issue" + ) -> str: """Add and/or remove labels on an issue or pull request. 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 or PR number. add: Comma-separated label names to add. remove: Comma-separated label names to remove. @@ -235,6 +249,7 @@ async def github_set_labels(repo: str, number: int, add: str = "", remove: str = Returns a short confirmation. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err if kind not in ("issue", "pr"): @@ -253,11 +268,13 @@ async def github_set_labels(repo: str, number: int, add: str = "", remove: str = return out.strip() or f"Updated labels on {kind} #{number} in {repo}." @tool - async def github_set_assignees(repo: str, number: int, add: str = "", remove: str = "", kind: str = "issue") -> str: + async def github_set_assignees( + number: int, repo: str = "", add: str = "", remove: str = "", kind: str = "issue" + ) -> str: """Add and/or remove assignees on an issue or pull request. 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 or PR number. add: Comma-separated GitHub usernames to assign. remove: Comma-separated GitHub usernames to unassign. @@ -265,6 +282,7 @@ async def github_set_assignees(repo: str, number: int, add: str = "", remove: st Returns a short confirmation. """ + repo = resolve_repo(repo, default_repo) or "" if err := bad_repo(repo): return err if kind not in ("issue", "pr"):