At bring-up, nisse-review read no repositories: Gitea's /user/repos leaves out a public repository's Read collaborators, so dev/nisse never showed up. `repositories` now holds patterns (dev/nisse, dev/*, */*) matched against every repository the account can read, public ones included (an organization's or user's list, or the instance's search); unset, it is /user/repos as before. A configured repository it can't read is left out with an oplog line. The read-only wall (R3) is checked every round, not only at startup: an account granted push or admin while running stops (exit 3) rather than review on. nisse-core's Gitea client gains GiteaNotFound for 404s, owner_repositories and visible_repositories. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
206 lines
6.3 KiB
Python
206 lines
6.3 KiB
Python
"""A Gitea and a model to review against, in memory."""
|
|
|
|
import io
|
|
import tarfile
|
|
from dataclasses import dataclass, field, replace
|
|
from typing import Any
|
|
|
|
from nisse_core.gitea import (
|
|
Comment,
|
|
GiteaError,
|
|
GiteaNotFound,
|
|
Issue,
|
|
LineComment,
|
|
PullRequest,
|
|
Review,
|
|
ReviewNote,
|
|
)
|
|
from nisse_core.log.canonical import CanonicalEvent, ToolCall
|
|
|
|
FILES = {
|
|
"README.md": "# demo\n\nCount words.\n",
|
|
"count.py": "def count(text):\n return len(text.split())\n",
|
|
"test_count.py": "from count import count\n\n\ndef test():\n assert count('a b') == 2\n",
|
|
}
|
|
DIFF = """diff --git a/count.py b/count.py
|
|
--- a/count.py
|
|
+++ b/count.py
|
|
@@ -1,2 +1,2 @@
|
|
def count(text):
|
|
- return len(text)
|
|
+ return len(text.split())
|
|
"""
|
|
|
|
|
|
def archive(files: dict[str, str], *, top: str = "demo") -> bytes:
|
|
out = io.BytesIO()
|
|
with tarfile.open(fileobj=out, mode="w:gz") as tar:
|
|
for path, text in files.items():
|
|
raw = text.encode()
|
|
info = tarfile.TarInfo(f"{top}/{path}")
|
|
info.size = len(raw)
|
|
tar.addfile(info, io.BytesIO(raw))
|
|
return out.getvalue()
|
|
|
|
|
|
def pr(number: int = 7, **more: Any) -> PullRequest:
|
|
base = PullRequest(
|
|
number=number,
|
|
state="open",
|
|
head="fix-count",
|
|
url=f"https://gitea.test/o/demo/pulls/{number}",
|
|
head_sha="abc123def456",
|
|
title="Count words, not characters",
|
|
body="Fixes #3.",
|
|
author="nisse-dev",
|
|
base="main",
|
|
)
|
|
return replace(base, **more)
|
|
|
|
|
|
@dataclass
|
|
class FakeGitea:
|
|
account: str = "nisse-review"
|
|
repos: dict[str, list[PullRequest]] = field(default_factory=dict)
|
|
merged: set[tuple[str, int]] = field(default_factory=set)
|
|
permissions: dict[str, dict[str, bool]] = field(default_factory=dict)
|
|
files: dict[str, str] = field(default_factory=lambda: dict(FILES))
|
|
diff: str = DIFF
|
|
posted: list[dict[str, Any]] = field(default_factory=list)
|
|
comments: list[tuple[str, int, str]] = field(default_factory=list)
|
|
refuse_off_diff: bool = False
|
|
down: bool = False
|
|
# Readable but not given (public): missing from /user/repos, as Gitea
|
|
# leaves a public repository's Read collaborators out of it.
|
|
public: set[str] = field(default_factory=set)
|
|
|
|
def _up(self) -> None:
|
|
if self.down:
|
|
raise GiteaError("gitea is down")
|
|
|
|
def whoami(self) -> str:
|
|
return self.account
|
|
|
|
def readable_repositories(self) -> list[str]:
|
|
self._up()
|
|
return [r for r in self.repos if r not in self.public]
|
|
|
|
def visible_repositories(self) -> list[str]:
|
|
self._up()
|
|
return list(self.repos)
|
|
|
|
def owner_repositories(self, owner: str) -> list[str]:
|
|
self._up()
|
|
found = [r for r in self.repos if r.startswith(f"{owner}/")]
|
|
if not found:
|
|
raise GiteaNotFound(f"no owner {owner}")
|
|
return found
|
|
|
|
def repository(self, repo: str) -> dict[str, Any]:
|
|
if repo not in self.repos:
|
|
raise GiteaNotFound(f"no repository {repo}")
|
|
return {
|
|
"permissions": self.permissions.get(
|
|
repo, {"pull": True, "push": False, "admin": False}
|
|
)
|
|
}
|
|
|
|
def open_pull_requests(self, repo: str) -> list[PullRequest]:
|
|
self._up()
|
|
return list(self.repos[repo])
|
|
|
|
def pull_request(self, repo: str, index: int) -> PullRequest:
|
|
self._up()
|
|
for found in self.repos.get(repo, []):
|
|
if found.number == index:
|
|
return found
|
|
return pr(index, state="closed", merged=(repo, index) in self.merged)
|
|
|
|
def pull_request_commits(self, repo: str, index: int) -> list[str]:
|
|
return ["abc123d Count words"]
|
|
|
|
def pull_request_diff(self, repo: str, index: int) -> str:
|
|
return self.diff
|
|
|
|
def list_comments(self, repo: str, index: int, since: str | None = None):
|
|
return [Comment(1, "don", "Please keep it simple.", "t")]
|
|
|
|
def review_notes(self, repo: str, index: int) -> list[ReviewNote]:
|
|
return []
|
|
|
|
def checks(self, repo: str, sha: str) -> str:
|
|
return "success"
|
|
|
|
def archive(self, repo: str, ref: str) -> bytes:
|
|
return archive(self.files)
|
|
|
|
def issue(self, repo: str, index: int) -> Issue:
|
|
return Issue(index, "Counting is wrong", f"in {repo}", "don", ())
|
|
|
|
def search_issues(self, repo: str, text: str) -> list[Issue]:
|
|
return [Issue(3, "Counting is wrong", "", "don", ())]
|
|
|
|
def reviews(self, repo: str, index: int) -> list[Review]:
|
|
self._up()
|
|
return [
|
|
Review(n, self.account, "COMMENT", p["commit"], p["body"])
|
|
for n, p in enumerate(self.posted, 1)
|
|
if (p["repo"], p["pr"]) == (repo, index)
|
|
]
|
|
|
|
def post_review(
|
|
self,
|
|
repo: str,
|
|
index: int,
|
|
*,
|
|
body: str,
|
|
commit: str,
|
|
comments: list[LineComment] = (), # type: ignore[assignment]
|
|
) -> int:
|
|
self._up()
|
|
if self.refuse_off_diff and any(c.path != "count.py" for c in comments):
|
|
raise GiteaError("422: line is not part of the diff")
|
|
self.posted.append(
|
|
{
|
|
"repo": repo,
|
|
"pr": index,
|
|
"body": body,
|
|
"commit": commit,
|
|
"comments": list(comments),
|
|
}
|
|
)
|
|
return len(self.posted)
|
|
|
|
def post_comment(self, repo: str, index: int, body: str) -> Comment:
|
|
self.comments.append((repo, index, body))
|
|
return Comment(99, self.account, body, "t")
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
|
|
def call(tool: str, **arguments: str) -> ToolCall:
|
|
call.n += 1 # type: ignore[attr-defined]
|
|
return ToolCall(call_id=f"c{call.n}", tool=tool, arguments=arguments) # type: ignore[attr-defined]
|
|
|
|
|
|
call.n = 0 # type: ignore[attr-defined]
|
|
|
|
|
|
class Scripted:
|
|
"""Replies with each turn in order; remembers what it was offered."""
|
|
|
|
def __init__(self, *turns: list[CanonicalEvent]) -> None:
|
|
self._turns = list(turns)
|
|
self.offered: list[set[str]] = []
|
|
self.systems: list[str] = []
|
|
self.seen: list[list[Any]] = []
|
|
|
|
def complete(self, events, tools, model, system=None) -> list[CanonicalEvent]:
|
|
self.offered.append({t["function"]["name"] for t in tools or []})
|
|
self.systems.append(system or "")
|
|
self.seen.append(list(events))
|
|
if not self._turns:
|
|
return []
|
|
return self._turns.pop(0)
|