Files
nisse/nisse-review/src/nisse_review/reader.py
T
dongarandClaude Opus 5.5 1a7836167d
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m51s
CI / e2e (pull_request) Successful in 4m38s
nisse-review: read long files in parts; a retry starts afresh
From its failed review of #156: read_file stopped at 20,000 characters
with no way on, so the reviewer hunted each file's end a definition at a
time and ran out of turns; its retry carried every read before it, and a
model call timed out. Both drafts were lost.

- read_file takes a start line and says where the next part starts.
- Each run's view starts at its brief (Transcript.since); the log keeps
  the rest.
- A run out of turns with points drafted posts them as a partial review.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-17 06:48:47 +00:00

265 lines
9.9 KiB
Python

"""The repository, read-only, as the reviewer's executor (R3, L13).
Reads are bound to the item's repository by construction: no tool names one.
Code comes from Gitea's archive of the PR's head and of its base branch, one
API call each, unpacked into a scratch folder on the agent host and read in
Python: no git, no shell, nothing a model chose is ever run. Issues come
from the Gitea API, for the same repository.
"""
import io
import posixpath
import shutil
import tarfile
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from nisse_core.gitea import GiteaClient, GiteaError
from nisse_core.loop.driver import ToolOutcome
from nisse_core.loop.tools import tool
MAX_FILE_CHARS = 20000
MAX_LISTED = 500
MAX_FOUND = 50 # matching lines from search_code
MAX_ISSUES = 20
REF = "head (the PR's code) or base (the branch it merges into)."
READ_FILE = tool(
"read_file",
"Read a file in the repository. Its lines are numbered, for comment. A "
"long file comes in parts: the end of each says where the next starts.",
path="The file's path from the repository root.",
ref=REF,
start="Optional: the line to start from (1, the default, is the top).",
)
LIST_FILES = tool(
"list_files",
"List the files under a folder in the repository.",
path="The folder, from the repository root; empty for the whole tree.",
ref=REF,
)
SEARCH_CODE = tool(
"search_code",
"Find lines in the repository's files containing some text (ignoring "
"case): file, line number and the line.",
text="What to look for.",
ref=REF,
)
READ_ISSUE = tool(
"read_issue",
"Read one of the repository's issues (or PRs) by number, with its text.",
number="The issue's number.",
)
LIST_ISSUES = tool(
"list_issues",
"Find the repository's issues, open or closed, whose text matches.",
text="What to look for.",
)
READER_TOOLS = [READ_FILE, LIST_FILES, SEARCH_CODE, READ_ISSUE, LIST_ISSUES]
class Repository:
"""``refs``: what ``head`` and ``base`` name (commits or branches).
``scratch``: a folder of this run's own, removed by ``close``."""
def __init__(
self,
gitea: GiteaClient,
repo: str,
refs: Mapping[str, str],
scratch: Path,
) -> None:
self._gitea = gitea
self.repo = repo
self._refs = dict(refs)
self._scratch = scratch
self._trees: dict[str, Path] = {}
def close(self) -> None:
shutil.rmtree(self._scratch, ignore_errors=True)
def tree(self, ref: str) -> Path:
"""The repository at ``ref``, unpacked on first use."""
if ref not in self._refs:
raise ValueError(f"ref is head or base, not {ref!r}")
if ref not in self._trees:
root = self._scratch / ref
shutil.rmtree(root, ignore_errors=True)
root.mkdir(parents=True)
_unpack(self._gitea.archive(self.repo, self._refs[ref]), root)
self._trees[ref] = root
return self._trees[ref]
def lines(self, path: str, ref: str = "head") -> list[str] | None:
"""A file's lines, or None if there's no such file."""
found = self._file(path, ref)
if found is None:
return None
return found.read_text(errors="replace").splitlines()
def _file(self, path: str, ref: str) -> Path | None:
clean = _clean(path)
if clean is None:
return None
found = self.tree(ref) / clean
return found if found.is_file() and not found.is_symlink() else None
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
try:
match tool:
case "read_file":
return self._read(arguments)
case "list_files":
return self._list(arguments)
case "search_code":
return self._search(arguments)
case "read_issue":
return self._issue(arguments)
case "list_issues":
return self._issues(arguments)
except (ValueError, KeyError) as exc:
return ToolOutcome(str(exc), False)
except GiteaError as exc:
return ToolOutcome(f"Gitea didn't answer: {exc}", False)
return ToolOutcome(f"no such tool: {tool}", False)
def _ref(self, arguments: Mapping[str, Any]) -> str:
return str(arguments.get("ref") or "head").strip().lower()
def _read(self, arguments: Mapping[str, Any]) -> ToolOutcome:
path = str(arguments.get("path", ""))
ref = self._ref(arguments)
lines = self.lines(path, ref)
if lines is None:
return ToolOutcome(f"{path}: no such file at {ref}", False)
raw = str(arguments.get("start") or "1").strip()
if not raw.isdigit() or not 1 <= int(raw) <= max(len(lines), 1):
return ToolOutcome(f"start is a line number from 1 to {len(lines)}", False)
# A part of at most MAX_FILE_CHARS, in whole lines; the rest is a
# read away, never out of reach (#156: a long file's end was).
first = int(raw)
shown: list[str] = []
size = 0
for n in range(first, len(lines) + 1):
row = f"{n:>5} {lines[n - 1]}"
if shown and size + len(row) + 1 > MAX_FILE_CHARS:
break
shown.append(row[:MAX_FILE_CHARS])
size += len(row) + 1
last = first + len(shown) - 1
where = f"lines {first}-{last} of {len(lines)}"
more = (
f"\n[{where}: read on with start={last + 1}]" if last < len(lines) else ""
)
text = "\n".join(shown)
return ToolOutcome(f"[{self.repo}@{ref}: {path}, {where}]\n{text}{more}", True)
def _list(self, arguments: Mapping[str, Any]) -> ToolOutcome:
folder = str(arguments.get("path", "")).strip().strip("/")
ref = self._ref(arguments)
root = self.tree(ref)
start = root if not folder else root / (_clean(folder) or "\0")
if not start.is_dir():
return ToolOutcome(f"{folder}: no such folder at {ref}", False)
found = sorted(
str(p.relative_to(root))
for p in start.rglob("*")
if p.is_file() and not p.is_symlink()
)
shown = found[:MAX_LISTED]
more = f"\n[{len(found) - len(shown)} more]" if len(found) > len(shown) else ""
return ToolOutcome(
f"[{self.repo}@{ref}: {folder or 'every file'}]\n"
+ ("\n".join(shown) or "no files")
+ more,
True,
)
def _search(self, arguments: Mapping[str, Any]) -> ToolOutcome:
text = str(arguments.get("text", ""))
if not text.strip():
return ToolOutcome("search for some text", False)
ref = self._ref(arguments)
root = self.tree(ref)
needle = text.lower()
hits: list[str] = []
for path in sorted(root.rglob("*")):
if not path.is_file() or path.is_symlink():
continue
try:
content = path.read_text()
except (UnicodeDecodeError, OSError):
continue # binary
for n, line in enumerate(content.splitlines(), 1):
if needle in line.lower():
hits.append(f"{path.relative_to(root)}:{n}: {line.strip()[:200]}")
if len(hits) > MAX_FOUND:
break
if len(hits) > MAX_FOUND:
break
more = "\n[more not shown: narrow the search]" if len(hits) > MAX_FOUND else ""
body = "\n".join(hits[:MAX_FOUND]) or "nothing"
return ToolOutcome(f"[{self.repo}@{ref} search: {text}]\n{body}{more}", True)
def _issue(self, arguments: Mapping[str, Any]) -> ToolOutcome:
number = _number(arguments.get("number"))
issue = self._gitea.issue(self.repo, number)
return ToolOutcome(
f"[{self.repo}#{issue.number}, by {issue.author}] {issue.title}\n\n"
f"{issue.body}",
True,
)
def _issues(self, arguments: Mapping[str, Any]) -> ToolOutcome:
text = str(arguments.get("text", "")).strip()
found = self._gitea.search_issues(self.repo, text)[:MAX_ISSUES]
lines = [f"#{i.number}: {i.title}" for i in found]
return ToolOutcome(
f"[{self.repo} issues: {text}]\n" + ("\n".join(lines) or "nothing"), True
)
def _number(raw: Any) -> int:
try:
number = int(str(raw).strip().lstrip("#"))
except ValueError:
raise ValueError(f"{raw!r}: an issue's number, like 14") from None
if number <= 0:
raise ValueError(f"{raw!r}: an issue's number, like 14")
return number
def _clean(path: str) -> str | None:
"""A path inside the tree, or None: never absolute, never up and out."""
path = path.strip()
if not path or path.startswith("/") or "\\" in path:
return None
clean = posixpath.normpath(path)
if clean == "." or clean.startswith(".."):
return None
return clean
def _unpack(archive: bytes, root: Path) -> None:
"""Gitea's archive, without its top folder; only plain files and
folders, each inside ``root``."""
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar:
for member in tar.getmembers():
if not (member.isfile() or member.isdir()):
continue # links and devices are never followed
parts = member.name.split("/", 1)
if len(parts) < 2 or not parts[1]:
continue
inner = _clean(parts[1])
if inner is None:
continue
target = root / inner
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
source = tar.extractfile(member)
if source is not None:
target.write_bytes(source.read())