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
25 changes: 20 additions & 5 deletions read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
84 changes: 82 additions & 2 deletions tests/test_read_tools.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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", ""))):
Expand Down Expand Up @@ -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")
Loading