M7.3: nisse-review's first slice
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m49s
CI / e2e (pull_request) Successful in 3m18s

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
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-13 22:34:02 +00:00
1 parent fc71d3da75
commit f59d974a95
24 files changed
+2330 -7

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
"""nisse-review's scheduler (R1): its one duty, and its items one at a time.
Every round, it asks Gitea for the open PRs in every repository its account
can read. A PR that is ready (not a draft, not marked work in progress) gets
a work item; the oldest ready item is reviewed, and its review posted. An
item closes when its PR merges or closes. Later reviews, replies and rounds
come with M7.5.
Crash safety (L8): an item found ``running`` was interrupted. Whether its
review got posted is asked of Gitea, never assumed; until Gitea can say, the
item waits rather than risk a second review.
"""
import re
import tempfile
import threading
from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
from nisse_core.oplog import OpLog
from nisse_core.state.store import WorkItem
from nisse_review.reader import Repository
from nisse_review.review import Reviewer
from nisse_review.state import (
FAILED,
POST_REVIEW,
PR,
READY,
REVIEWED,
RUNNING,
WAITING,
StateStore,
data,
item_id,
pr_of,
)
_WIP = re.compile(r"^\s*(\[wip\]|wip\b)", re.IGNORECASE)
def ready(pr: PullRequest) -> bool:
"""R1: open, not a draft, and not marked work in progress."""
return not pr.draft and not _WIP.match(pr.title)
class Scheduler:
def __init__(
self,
store: StateStore,
gitea: GiteaClient,
reviewer: Reviewer,
*,
oplog: OpLog,
scratch: Path,
poll_s: float = 60.0,
attempts: int = 2,
only: frozenset[str] | None = None,
) -> None:
self._store = store
self._gitea = gitea
self._reviewer = reviewer
self._oplog = oplog
self._scratch = scratch
self._poll_s = poll_s
self._attempts = attempts
self._only = only # the repositories reviewed; None: every readable one
self._stop = threading.Event()
# -- the loop ---------------------------------------------------------------
def run_forever(self) -> None:
while not self._stop.is_set():
if not self.round():
self._stop.wait(self._poll_s)
def stop(self) -> None:
self._stop.set()
def round(self) -> bool:
"""Intake, then one review if one is ready; True if one ran."""
try:
self.recover()
self.intake()
except GiteaError as exc:
self._oplog.emit("intake.failed", cause=str(exc)[:300])
return False
item = self._next()
if item is None:
return False
self.review(item)
return True
# -- intake (R1) ------------------------------------------------------------
def intake(self) -> None:
open_items = {i.id: i for i in self._store.work_items("open")}
for repo in self._gitea.readable_repositories():
if self._only is not None and repo not in self._only:
continue
seen: set[str] = set()
for pr in self._gitea.open_pull_requests(repo):
key = item_id(repo, pr.number)
seen.add(key)
self._see(open_items.get(key), repo, pr)
for key, item in open_items.items():
if item.scope == repo and key not in seen:
self._gone(item)
def _see(self, item: WorkItem | None, repo: str, pr: PullRequest) -> None:
position = READY if ready(pr) else WAITING
if item is None:
self._store.open_work_item(
item_id(repo, pr.number),
title=pr.title,
scope=repo,
position=position,
data=data(PR(repo, pr.number, head=pr.head_sha)),
cause="ready for review" if position == READY else "not ready",
)
return
known = pr_of(item)
moved = known.head != pr.head_sha
new = PR(known.repo, known.number, pr.head_sha, known.reviewed, known.attempts)
if item.position in (READY, WAITING):
if position != item.position or moved:
self._store.update_work_item(
item.id, position=position, data=data(new), cause="PR changed"
)
elif item.position == FAILED and moved:
# It never got a review: new code is worth another try.
new = PR(known.repo, known.number, pr.head_sha, known.reviewed, 0)
self._store.update_work_item(
item.id, position=position, data=data(new), cause="new push"
)
elif moved:
# Reviewed already: a later review is M7.5's; keep the head.
self._store.update_work_item(
item.id, position=item.position, data=data(new), cause="new push"
)
def _gone(self, item: WorkItem) -> None:
pr = pr_of(item)
merged = self._gitea.pull_request(pr.repo, pr.number).merged
self._store.close_work_item(
item.id,
outcome="completed" if merged else "abandoned",
cause="PR merged" if merged else "PR closed",
)
def _next(self) -> WorkItem | None:
ready_items = [i for i in self._store.work_items("open") if i.position == READY]
return min(ready_items, key=lambda i: i.opened_at, default=None)
# -- a review ---------------------------------------------------------------
def review(self, item: WorkItem) -> None:
known = pr_of(item)
self._store.update_work_item(
item.id, position=RUNNING, data=item.data, cause="review started"
)
try:
pr = self._gitea.pull_request(known.repo, known.number)
scratch = Path(tempfile.mkdtemp(dir=self._scratch, prefix="review-"))
repo = Repository(
self._gitea,
known.repo,
{"head": pr.head_sha, "base": pr.base},
scratch,
)
try:
outcome, draft = self._reviewer.run(item.id, pr, repo)
finally:
repo.close()
if outcome.kind != "wait":
self._failed(item, known, pr, outcome.note or outcome.kind)
return
summary = str((outcome.arguments or {}).get("summary", ""))
posted = self._reviewer.post(item.id, known.repo, pr, summary, draft)
except GiteaError as exc:
self._failed(item, known, None, f"Gitea: {exc}")
return
except Exception as exc: # noqa: BLE001 -- counted, never a crash loop
self._failed(item, known, None, repr(exc))
return
self._oplog.emit(
"review.posted",
cause="review",
work_item=item.id,
review=posted.review,
inline=posted.inline,
linked=posted.linked,
)
self._store.update_work_item(
item.id,
position=REVIEWED,
data=data(PR(known.repo, known.number, pr.head_sha, pr.head_sha, 0)),
cause="review posted",
)
def _failed(
self, item: WorkItem, known: PR, pr: PullRequest | None, why: str
) -> None:
attempts = known.attempts + 1
head = pr.head_sha if pr else known.head
new = PR(known.repo, known.number, head, known.reviewed, attempts)
self._oplog.emit("review.failed", cause=why[:300], work_item=item.id)
if attempts < self._attempts:
self._store.update_work_item(
item.id, position=READY, data=data(new), cause="review failed"
)
return
# Said once on the PR, so its author isn't left waiting on nothing.
try:
self._gitea.post_comment(
known.repo,
known.number,
f"nisse-review couldn't review this at {head[:12]} "
f"({attempts} tries; the last: {why[:300]}). It tries again "
"after the next push.",
)
except GiteaError as exc:
self._oplog.emit("review.unannounced", cause=str(exc)[:300])
self._store.update_work_item(
item.id, position=FAILED, data=data(new), cause="review kept failing"
)
# -- recovery (L8) ----------------------------------------------------------
def recover(self) -> None:
"""Items left running by a crash: posted, or to review again, as
Gitea says; a pending intent is resolved the same way."""
pending = {
i.work_item: i
for i in self._store.pending_intents()
if i.kind == POST_REVIEW
}
for item in self._store.work_items("open"):
if item.position != RUNNING:
continue
known = pr_of(item)
intent = pending.get(item.id)
head = intent.payload["head"] if intent else known.head
posted = self._reviewer.posted(known.repo, known.number, head)
if intent is not None:
if posted:
self._store.commit_intent(intent.id, cause="review found posted")
else:
self._store.abort_intent(intent.id, cause="review not found")
new = PR(
known.repo,
known.number,
head,
head if posted else known.reviewed,
known.attempts,
)
self._store.update_work_item(
item.id,
position=REVIEWED if posted else READY,
data=data(new),
cause="recovered after a restart",
)