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
10 changes: 9 additions & 1 deletion read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,15 @@ async def github_read_file(repo: str, path: str, ref: str = "") -> str:
"""
if err := bad_repo(repo):
return err
return "Error: github_read_file is not implemented yet (stub — to be built by the team)."
args = ["api", f"repos/{repo}/contents/{path}", "-H", "Accept: application/vnd.github.raw+json"]
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
if len(out) > 20000:
out = out[:20000] + "\n… (truncated at 20000 chars)"
return out

@tool
async def github_repo_contents(repo: str, path: str = "", ref: str = "") -> str:
Expand Down
68 changes: 68 additions & 0 deletions tests/test_read_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""github_read_file over a mocked `gh` — formats raw content, errors readably, caps size.

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``.
"""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest
from ghplugin.read_tools import get_read_tools


def _read_file_tool():
for t in get_read_tools():
if t.name == "github_read_file":
return t
raise AssertionError("github_read_file tool not found")


@pytest.mark.asyncio
async def test_success_returns_raw_content():
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "file content", ""))):
result = await _read_file_tool().ainvoke({"repo": "owner/name", "path": "README.md"})
assert result == "file content"


@pytest.mark.asyncio
async def test_ref_is_passed_to_gh_args():
mock = AsyncMock(return_value=(0, "ok", ""))
with patch("ghplugin.read_tools.run_gh", mock):
await _read_file_tool().ainvoke({"repo": "owner/name", "path": "docs/guide.md", "ref": "main"})
args = mock.call_args.args[0]
assert "-f" in args
assert "ref=main" in args


@pytest.mark.asyncio
async def test_invalid_repo_returns_bad_repo_error():
# run_gh must NOT be called for an invalid repo.
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")
mock.assert_not_called()


@pytest.mark.asyncio
async def test_gh_error_is_surfaced():
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(1, "", "not found"))):
result = await _read_file_tool().ainvoke({"repo": "owner/name", "path": "missing.md"})
assert result.startswith("Error (gh exit 1)")


@pytest.mark.asyncio
async def test_long_content_is_truncated():
with patch("ghplugin.read_tools.run_gh", new=AsyncMock(return_value=(0, "x" * 25000, ""))):
result = await _read_file_tool().ainvoke({"repo": "owner/name", "path": "big.txt"})
assert result.endswith("… (truncated at 20000 chars)")
assert len(result) == 20000 + len("\n… (truncated at 20000 chars)")


@pytest.mark.asyncio
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 == ""
Loading