nisse-review: a new head's review waits for its CI
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m59s
CI / e2e (pull_request) Failing after 49m46s

A review judges the CI result (R1), so a new head's review now waits while
its checks are pending, and starts once they finish, passed or failed. It
also keeps reviews off a GPU that CI is using: #154's first review ran
beside CI's end-to-end job and took about 30 minutes.

- Pending: waits up to tuning.review.ci_wait_s (1 h), then reviews anyway.
- No checks: waits tuning.review.ci_grace_s (2 min) for them to appear,
  then reviews.
- A PR waiting on CI holds up no other: the oldest settled one goes first.
- The head's first sighting is kept on the item (seen_at).

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-16 23:08:46 +00:00
1 parent 3283f9540d
commit be5ea5f170
8 files changed
+134 -6

No files matched your search

+48 -4
View File
@@ -14,6 +14,8 @@ item waits rather than risk a second review.
import re
import tempfile
import threading
import time
from collections.abc import Callable
from pathlib import Path
from nisse_core.gitea import GiteaClient, GiteaError, PullRequest
@@ -56,6 +58,9 @@ class Scheduler:
poll_s: float = 60.0,
attempts: int = 2,
patterns: frozenset[str] | None = None,
ci_wait_s: float = 3600.0,
ci_grace_s: float = 120.0,
clock: Callable[[], float] = time.time,
) -> None:
self._store = store
self._gitea = gitea
@@ -66,6 +71,10 @@ class Scheduler:
self._attempts = attempts
# The repositories reviewed, as patterns; None: every one it was given.
self._patterns = patterns
self._ci_wait_s = ci_wait_s
self._ci_grace_s = ci_grace_s
self._clock = clock
self._said_waiting: set[str] = set() # "item@head", logged once
self._stop = threading.Event()
# -- the loop ---------------------------------------------------------------
@@ -131,13 +140,21 @@ class Scheduler:
title=pr.title,
scope=repo,
position=position,
data=data(PR(repo, pr.number, head=pr.head_sha)),
data=data(PR(repo, pr.number, head=pr.head_sha, seen_at=self._clock())),
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)
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,
)
if item.position in (READY, WAITING):
if position != item.position or moved:
self._store.update_work_item(
@@ -145,7 +162,7 @@ class Scheduler:
)
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)
new = PR(known.repo, known.number, pr.head_sha, known.reviewed, 0, seen_at)
self._store.update_work_item(
item.id, position=position, data=data(new), cause="new push"
)
@@ -165,8 +182,35 @@ class Scheduler:
)
def _next(self) -> WorkItem | None:
"""The oldest ready item whose CI has settled."""
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)
for item in sorted(ready_items, key=lambda i: i.opened_at):
if self._ci_settled(item):
return item
return None
def _ci_settled(self, item: WorkItem) -> bool:
"""R1: a review judges the CI result, so a new head waits for its
checks: reviewed once they've finished (passed or not), at once with
no CI at all after a short grace for checks to appear, and anyway
once they've been pending ``ci_wait_s``. Waiting also keeps reviews
off a GPU that CI is using."""
known = pr_of(item)
waited = self._clock() - known.seen_at
try:
state = self._gitea.checks(known.repo, known.head)
except GiteaError as exc:
self._oplog.emit("review.checks_unknown", cause=str(exc)[:300])
return False # asked again next round
if state == "pending" and waited < self._ci_wait_s:
key = f"{item.id}@{known.head}"
if key not in self._said_waiting:
self._said_waiting.add(key)
self._oplog.emit(
"review.waiting_for_ci", cause="checks pending", work_item=item.id
)
return False
return not (state == "none" and waited < self._ci_grace_s)
# -- a review ---------------------------------------------------------------