nisse-review: later reviews, replies, rounds, stop and review (M7.5)

Back on a PR it reviewed: a new head gets a later review of the commits
since (each one's diff, through the API) and of its earlier points; what
was said since gets replies, answered where it was said or skipped with a
reason. It signs off once and stays quiet while still happy. Past
tuning.review.max_rounds it sums up for the operator and goes quiet,
until a push or someone other than the PR's author joins in. An
operator's `stop` quiets it; `review` asks for a fresh review.

Reviews carry numbered markers and replies their point's key, so
recovery posts nothing twice.

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-17 07:06:06 +00:00
1 parent 7e73b8fb37
commit 37bbac7e72
14 files changed
+1268 -157

No files matched your search

+275 -82
View File
@@ -1,14 +1,24 @@
"""nisse-review's scheduler (R1): its one duty, and its items one at a time.
"""nisse-review's scheduler (R1, R2): its one duty, and its items one at a time.
Every round, it works out the repositories it reviews and checks it still
can't write to any (``access``), then asks Gitea for their open PRs. 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.
can't write to any (``access``), then asks Gitea for their open PRs. A PR
that is ready (not a draft, not marked work in progress) gets a work item.
Each round takes in what changed on every item: a new push, and what was
said on the PR since. The oldest item with a run due gets it: its first
review, a later review of what changed, replies to what was said, or, at the
round cap, a summary for the operator. An item closes when its PR merges or
closes.
Rounds (R2): each review, and each run that posted replies, is a round.
After ``max_rounds`` of them, the next run sums up what is still open and
the item goes quiet (``CAPPED``). A new push, or a comment from anyone but
the reviewer and the PR's author, resets the count. An operator's `stop`
comment quiets it until their `review` (R5), which asks for a fresh review.
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.
item waits rather than risk a second review. A reply carries the key of the
point it answers, so a re-run never answers it twice.
"""
import re
@@ -16,6 +26,7 @@ import tempfile
import threading
import time
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
@@ -23,14 +34,20 @@ from nisse_core.oplog import OpLog
from nisse_core.state.store import WorkItem
from nisse_review.access import check, reviewed
from nisse_review.reader import Repository
from nisse_review.review import Reviewer
from nisse_review.review import Draft, Posted, Reviewer, Said, answered, said_on
from nisse_review.state import (
CAPPED,
FAILED,
FIRST,
LATER,
POST_REVIEW,
PR,
READY,
REPLIES,
REVIEWED,
RUNNING,
STOPPED,
SUMMARY,
WAITING,
StateStore,
data,
@@ -46,12 +63,23 @@ PARTIAL = (
_WIP = re.compile(r"^\s*(\[wip\]|wip\b)", re.IGNORECASE)
# The ending tools that post a review.
_POSTING = {"post_review", "sign_off", "post_summary"}
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)
def command(one: Said, operators: frozenset[str]) -> str | None:
"""R5: `stop` or `review`, as the whole of an operator's comment."""
if not one.key.startswith("comment-") or one.author not in operators:
return None
word = one.body.strip().strip(".!`").strip().lower()
return word if word in ("stop", "review") else None
class Scheduler:
def __init__(
self,
@@ -66,6 +94,8 @@ class Scheduler:
patterns: frozenset[str] | None = None,
ci_wait_s: float = 3600.0,
ci_grace_s: float = 120.0,
operators: frozenset[str] = frozenset(),
max_rounds: int = 4,
clock: Callable[[], float] = time.time,
) -> None:
self._store = store
@@ -79,6 +109,8 @@ class Scheduler:
self._patterns = patterns
self._ci_wait_s = ci_wait_s
self._ci_grace_s = ci_grace_s
self._operators = operators
self._max_rounds = max_rounds
self._clock = clock
self._said_waiting: set[str] = set() # "item@head", logged once
self._stop = threading.Event()
@@ -94,8 +126,8 @@ class Scheduler:
self._stop.set()
def round(self) -> bool:
"""The wall, intake, then one review if one is ready; True if one
ran. An account that can write to a repository it reviews raises
"""The wall, intake, then one run if one is due; True if one ran.
An account that can write to a repository it reviews raises
``AccessRefused``: it stops, never reviews on (R3)."""
try:
repos = check(
@@ -133,51 +165,130 @@ class Scheduler:
for pr in prs:
key = item_id(repo, pr.number)
seen.add(key)
self._see(open_items.get(key), repo, pr)
try:
self._see(open_items.get(key), repo, pr)
except GiteaError as exc:
# What was said couldn't be read: next round, then.
self._oplog.emit(
"intake.pr_failed", cause=str(exc)[:300], work_item=key
)
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
"""A PR as Gitea has it now: its head, its readiness, and what was
said on it since the item last looked."""
if item is None:
# Everything said so far is in its first review's brief.
said = said_on(self._gitea, repo, pr.number)
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, seen_at=self._clock())),
cause="ready for review" if position == READY else "not ready",
position=READY if ready(pr) else WAITING,
data=data(
PR(
repo,
pr.number,
head=pr.head_sha,
seen_at=self._clock(),
seen=tuple(one.key for one in said),
)
),
cause="ready for review" if ready(pr) else "not ready",
)
return
if item.position == RUNNING:
return # recovery's, not intake's
known = pr_of(item)
said = said_on(self._gitea, repo, pr.number)
if "seen" not in item.data["pr"]:
# Reviewed before M7.5: what was said then is taken in, unanswered.
known = replace(known, seen=tuple(one.key for one in said))
moved = known.head != pr.head_sha
seen_at = self._clock() if moved else known.seen_at
new = PR(
known.repo,
known.number,
pr.head_sha,
known.reviewed,
known.attempts,
seen_at,
stopped = item.position == STOPPED
fresh = list(known.fresh)
reset = moved
asked = False
why: list[str] = ["new push"] if moved else []
for one in said:
if one.key in known.seen or one.author == self._reviewer.account:
continue
word = command(one, self._operators)
if word is not None:
self._acknowledge(item.id, repo, one, word)
stopped = word == "stop"
asked = word == "review"
if asked:
fresh = []
why.append(f"operator: {word}")
continue
# While stopped, this waits unanswered: `review` starts afresh.
fresh.append(one.key)
if one.author != pr.author:
reset = True # someone else joined in (R2)
if fresh != list(known.fresh):
why.append("said since")
new = replace(
known,
head=pr.head_sha,
attempts=0 if moved or asked else known.attempts,
seen_at=self._clock() if moved else known.seen_at,
seen=tuple(dict.fromkeys([*known.seen, *(one.key for one in said)])),
fresh=tuple(fresh),
rounds=0 if reset or asked else known.rounds,
full=known.full or asked,
)
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, seen_at)
position = self._position(
item.position,
new,
pr,
moved=moved,
stopped=stopped,
reset=reset,
asked=asked,
)
if position == CAPPED:
new = replace(new, fresh=()) # it stopped answering (R2)
if new != pr_of(item) or position != item.position:
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"
item.id,
position=position,
data=data(new),
cause=", ".join(why) or "PR changed",
)
def _position(
self,
was: str,
new: PR,
pr: PullRequest,
*,
moved: bool,
stopped: bool,
reset: bool,
asked: bool,
) -> str:
if stopped:
return STOPPED
if was == CAPPED and not (reset or asked):
return CAPPED
if was == FAILED and not (moved or asked):
return FAILED # failed at this head: new code or `review` retries
if not ready(pr):
return WAITING
return READY if self._kind(new) is not None else REVIEWED
def _acknowledge(self, item: str, repo: str, one: Said, word: str) -> None:
"""A command is answered with a reaction, not a comment: nothing for
the PR's author to answer."""
self._oplog.emit(f"review.{word}", cause="operator", work_item=item)
try:
self._gitea.react(repo, int(one.key.split("-", 1)[1]), "+1")
except GiteaError as exc:
self._oplog.emit("review.unacknowledged", cause=str(exc)[:300])
def _gone(self, item: WorkItem) -> None:
pr = pr_of(item)
merged = self._gitea.pull_request(pr.repo, pr.number).merged
@@ -187,11 +298,28 @@ class Scheduler:
cause="PR merged" if merged else "PR closed",
)
def _kind(self, known: PR) -> str | None:
"""The run an item has due, if any."""
if known.full or not known.reviewed:
return FIRST
if known.head != known.reviewed:
return LATER
if known.fresh:
return SUMMARY if known.rounds >= self._max_rounds else REPLIES
return None
def _next(self) -> WorkItem | None:
"""The oldest ready item whose CI has settled."""
"""The oldest ready item with a run due, a review's once its CI has
settled; replies don't wait for CI."""
ready_items = [i for i in self._store.work_items("open") if i.position == READY]
for item in sorted(ready_items, key=lambda i: i.opened_at):
if self._ci_settled(item):
kind = self._kind(pr_of(item))
if kind is None:
self._store.update_work_item(
item.id, position=REVIEWED, data=item.data, cause="nothing due"
)
continue
if kind in (REPLIES, SUMMARY) or self._ci_settled(item):
return item
return None
@@ -224,15 +352,26 @@ class Scheduler:
retry at a head already past ``ci_wait_s`` doesn't wait again."""
return known.seen_at if head == known.head else self._clock()
# -- a review ---------------------------------------------------------------
# -- a run ------------------------------------------------------------------
def review(self, item: WorkItem) -> None:
known = pr_of(item)
kind = self._kind(known) or FIRST
self._store.update_work_item(
item.id, position=RUNNING, data=item.data, cause="review started"
item.id,
position=RUNNING,
data=data(replace(known, run=kind)),
cause=f"{kind} run started",
)
try:
pr = self._gitea.pull_request(known.repo, known.number)
said = said_on(self._gitea, known.repo, known.number)
# Points a run before a crash already answered (their markers).
done = answered(said, self._reviewer.account)
known = replace(known, fresh=tuple(k for k in known.fresh if k not in done))
if kind == REPLIES and not known.fresh:
self._settle(item, known, pr, "done", Draft(), posted=None)
return
scratch = Path(tempfile.mkdtemp(dir=self._scratch, prefix="review-"))
repo = Repository(
self._gitea,
@@ -241,7 +380,9 @@ class Scheduler:
scratch,
)
try:
outcome, draft = self._reviewer.run(item.id, pr, repo)
outcome, draft = self._reviewer.run(
item.id, pr, repo, kind=kind, known=known, said=said
)
finally:
repo.close()
if outcome.kind == "capped" and draft.comments:
@@ -250,41 +391,75 @@ class Scheduler:
self._oplog.emit(
"review.partial", cause=outcome.note, work_item=item.id
)
summary = PARTIAL.format(n=len(draft.comments))
tool, summary = "post_review", PARTIAL.format(n=len(draft.comments))
elif outcome.kind != "wait":
self._failed(item, known, pr, outcome.note or outcome.kind)
return
else:
tool = outcome.tool
summary = str((outcome.arguments or {}).get("summary", ""))
posted = self._reviewer.post(item.id, known.repo, pr, summary, draft)
posted = None
if tool in _POSTING:
kept = draft if tool == "post_review" else Draft()
posted = self._reviewer.post(
item.id, known.repo, pr, summary, kept, n=known.posts + 1
)
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._settle(item, known, pr, tool, draft, posted)
def _settle(
self,
item: WorkItem,
known: PR,
pr: PullRequest,
tool: str,
draft: Draft,
posted: Posted | None,
) -> None:
"""A run ended on ``tool``: what it leaves the item with."""
reviewing = tool in ("post_review", "sign_off", "still_happy")
counted = tool in ("post_review", "sign_off") or bool(draft.replied)
if posted is not None:
self._oplog.emit(
"review.posted",
cause=tool,
work_item=item.id,
review=posted.review,
inline=posted.inline,
linked=posted.linked,
)
else:
self._oplog.emit(
f"review.{tool}",
cause="run ended",
work_item=item.id,
replied=len(draft.replied),
skipped=len(draft.skipped),
)
happy = {"post_review": False, "sign_off": True, "still_happy": True}
new = replace(
known,
head=pr.head_sha,
reviewed=pr.head_sha if reviewing else known.reviewed,
attempts=0,
seen_at=self._seen(known, pr.head_sha),
fresh=(),
rounds=known.rounds + 1 if counted else known.rounds,
posts=known.posts + (1 if posted is not None else 0),
happy=happy.get(tool, known.happy),
full=False,
run="",
)
self._store.update_work_item(
item.id,
position=REVIEWED,
data=data(
PR(
known.repo,
known.number,
pr.head_sha,
pr.head_sha,
0,
self._seen(known, pr.head_sha),
)
),
cause="review posted",
position=CAPPED if tool == "post_summary" else REVIEWED,
data=data(new),
cause=f"{tool}",
)
def _failed(
@@ -292,13 +467,12 @@ class Scheduler:
) -> 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._seen(known, head),
new = replace(
known,
head=head,
attempts=attempts,
seen_at=self._seen(known, head),
run="",
)
self._oplog.emit("review.failed", cause=why[:300], work_item=item.id)
if attempts < self._attempts:
@@ -313,7 +487,7 @@ class Scheduler:
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.",
"after the next push, or an operator's `review`.",
)
except GiteaError as exc:
self._oplog.emit("review.unannounced", cause=str(exc)[:300])
@@ -324,8 +498,8 @@ class Scheduler:
# -- 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."""
"""Items left running by a crash: posted, or to run again, as Gitea
says; a pending intent is resolved the same way."""
pending = {
i.work_item: i
for i in self._store.pending_intents()
@@ -336,24 +510,43 @@ class Scheduler:
continue
known = pr_of(item)
intent = pending.get(item.id)
if intent is None and known.run == REPLIES:
# Replies carry their points' keys: the re-run skips those.
self._store.update_work_item(
item.id,
position=READY,
data=data(replace(known, run="")),
cause="recovered after a restart",
)
continue
head = intent.payload["head"] if intent else known.head
posted = self._reviewer.posted(known.repo, known.number, head)
n = int(intent.payload.get("n", 1)) if intent else known.posts + 1
posted = self._reviewer.posted(known.repo, known.number, head, n)
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._seen(known, head),
)
if posted:
new = replace(
known,
head=head,
reviewed=head if known.run != SUMMARY else known.reviewed,
seen_at=self._seen(known, head),
fresh=(),
rounds=known.rounds + 1,
posts=n,
happy=False, # a review or a sign-off: it can't tell which
full=False,
run="",
)
position = CAPPED if known.run == SUMMARY else REVIEWED
else:
new = replace(known, head=head, seen_at=self._seen(known, head), run="")
position = READY
self._store.update_work_item(
item.id,
position=REVIEWED if posted else READY,
position=position,
data=data(new),
cause="recovered after a restart",
)