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
This commit is contained in:
1 parent
fc71d3da75
commit
f59d974a95
24 files changed
+2330
-7
No files matched your search
@@ -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",
|
||||
)
|
||||
Reference in new issue
Block a user