diff --git a/read_tools.py b/read_tools.py index b09c08d..c0f9adf 100644 --- a/read_tools.py +++ b/read_tools.py @@ -259,14 +259,29 @@ async def github_repo_contents(repo: str, path: str = "", ref: str = "") -> str: repo: Repository as ``owner/name`` (required, no default). path: Directory path within the repo (default: repo root). ref: Optional branch / tag / SHA (default: the repo's default branch). - - TODO(team): implement via `gh api repos/{repo}/contents/{path}?ref={ref}` — - returns a JSON array of {name, path, type, size}. Format a compact listing; - validate repo; return a readable Error on failure. """ if err := bad_repo(repo): return err - return "Error: github_repo_contents is not implemented yet (stub — to be built by the team)." + args = ["api", f"repos/{repo}/contents/{path}" if path else f"repos/{repo}/contents"] + if ref.strip(): + args += ["-f", f"ref={ref}"] + rc, out, serr = await run_gh(args) + if gh_err := check_gh_error(rc, serr): + return gh_err + try: + items = json.loads(out) + except json.JSONDecodeError: + return f"Error: could not parse gh output: {out[:200]}" + if not items: + return f"No contents in {repo}/{path or '.'}." + type_map = {"file": "FILE", "dir": "DIR ", "symlink": "LINK", "submodule": "SUB "} + lines = [f"{repo}/{path or '.'} — {len(items)} item(s):"] + for entry in items: + etype = entry.get("type", "") + t = type_map.get(etype, (etype or "?").upper())[:4] + size = "0" if etype == "dir" else str(entry.get("size", 0)) + lines.append(f"{t:4s} {size:>8s} {entry.get('name', '?')} ({entry.get('path', '')})") + return "\n".join(lines) return [ github_get_pr, diff --git a/tests/test_read_tools.py b/tests/test_read_tools.py index a88aabd..42e56b7 100644 --- a/tests/test_read_tools.py +++ b/tests/test_read_tools.py @@ -1,11 +1,12 @@ -"""github_read_file over a mocked `gh` — formats raw content, errors readably, caps size. +"""github_read_file + github_repo_contents over a mocked `gh`. Host-free: we mock ``ghplugin.read_tools.run_gh`` (the name bound in read_tools) so no -real `gh`/network is touched. The tool is a langchain ``@tool``; invoke via ``ainvoke``. +real `gh`/network is touched. The tools are langchain ``@tool``; invoke via ``ainvoke``. """ from __future__ import annotations +import json from unittest.mock import AsyncMock, patch import pytest @@ -19,6 +20,21 @@ def _read_file_tool(): raise AssertionError("github_read_file tool not found") +def _repo_contents_tool(): + for t in get_read_tools(): + if t.name == "github_repo_contents": + return t + raise AssertionError("github_repo_contents tool not found") + + +_CONTENTS_JSON = json.dumps( + [ + {"name": "README.md", "path": "src/README.md", "type": "file", "size": 1234}, + {"name": "lib", "path": "src/lib", "type": "dir", "size": 0}, + ] +) + + @pytest.mark.asyncio async def test_success_returns_raw_content(): with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "file content", ""))): @@ -66,3 +82,67 @@ async def test_empty_content_returns_empty_string(): with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "", ""))): result = await _read_file_tool().ainvoke({"repo": "owner/name", "path": "empty.txt"}) assert result == "" + + +# ── github_repo_contents ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_repo_contents_lists_file_and_dir(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, _CONTENTS_JSON, ""))): + result = await _repo_contents_tool().ainvoke({"repo": "owner/name", "path": "src"}) + assert "owner/name/src — 2 item(s):" in result + assert "FILE" in result + assert "DIR " in result + assert "README.md" in result + assert "(src/README.md)" in result + assert "lib" in result + assert "1234" in result + + +@pytest.mark.asyncio +async def test_repo_contents_root_passes_no_trailing_slash(): + mock = AsyncMock(return_value=(0, _CONTENTS_JSON, "")) + with patch("ghplugin.read_tools.run_gh", mock): + await _repo_contents_tool().ainvoke({"repo": "owner/name"}) + assert mock.call_args.args[0] == ["api", "repos/owner/name/contents"] + + +@pytest.mark.asyncio +async def test_repo_contents_ref_is_passed_to_gh_args(): + mock = AsyncMock(return_value=(0, _CONTENTS_JSON, "")) + with patch("ghplugin.read_tools.run_gh", mock): + await _repo_contents_tool().ainvoke({"repo": "owner/name", "path": "src", "ref": "main"}) + args = mock.call_args.args[0] + assert "-f" in args + assert "ref=main" in args + + +@pytest.mark.asyncio +async def test_repo_contents_empty_dir_returns_note(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "[]", ""))): + result = await _repo_contents_tool().ainvoke({"repo": "owner/name", "path": "empty"}) + assert "No contents" in result + + +@pytest.mark.asyncio +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") + mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_repo_contents_gh_error_is_surfaced(): + with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(1, "", "not found"))): + result = await _repo_contents_tool().ainvoke({"repo": "owner/name", "path": "missing"}) + assert result.startswith("Error (gh exit 1)") + + +@pytest.mark.asyncio +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")