Files
nisse/nisse-review/src/nisse_review/reader.py
T
dongarandClaude Opus 5.5 f59d974a95
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m49s
CI / e2e (pull_request) Successful in 3m18s
M7.3: nisse-review's first slice
A new package over nisse-core: it reviews every ready PR in the
repositories its account can read, and posts one COMMENT review.

- scheduler: each round, open PRs per readable repository (or the
  configured repositories); an item per PR, ready unless draft or WIP;
  closed with the PR. One review at a time, oldest first.
- reader: the repository at head and base from Gitea archives, unpacked
  and read in Python (read_file, list_files, search_code), and its issues;
  bound to the item's repository; no git, no shell.
- review: starts from the PR, commits, diff, comments, closed issues, CI;
  comment drafts a point on any existing line; post_review posts one
  COMMENT review at the head, inline where Gitea takes it, else as links in
  the summary. ask_expert; vault and memory (memory/nisse-review).
- L8: posting is an intent; recovery asks Gitea for the marked review and
  never posts twice; with Gitea down, the item waits.
- Failures retried, then said on the PR until the next push.
- Startup refuses an account that can push to or administer any repo.
- nisse-core gitea: readable_repositories, open_pull_requests, diff,
  commits, archive, issue, search_issues, reviews, post_review; PR fields.
- Bring-up: scripts/review-bringup.sh; nisse-review.example.yaml.
- Tests: unit over a fake Gitea and scripted model; a live test
  (NISSE_REVIEW_GITEA_TOKEN) reviews a PR in a fresh test repository.
- Docs: Development Plan M7.3, Tuning Guide (tuning.review.*).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-13 22:34:02 +00:00

247 lines
9.0 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.",
path="The file's path from the repository root.",
ref=REF,
)
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)
text = "\n".join(f"{n:>5} {line}" for n, line in enumerate(lines, 1))
if len(text) > MAX_FILE_CHARS:
more = len(text) - MAX_FILE_CHARS
text = text[:MAX_FILE_CHARS] + f"\n[{more} more characters not shown]"
return ToolOutcome(f"[{self.repo}@{ref}: {path}]\n{text}", 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())