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