alissa-tools-github-revloop 0.16.6__tar.gz → 0.16.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {alissa_tools_github_revloop-0.16.6/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.7}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/__main__.py +9 -0
  3. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/config.py +26 -0
  4. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/ghclient.py +300 -6
  5. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/loop.py +417 -8
  6. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/state.py +81 -24
  7. alissa_tools_github_revloop-0.16.7/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  9. alissa_tools_github_revloop-0.16.6/src/main/alissa/tools/github/revloop/version +0 -1
  10. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/LICENSE +0 -0
  11. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/NOTICE +0 -0
  13. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/README.md +0 -0
  14. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/requirements.txt +0 -0
  15. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/setup.cfg +0 -0
  16. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/setup.py +0 -0
  17. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  18. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  19. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  21. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.6
3
+ Version: 0.16.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -122,6 +122,14 @@ def build_parser() -> argparse.ArgumentParser:
122
122
  help="page-worthy threshold: more live reviewer sessions than this "
123
123
  "after a sweep and the daemon logs loudly",
124
124
  )
125
+ over.add_argument(
126
+ "--checks-wait-seconds",
127
+ type=int,
128
+ metavar="SECONDS",
129
+ help="how long a round holds its approve while the judged head's CI "
130
+ "rollup is still running (or unreadable) before recording the verdict "
131
+ "as a comment instead; a red rollup never waits and never approves",
132
+ )
125
133
 
126
134
  dry = over.add_mutually_exclusive_group()
127
135
  dry.add_argument(
@@ -157,6 +165,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
157
165
  "on_missing_hub": args.on_missing_hub,
158
166
  "reap_grace_seconds": args.reap_grace_seconds,
159
167
  "reap_session_cap": args.reap_session_cap,
168
+ "checks_wait_seconds": args.checks_wait_seconds,
160
169
  "dry_run": args.dry_run,
161
170
  }
162
171
 
@@ -129,6 +129,7 @@ CONFIG_KEYS = (
129
129
  "on_missing_hub",
130
130
  "reap_grace_seconds",
131
131
  "reap_session_cap",
132
+ "checks_wait_seconds",
132
133
  "dry_run",
133
134
  )
134
135
 
@@ -169,6 +170,20 @@ DEFAULT_REAP_GRACE_SECONDS = 30 * 60
169
170
  # healthy deployment reaches, not a capacity limit.
170
171
  DEFAULT_REAP_SESSION_CAP = 6
171
172
 
173
+ # How long a round holds its APPROVE while the head's CI rollup is still
174
+ # running (or unreadable) before it gives up and records the verdict as a
175
+ # COMMENT instead. An approve from the reviewer identity is the operator's cue
176
+ # to merge, so it must never claim a head whose checks this loop did not see
177
+ # conclude -- but the round cannot hold open forever either, or a CI system that
178
+ # never reports strands the PR outside the loop.
179
+ #
180
+ # 30 minutes is the trade: longer than any check suite in this fleet (the studio
181
+ # runs finish in single-digit minutes), short enough that a stuck rollup
182
+ # surfaces as a comment within the same working hour instead of the following
183
+ # day. Configurable because the right value is a property of a deployment's CI,
184
+ # not of the daemon.
185
+ DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
186
+
172
187
 
173
188
  def default_state_path(workspace_root: Path) -> Path:
174
189
  return Path(workspace_root) / ".revloop" / "state.db"
@@ -218,6 +233,12 @@ class Config:
218
233
  reap_grace_seconds: int = DEFAULT_REAP_GRACE_SECONDS
219
234
  reap_session_cap: int = DEFAULT_REAP_SESSION_CAP
220
235
 
236
+ # The bound on holding a round's approve for a rollup that has not settled;
237
+ # see DEFAULT_CHECKS_WAIT_SECONDS. 0 is legal and means "never hold": a
238
+ # rollup that is not already green degrades the verdict to a comment on the
239
+ # first poll that would have posted it.
240
+ checks_wait_seconds: int = DEFAULT_CHECKS_WAIT_SECONDS
241
+
221
242
  dry_run: bool = False
222
243
 
223
244
  def __post_init__(self) -> None:
@@ -336,6 +357,10 @@ class Config:
336
357
  # state of a working loop -- an alarm that always fires is noise.
337
358
  raise ValueError(f"reap_session_cap must be >= 1, got {session_cap}")
338
359
 
360
+ checks_wait = int(raw.get("checks_wait_seconds", cls.checks_wait_seconds))
361
+ if checks_wait < 0:
362
+ raise ValueError(f"checks_wait_seconds must be >= 0, got {checks_wait}")
363
+
339
364
  token_env = raw.get("reviewer_token_env")
340
365
  if token_env is not None:
341
366
  token_env = str(token_env).strip()
@@ -379,6 +404,7 @@ class Config:
379
404
  on_missing_hub=hub_mode,
380
405
  reap_grace_seconds=grace,
381
406
  reap_session_cap=session_cap,
407
+ checks_wait_seconds=checks_wait,
382
408
  dry_run=bool(raw.get("dry_run", False)),
383
409
  )
384
410
 
@@ -62,11 +62,55 @@ GH_TOKEN_VARS = ("GH_TOKEN", "GITHUB_TOKEN")
62
62
  # throttling from an authorization failure -- these can. See `_api`.
63
63
  RATE_LIMIT_MARKERS = ("rate limit", "abuse detection", "429")
64
64
 
65
- # The review events the daemon may submit. COMMENT is deliberately absent: a
66
- # comment-mode review cannot express approval, which is the whole reason the
67
- # GitHub state was useless as a convergence signal in the first place.
65
+ # The review events the daemon may submit.
68
66
  EVENT_APPROVE = "APPROVE"
69
67
  EVENT_REQUEST_CHANGES = "REQUEST_CHANGES"
68
+ # COMMENT cannot express approval, which is exactly why it exists here: it is
69
+ # the DEGRADED form of an approve the CI gate refused to post (a rollup that
70
+ # never concluded). It is never a verdict the loop chooses on its own -- see
71
+ # loop._gate_on_checks -- and `submit_review` still refuses anything else.
72
+ EVENT_COMMENT = "COMMENT"
73
+
74
+ # -- CI check rollup ------------------------------------------------------
75
+ #
76
+ # An APPROVE by the reviewer identity is the operator's cue to merge, so it has
77
+ # to mean "reviewed AND green". These read one commit's rollup so the verdict
78
+ # path can hold that promise; see loop._gate_on_checks for what each state does
79
+ # to a round.
80
+
81
+ CHECKS_GREEN = "green"
82
+ CHECKS_PENDING = "pending"
83
+ CHECKS_RED = "red"
84
+ # The rollup could not be read at all (an API failure, or more check runs than
85
+ # the page bound). Deliberately its own state rather than folded into either
86
+ # neighbour: unreadable must never approve, and must never post a red verdict
87
+ # naming checks nobody has seen fail.
88
+ CHECKS_UNKNOWN = "unknown"
89
+
90
+ # Conclusions that do NOT block an approve. `skipped` and `neutral` are the
91
+ # normal answer for a path-filtered matrix job (studio #323's api/cli/mcp/plugin
92
+ # jobs), so treating them as red would block every approve on every repo that
93
+ # filters by path. Every OTHER completed conclusion blocks -- including ones
94
+ # GitHub may add later, which is the conservative direction for a gate whose
95
+ # whole job is to not approve a head it cannot vouch for.
96
+ CHECKS_PASSING_CONCLUSIONS = frozenset({"success", "skipped", "neutral"})
97
+
98
+ # Legacy commit-status states, mapped into the check-run conclusion vocabulary
99
+ # so both kinds of context classify through one rule. `pending` becomes the
100
+ # empty conclusion -- "still running" -- and an unrecognised state stays itself,
101
+ # which lands it on the blocking side above.
102
+ _STATUS_CONCLUSIONS = {
103
+ "success": "success",
104
+ "failure": "failure",
105
+ "error": "error",
106
+ "pending": "",
107
+ }
108
+
109
+ # How many pages either rollup listing will walk. A matrix job set can be large;
110
+ # it cannot plausibly be 500 contexts on one commit, and a bound is what keeps a
111
+ # pathological commit from stalling a poll pass. Past it the rollup is UNKNOWN
112
+ # rather than green -- a partial read cannot support an approve.
113
+ CHECK_RUN_PAGE_LIMIT = 5
70
114
 
71
115
  # The hidden marker the daemon stamps into every native verdict review it
72
116
  # submits, carrying the round it closes. Two jobs, both load-bearing:
@@ -159,6 +203,96 @@ class Review:
159
203
  return int(match.group(1)) if match else None
160
204
 
161
205
 
206
+ @dataclass(frozen=True)
207
+ class CheckContext:
208
+ """One check on a commit -- a check run or a legacy commit status.
209
+
210
+ `conclusion` is normalised lowercase and EMPTY while the check is still
211
+ running, which is the whole discriminator the rollup needs: a context with
212
+ no conclusion yet cannot vouch for the commit, and one with a conclusion
213
+ outside CHECKS_PASSING_CONCLUSIONS votes against it.
214
+ """
215
+
216
+ name: str
217
+ conclusion: str = ""
218
+ url: str = ""
219
+
220
+ @property
221
+ def running(self) -> bool:
222
+ return not self.conclusion
223
+
224
+ @property
225
+ def passing(self) -> bool:
226
+ return self.conclusion in CHECKS_PASSING_CONCLUSIONS
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class CheckRollup:
231
+ """One commit's CI rollup, reduced to the question the verdict path asks:
232
+ may an APPROVE claim this head?
233
+
234
+ `failing` and `running` carry the contexts behind the answer, because a
235
+ verdict that declines to approve has to say WHICH check it is declining on
236
+ (with its run URL) or the operator is left to go find it.
237
+ """
238
+
239
+ state: str
240
+ failing: tuple[CheckContext, ...] = ()
241
+ running: tuple[CheckContext, ...] = ()
242
+ total: int = 0
243
+ # Why `state` is CHECKS_UNKNOWN -- an API error, or the page bound. EVERY
244
+ # read failure lands here, including a 404: this deliberately does not try
245
+ # to tell "the commit is gone" from any other failure by its error string.
246
+ # A 404 also answers "the token cannot see this private repo", and the
247
+ # branch that stepped aside for a presumed force-push posted an UNGATED
248
+ # approve on a head whose rollup was never read -- fail-open, on a substring
249
+ # match, in the one component whose contract is to fail closed. The
250
+ # force-push case is still released: the degraded post gets the same 422
251
+ # from GitHub and reaches loop._abandon_verdict, which proves absence from
252
+ # the PR's commit list rather than trusting the message.
253
+ unreadable: str = ""
254
+
255
+ @property
256
+ def summary(self) -> str:
257
+ """One log-line description of the rollup."""
258
+ if self.state == CHECKS_RED:
259
+ return f"red — failing: {check_names(self.failing)}"
260
+ if self.state == CHECKS_PENDING:
261
+ return f"pending — still running: {check_names(self.running)}"
262
+ if self.state == CHECKS_UNKNOWN:
263
+ return f"unreadable — {self.unreadable or 'no reason recorded'}"
264
+ return f"green — {self.total} context(s), none failing or running"
265
+
266
+
267
+ def check_names(contexts: "tuple[CheckContext, ...]") -> str:
268
+ """The contexts' names for a log line, or "none"."""
269
+ return ", ".join(c.name for c in contexts) or "none"
270
+
271
+
272
+ def rollup_of(contexts: "list[CheckContext]") -> CheckRollup:
273
+ """Reduce read contexts to a rollup state.
274
+
275
+ Precedence is failure over running, deliberately: a commit with one job
276
+ already red and three still going is not "wait and see" -- it cannot be
277
+ approved at all, and saying so now beats saying it half an hour later.
278
+
279
+ NO contexts is GREEN, not pending. A repo (or a commit) with no CI is the
280
+ pre-gate behaviour and must approve exactly as it always did; the pending
281
+ hold is for checks that exist and have not finished.
282
+ """
283
+ failing = tuple(c for c in contexts if not c.running and not c.passing)
284
+ running = tuple(c for c in contexts if c.running)
285
+ if failing:
286
+ state = CHECKS_RED
287
+ elif running:
288
+ state = CHECKS_PENDING
289
+ else:
290
+ state = CHECKS_GREEN
291
+ return CheckRollup(
292
+ state=state, failing=failing, running=running, total=len(contexts)
293
+ )
294
+
295
+
162
296
  def countable_rounds(reviews: list["Review"]) -> int:
163
297
  """Rounds represented by the reviewer's substantive reviews.
164
298
 
@@ -504,6 +638,161 @@ class GitHub:
504
638
  return out
505
639
  page += 1
506
640
 
641
+ # -- CI check rollup ---------------------------------------------------
642
+
643
+ def check_rollup(self, owner: str, repo: str, sha: str) -> CheckRollup:
644
+ """The CI rollup for ONE commit -- never for "the PR".
645
+
646
+ Pinned to a SHA on purpose, and the SHA the caller passes is the one its
647
+ verdict is recorded against: `gh pr checks` (and the GraphQL
648
+ `statusCheckRollup` behind it) answers for whatever the PR's head is at
649
+ the moment of the call, which is a different commit as soon as the
650
+ implementer pushes mid-round. Approving commit A on commit B's rollup is
651
+ the failure this whole path exists to prevent, so the commit endpoints
652
+ are read instead: they answer about the commit named in the URL and
653
+ nothing else.
654
+
655
+ BOTH kinds of context are read. Check runs are what GitHub Actions
656
+ produces; legacy commit statuses are what external CI still posts, and a
657
+ repo can have either or both. The combined-status endpoint answers
658
+ `state: pending` for a commit with NO statuses at all, so an empty list
659
+ is dropped rather than read as "something is running" -- otherwise every
660
+ approve on every Actions-only repo would hold until the wait bound.
661
+
662
+ Never raises except RateLimited (which run_forever's backoff owns): an
663
+ unreadable rollup is a CHECKS_UNKNOWN answer the caller can hold on, not
664
+ a reason to abort a poll pass that has other PRs to decide.
665
+ """
666
+ contexts: list[CheckContext] = []
667
+ try:
668
+ contexts.extend(self._check_runs(owner, repo, sha))
669
+ contexts.extend(self._commit_statuses(owner, repo, sha))
670
+ except RateLimited:
671
+ raise
672
+ except TruncatedListing as exc:
673
+ return CheckRollup(CHECKS_UNKNOWN, unreadable=str(exc)[:300])
674
+ except Exception as exc:
675
+ return CheckRollup(
676
+ CHECKS_UNKNOWN, unreadable=f"{type(exc).__name__}: {exc}"[:300]
677
+ )
678
+ return rollup_of(contexts)
679
+
680
+ def _rollup_listing(self, path: str, key: str, what: str) -> list[dict]:
681
+ """Page ONE rollup listing to completion, or refuse to answer.
682
+
683
+ Completeness is decided by two signals, and the pair is the point --
684
+ neither alone is sound here:
685
+
686
+ * the payload's own `total_count`. Verified against this repo's live
687
+ `check-runs` (6 runs, `total_count: 6`), and it is what makes the page
688
+ bound exact rather than off by one: a listing of exactly
689
+ CHECK_RUN_PAGE_LIMIT * PER_PAGE entries is COMPLETE and says so,
690
+ instead of reading as truncated because its last page happened to be
691
+ full.
692
+ * a short page, the standard end-of-listing signal, used when the
693
+ endpoint reports no count.
694
+
695
+ `total_count` never ends the read EARLY except by being satisfied, and a
696
+ page that comes back empty ends it too -- an endpoint that claims more
697
+ than it will serve must not spin the bound. But a count that is still
698
+ unsatisfied when the pages run out REFUSES: `TruncatedListing`, which
699
+ `check_rollup` turns into CHECKS_UNKNOWN. That is the case the finding
700
+ on this method was about -- an unpaged read of a 35-context commit saw
701
+ 30 successes and called a red head green -- and the direction has to be
702
+ "cannot answer", never "nothing failing in what I got".
703
+
704
+ `forbidden_is_rate_limit=False` for the same reason `submit_review`
705
+ passes it: a 403 here is an authorization fact about the deployment (a
706
+ credential without `checks: read`) with its own handling one layer up,
707
+ and collapsing it into RateLimited would abort the whole poll pass and
708
+ double run_forever's backoff instead of degrading this one verdict.
709
+ """
710
+ out: list[dict] = []
711
+ total: int | None = None
712
+ for page in range(1, CHECK_RUN_PAGE_LIMIT + 1):
713
+ data = (
714
+ self._api(
715
+ "-X",
716
+ "GET",
717
+ path,
718
+ "-f",
719
+ f"per_page={PER_PAGE}",
720
+ "-f",
721
+ f"page={page}",
722
+ forbidden_is_rate_limit=False,
723
+ )
724
+ or {}
725
+ )
726
+ items = list(data.get(key) or [])
727
+ if total is None and isinstance(data.get("total_count"), int):
728
+ total = int(data["total_count"])
729
+ out.extend(items)
730
+ if total is None:
731
+ if len(items) < PER_PAGE:
732
+ return out
733
+ continue
734
+ if len(out) >= total:
735
+ return out
736
+ if not items:
737
+ break
738
+ raise TruncatedListing(
739
+ f"{path} reports {total if total is not None else 'more'} {what} but "
740
+ f"served {len(out)} within {CHECK_RUN_PAGE_LIMIT} page(s); the rollup "
741
+ f"cannot be read completely, so it cannot be called green"
742
+ )
743
+
744
+ def _check_runs(self, owner: str, repo: str, sha: str) -> list[CheckContext]:
745
+ """The commit's check runs.
746
+
747
+ A run that is not `completed` carries no conclusion here whatever its
748
+ payload says, so "queued", "in_progress" and "waiting" all read as
749
+ running -- the states GitHub can add to that list are exactly the ones a
750
+ gate must not mistake for a verdict.
751
+ """
752
+ runs = self._rollup_listing(
753
+ f"repos/{owner}/{repo}/commits/{sha}/check-runs", "check_runs", "check runs"
754
+ )
755
+ out: list[CheckContext] = []
756
+ for run_ in runs:
757
+ completed = str(run_.get("status") or "").lower() == "completed"
758
+ out.append(
759
+ CheckContext(
760
+ name=str(run_.get("name") or "unnamed check"),
761
+ conclusion=(
762
+ str(run_.get("conclusion") or "").lower() if completed else ""
763
+ ),
764
+ url=str(run_.get("html_url") or run_.get("details_url") or ""),
765
+ )
766
+ )
767
+ return out
768
+
769
+ def _commit_statuses(self, owner: str, repo: str, sha: str) -> list[CheckContext]:
770
+ """The commit's legacy statuses, one context each.
771
+
772
+ Paged like the check runs, and for a sharper reason: this endpoint's
773
+ default page size is 30, so the unpaged read this replaced could see 30
774
+ successes on a 35-context commit and call a red head green.
775
+
776
+ The combined `state` is deliberately ignored and only the `statuses`
777
+ array is read: the endpoint answers `state: pending` for a commit with NO
778
+ statuses at all (verified live: `total_count: 0`, `state: pending`), so
779
+ trusting it would hold every approve on every check-runs-only repo.
780
+ """
781
+ statuses = self._rollup_listing(
782
+ f"repos/{owner}/{repo}/commits/{sha}/status", "statuses", "statuses"
783
+ )
784
+ out: list[CheckContext] = []
785
+ for status in statuses:
786
+ state = str(status.get("state") or "").lower()
787
+ out.append(
788
+ CheckContext(
789
+ name=str(status.get("context") or "unnamed status"),
790
+ conclusion=_STATUS_CONCLUSIONS.get(state, state),
791
+ url=str(status.get("target_url") or ""),
792
+ )
793
+ )
794
+ return out
795
+
507
796
  def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
508
797
  """My substantive submitted reviews, oldest first -- one per round.
509
798
 
@@ -551,11 +840,16 @@ class GitHub:
551
840
  under any other login (see assert_review_identity). `commit_id` pins
552
841
  the review to the head it judged, so a later push cannot make an old
553
842
  verdict look current.
843
+
844
+ COMMENT is accepted, narrowly: it is what an approve DEGRADES to when
845
+ the head's CI rollup never concluded (loop._gate_on_checks). It still
846
+ cannot express approval -- that is why the gate reaches for it -- so a
847
+ round closed by one converges nothing.
554
848
  """
555
- if event not in (EVENT_APPROVE, EVENT_REQUEST_CHANGES):
849
+ if event not in (EVENT_APPROVE, EVENT_REQUEST_CHANGES, EVENT_COMMENT):
556
850
  raise ValueError(
557
- f"event must be {EVENT_APPROVE} or {EVENT_REQUEST_CHANGES}, "
558
- f"got {event!r} — a COMMENT review cannot close a round"
851
+ f"event must be one of {EVENT_APPROVE}, {EVENT_REQUEST_CHANGES}, "
852
+ f"{EVENT_COMMENT}, got {event!r}"
559
853
  )
560
854
  login = self.assert_review_identity()
561
855
  log.info(
@@ -36,8 +36,14 @@ from .config import (
36
36
  Config,
37
37
  )
38
38
  from .ghclient import (
39
+ CHECKS_GREEN,
40
+ CHECKS_PENDING,
41
+ CHECKS_RED,
42
+ CHECKS_UNKNOWN,
39
43
  EVENT_APPROVE,
44
+ EVENT_COMMENT,
40
45
  EVENT_REQUEST_CHANGES,
46
+ CheckRollup,
41
47
  GitHub,
42
48
  IssueComment,
43
49
  PullRequest,
@@ -93,6 +99,14 @@ MAX_VERDICT_POST_ATTEMPTS = 5
93
99
  # day, per stuck PR, with the operator paged exactly once.
94
100
  MAX_VERDICT_POST_BACKOFF_SECONDS = 60 * 60
95
101
 
102
+ # The GitHub review states the CI gate can produce for a round it refused to
103
+ # approve: a red head lands as CHANGES_REQUESTED, a rollup that never concluded
104
+ # as COMMENTED. Read by _convergence_reason, which must not converge on an
105
+ # approve envelope whose native verdict is one of these -- and must not be
106
+ # broader than this, or it would silently redefine what a DISMISSED review means
107
+ # for convergence (see the comment there).
108
+ GATED_VERDICT_STATES = ("COMMENTED", "CHANGES_REQUESTED")
109
+
96
110
 
97
111
  def _post_delay_after(attempts: int) -> float:
98
112
  """Seconds to wait after attempt `attempts` before trying again.
@@ -138,6 +152,83 @@ HEAD_MOVED_NOTE = (
138
152
  "count as a verdict on the newer code — the next round is owed."
139
153
  )
140
154
 
155
+ # Prepended to a verdict body whose APPROVE the CI gate turned into a
156
+ # REQUEST_CHANGES: the head's own checks are red. It leads with the failing
157
+ # names and run URLs because that is the only actionable content of the review
158
+ # -- the code review found nothing (the envelope reads approve) and the operator
159
+ # would otherwise have to go hunting for what "not approved" refers to.
160
+ CHECKS_RED_LEAD = (
161
+ "**Not approving `{sha}` — its CI checks are red.**\n\n"
162
+ "{failing}\n\n"
163
+ "The code review itself reached `{verdict}`: nothing below is a new finding "
164
+ "about the diff. What blocks the approve is the head's own check rollup. An "
165
+ "approve from this identity is the operator's merge cue, so it has to mean "
166
+ "*reviewed AND green* — on 2026-07-29 (studio #323) a round approved 3.4 "
167
+ "hours after CI had gone red, and the red sat unaddressed because the "
168
+ "approve read as ready.\n\n"
169
+ "Fix (or re-run) the check(s) above and re-request review; the next round "
170
+ "can approve the same code on a green head. No label was touched.\n\n"
171
+ )
172
+
173
+ # One bullet per blocking context. The URL is what makes the finding walkable;
174
+ # a check run without one (rare, and never for Actions) still names itself.
175
+ CHECKS_FAILING_LINE = "- `{name}` — {conclusion}{url}"
176
+
177
+ # Prepended when the gate held the approve for the whole wait bound and the
178
+ # rollup still had not settled, so the verdict lands as a COMMENT.
179
+ CHECKS_UNSETTLED_LEAD = (
180
+ "**Recorded as a comment, not an approve — the CI rollup at `{sha}` never "
181
+ "concluded.**\n\n"
182
+ "{detail}\n\n"
183
+ "This round's verdict was held for {waited} min waiting for the head's "
184
+ "checks to settle ({bound} min bound) and they did not, so it is recorded "
185
+ "as a comment: an approve would claim a head this loop never saw go green. "
186
+ "Nothing about the review itself changed — the verdict below is the round's "
187
+ "own.\n\n"
188
+ "Submitting this review consumes the pending review request, so the daemon "
189
+ "will not look at this PR again on its own: **conclude or fix the checks and "
190
+ "re-request review**, and the next round can approve the same code on a "
191
+ "green head. No label was touched.\n\n"
192
+ )
193
+
194
+ # The operator page that follows a DEGRADED verdict, and the reason it exists:
195
+ # submitting any review -- `COMMENT` included -- consumes the pending review
196
+ # request, so the PR leaves `review-requested:@me` the moment the degraded
197
+ # verdict lands. Nothing then brings it back on its own: a `COMMENT` is not what
198
+ # the DEV fix/re-request flow keys on (that reads REQUEST_CHANGES), and the
199
+ # operator's own cue, an APPROVE, never comes. Without this the PR strands
200
+ # silently with an unsettled rollup and no cap-out -- the same class as the #227
201
+ # latch. The red path needs no page: REQUEST_CHANGES re-enters by itself.
202
+ #
203
+ # One page per (round, judged head) through the ping ledger, and it names what
204
+ # to do rather than just what happened.
205
+ CHECKS_UNSETTLED_PAGE = (
206
+ "**Review round {round} could not approve `{sha}` — its CI rollup never "
207
+ "concluded.**\n\n"
208
+ "{detail}\n\n"
209
+ "The round's verdict is on the PR as a comment-mode review ({url}) rather "
210
+ "than an approve: the code review itself reached `{verdict}`, but an approve "
211
+ "from `{reviewer}` is the merge cue and this loop never saw the head go "
212
+ "green.\n\n"
213
+ "**This needs a human, because the loop cannot re-enter on its own.** "
214
+ "Submitting that review consumed the pending review request, so this PR has "
215
+ "left the daemon's attention set. Two ways forward:\n\n"
216
+ "1. conclude or fix the checks, then **re-request review from "
217
+ "`{reviewer}`** — the next round reviews the same code and can approve it on "
218
+ "a green head;\n"
219
+ "2. merge with a recorded waiver, if the unsettled checks are known-broken "
220
+ "rather than known-red.\n\n"
221
+ "No label was touched and no further round is queued."
222
+ )
223
+
224
+ # The `{detail}` above, per reason the rollup did not settle.
225
+ CHECKS_STILL_RUNNING = "Still running at the bound: {names}."
226
+ CHECKS_UNREADABLE = (
227
+ "The rollup could not be read: `{why}`. An unreadable rollup is not a green "
228
+ "one — check that the reviewer credential carries `checks: read` on this "
229
+ "repo."
230
+ )
231
+
141
232
  # The operator page for a native verdict post that keeps failing. Loud on
142
233
  # purpose: until it lands the round is NOT closed, so the loop is stalled on
143
234
  # this PR and no amount of waiting fixes it.
@@ -546,6 +637,33 @@ def parse_reentry_ack(body: str) -> Ack:
546
637
  return Ack(rounds=rounds)
547
638
 
548
639
 
640
+ def checks_unsettled_kind(round_: int, head_sha: str) -> str:
641
+ """The ping-ledger kind that dedupes ONE degraded round's operator page.
642
+
643
+ Per (round, head) like the hold note, and recorded only AFTER the comment
644
+ lands: this page is the operator's only signal that a PR has left the loop
645
+ holding an unsettled rollup, so a transient comment failure must retry
646
+ rather than be swallowed.
647
+ """
648
+ return f"checks-unsettled:{round_}:{head_sha}"
649
+
650
+
651
+ def checks_hold_kind(round_: int, head_sha: str) -> str:
652
+ """The ping-ledger kind that dedupes ONE held round's activity line.
653
+
654
+ A hold is re-decided every poll, so an append per decision would grow the
655
+ activity comment by an identical line a minute for as long as CI runs. One
656
+ line per (round, head) says the whole thing -- "this round is waiting on the
657
+ checks at this commit" -- and the head is in the key because a round whose
658
+ verdict is pinned to a different commit is a different wait.
659
+
660
+ The issue's contract is at most ONE waiting note per round and no new
661
+ comments; this appends to the existing mechanical activity comment rather
662
+ than posting its own, so a held round costs zero new comments.
663
+ """
664
+ return f"checks-hold:{round_}:{head_sha}"
665
+
666
+
549
667
  def verdict_post_kind(round_: int) -> str:
550
668
  """The ping-ledger kind that dedupes ONE round's failed-post page.
551
669
 
@@ -599,6 +717,32 @@ class Decision:
599
717
  reenqueued: bool = False
600
718
 
601
719
 
720
+ @dataclass(frozen=True)
721
+ class ChecksGate:
722
+ """What the head's CI rollup does to a round's APPROVE verdict.
723
+
724
+ Three shapes, matching the three things the gate can decide:
725
+
726
+ * `hold` set -- do not post at all this poll (the rollup has not settled and
727
+ the wait bound has not run out). The round stays open, exactly as it does
728
+ inside the pre-post grace window.
729
+ * `event` changed -- post, but not as an approve: REQUEST_CHANGES on a red
730
+ head, COMMENT when the rollup never settled. `lead` is prepended to the
731
+ verdict body and names the checks.
732
+ * neither -- green (or nothing to gate): the post proceeds unchanged.
733
+ """
734
+
735
+ hold: Decision | None = None
736
+ event: str = EVENT_APPROVE
737
+ lead: str = ""
738
+ # The rollup state that produced this decision, for the log/activity line.
739
+ state: str = CHECKS_GREEN
740
+ # The human-readable "why it did not settle", reused verbatim in the
741
+ # operator page that follows a degraded verdict (see _page_unsettled_checks)
742
+ # so the page and the review record cannot drift apart.
743
+ detail: str = ""
744
+
745
+
602
746
  def session_name(pr: PullRequest, round_: int) -> str:
603
747
  """A tmux-safe reviewer session name, unique per spawn.
604
748
 
@@ -651,6 +795,12 @@ class ReviewWatcher:
651
795
  # dry-run should not re-announce the same drift every poll either --
652
796
  # see _warn_identity_drift.
653
797
  self._dry_run_drift: set[tuple[str, int, str]] = set()
798
+ # (repo slug, number, round, judged head) -> the rollup summary this
799
+ # process already read for it in DRY-RUN. The dry-run branch returns
800
+ # before any ledger write, so without this the diagnostic re-reads the
801
+ # rollup (two API calls) on every poll, forever, for every PR with an
802
+ # owed approve. In-memory for the same reason _dry_run_drift is.
803
+ self._dry_run_rollups: dict[tuple[str, int, int, str], str] = {}
654
804
 
655
805
  # -- per-PR decision ---------------------------------------------------
656
806
 
@@ -809,14 +959,37 @@ class ReviewWatcher:
809
959
  )
810
960
 
811
961
  if self.config.dry_run:
962
+ # The rollup is READ here even in dry-run (a read takes nothing on
963
+ # GitHub and writes no ledger row), because "would this approve have
964
+ # been gated?" is exactly the question an operator reaches for
965
+ # dry-run to answer -- the same argument the identity-drift check
966
+ # makes for observing rather than staying silent.
967
+ # ...but ONCE per round per head, not every poll. A dry-run pass
968
+ # returns before the ledger bookkeeping (deliberately: it must write
969
+ # no durable state), so the dedupe is in-memory and
970
+ # process-lifetime, exactly as _dry_run_drift is and for the same
971
+ # reason -- a dry-run observation must never be silenced by
972
+ # something a production pass could have written, or the other way
973
+ # round.
974
+ checks = ""
975
+ if verdict == VERDICT_APPROVE:
976
+ judged = self._judged_head(pr, round_)
977
+ seen = (pr.full_name, pr.number, round_, judged)
978
+ if seen not in self._dry_run_rollups:
979
+ rollup = self.github.check_rollup(pr.owner, pr.repo, judged)
980
+ self._dry_run_rollups[seen] = rollup.summary
981
+ checks = (
982
+ f"; CI rollup at the judged head is "
983
+ f"{self._dry_run_rollups[seen]}"
984
+ )
812
985
  log.info(
813
- "[dry-run] would submit a native %s review for round %d of %s",
814
- verdict, round_, pr.slug,
986
+ "[dry-run] would submit a native %s review for round %d of %s%s",
987
+ verdict, round_, pr.slug, checks,
815
988
  )
816
989
  return Decision(
817
990
  Action.AWAITING_POST,
818
991
  f"[dry-run] round {round_} would be closed with a native "
819
- f"{verdict} review",
992
+ f"{verdict} review{checks}",
820
993
  round_,
821
994
  task_ref=task.ref,
822
995
  )
@@ -853,7 +1026,18 @@ class ReviewWatcher:
853
1026
  judged = str(row["head_sha"] or pr.head_sha)
854
1027
  moved = bool(judged) and judged != pr.head_sha
855
1028
  event = EVENT_APPROVE if verdict == VERDICT_APPROVE else EVENT_REQUEST_CHANGES
856
- body = NATIVE_VERDICT_BODY.format(
1029
+
1030
+ # THE CI GATE (issue #58). Only an APPROVE is gated: a REQUEST_CHANGES on
1031
+ # a red head is already a "not ready" signal and says nothing that CI
1032
+ # could contradict.
1033
+ gate = ChecksGate()
1034
+ if event == EVENT_APPROVE:
1035
+ gate = self._gate_on_checks(pr, task, round_, judged)
1036
+ if gate.hold is not None:
1037
+ return gate.hold
1038
+ event = gate.event
1039
+
1040
+ body = gate.lead + NATIVE_VERDICT_BODY.format(
857
1041
  round=round_,
858
1042
  verdict=verdict,
859
1043
  task_note=_VERDICT_TASK_NOTE.format(task_ref=task.ref),
@@ -881,24 +1065,226 @@ class ReviewWatcher:
881
1065
  # take down a poll pass that has other PRs to decide.
882
1066
  return self._verdict_post_failed(pr, task, round_, verdict, judged, exc)
883
1067
 
1068
+ # Named in all three records when the gate downgraded the verdict, so
1069
+ # "the envelope says approve but GitHub says otherwise" is never a
1070
+ # mystery in the log, the activity comment, or the poll snapshot.
1071
+ gate_note = (
1072
+ ""
1073
+ if gate.state == CHECKS_GREEN
1074
+ else f" — CI gate: the rollup at {judged[:8]} is {gate.state}, so the "
1075
+ f"{verdict} envelope did not post as an APPROVE"
1076
+ )
884
1077
  self.state.record_verdict_post(pr.full_name, pr.number, round_, url)
885
1078
  log.info(
886
- "%s round %d closed: native %s review submitted as %s (%s)",
887
- pr.slug, round_, event, self.github.login, url or "no url",
1079
+ "%s round %d closed: native %s review submitted as %s (%s)%s",
1080
+ pr.slug, round_, event, self.github.login, url or "no url", gate_note,
888
1081
  )
889
1082
  self._append_activity(
890
1083
  pr,
891
1084
  f"- {_now()} — round {round_} — native `{event}` review submitted "
892
- f"as `{self.github.login}` (verdict of record)",
1085
+ f"as `{self.github.login}` (verdict of record){gate_note}",
893
1086
  )
1087
+ if event == EVENT_COMMENT:
1088
+ # A degraded verdict takes the PR out of the loop (the review
1089
+ # request is consumed) without leaving a signal anything acts on.
1090
+ self._page_unsettled_checks(pr, round_, judged, verdict, url, gate.detail)
894
1091
  return Decision(
895
1092
  Action.POSTED,
896
1093
  f"round {round_} closed with a native {event} review as "
897
- f"{self.github.login}",
1094
+ f"{self.github.login}{gate_note}",
898
1095
  round_,
899
1096
  task_ref=task.ref,
900
1097
  )
901
1098
 
1099
+ def _gate_on_checks(
1100
+ self, pr: PullRequest, task: Task, round_: int, judged: str
1101
+ ) -> ChecksGate:
1102
+ """Decide what the CI rollup at `judged` does to this round's approve.
1103
+
1104
+ The rule the gate exists to keep: an APPROVE by the reviewer identity
1105
+ means *reviewed AND green*, because it is the operator's cue to merge.
1106
+ On studio #323 a round approved 3.4 hours after the head's `test` check
1107
+ had gone red, the operator's merge gate received an "approved, ready" PR
1108
+ that was unmergeable-red, and the failure sat unaddressed until a human
1109
+ noticed (issue #58).
1110
+
1111
+ Read on the head the verdict is PINNED to, never the PR's current head:
1112
+ approving commit A on commit B's rollup is the same class of error as
1113
+ stamping an old verdict onto a new head (see _judged_head). When the two
1114
+ differ the existing head-moved handling still does its job -- the review
1115
+ is recorded against `judged` and does not converge the loop -- and this
1116
+ gate additionally refuses to call that commit green on someone else's
1117
+ checks.
1118
+
1119
+ What it never does: touch labels. `alissa:maintain` and every other
1120
+ cross-daemon trigger stays an operator/devloop concern; revloop shapes
1121
+ its own verdict and nothing else. Nor does it post a comment of its own
1122
+ while waiting -- the wait note goes into the existing mechanical activity
1123
+ comment, once per held round (checks_hold_kind).
1124
+ """
1125
+ rollup = self.github.check_rollup(pr.owner, pr.repo, judged)
1126
+
1127
+ if rollup.state == CHECKS_GREEN:
1128
+ log.debug(
1129
+ "%s round %d: CI rollup at %s is %s — approving as usual",
1130
+ pr.slug, round_, judged[:8], rollup.summary,
1131
+ )
1132
+ return ChecksGate()
1133
+
1134
+ if rollup.state == CHECKS_RED:
1135
+ log.warning(
1136
+ "%s round %d: NOT approving %s — its CI rollup is %s. The "
1137
+ "%s envelope lands as a %s review instead",
1138
+ pr.slug, round_, judged[:8], rollup.summary,
1139
+ VERDICT_APPROVE, EVENT_REQUEST_CHANGES,
1140
+ )
1141
+ failing = "\n".join(
1142
+ CHECKS_FAILING_LINE.format(
1143
+ name=context.name,
1144
+ conclusion=context.conclusion or "not concluded",
1145
+ url=f" — {context.url}" if context.url else "",
1146
+ )
1147
+ for context in rollup.failing
1148
+ )
1149
+ return ChecksGate(
1150
+ event=EVENT_REQUEST_CHANGES,
1151
+ lead=CHECKS_RED_LEAD.format(
1152
+ sha=judged[:8], failing=failing, verdict=VERDICT_APPROVE
1153
+ ),
1154
+ state=CHECKS_RED,
1155
+ )
1156
+
1157
+ # PENDING (checks still running) or UNKNOWN (the rollup could not be
1158
+ # read) -- neither can support an approve, and both can settle, so both
1159
+ # HOLD the round first and only degrade at the bound.
1160
+ #
1161
+ # The clock is stamped against the CONDITION being waited on, and gets
1162
+ # exactly one restart: when an `unknown` hold is promoted to a `pending`
1163
+ # one. A transient read failure is not an observation of checks running,
1164
+ # so it must not eat the bound a real suite is entitled to. One restart,
1165
+ # never "restart whenever the state changes" -- a reader flapping between
1166
+ # the two would then push the bound out forever, which is precisely the
1167
+ # unbounded hold this bound exists to prevent.
1168
+ held_at, held_state = self.state.checks_hold(pr.full_name, pr.number, round_)
1169
+ promoted = held_state == CHECKS_UNKNOWN and rollup.state == CHECKS_PENDING
1170
+ if held_at is None or promoted:
1171
+ held_at = self.state.record_checks_hold(
1172
+ pr.full_name, pr.number, round_, rollup.state
1173
+ )
1174
+ waited = max(time.time() - held_at, 0)
1175
+ bound = self.config.checks_wait_seconds
1176
+ if waited < bound:
1177
+ # One line per poll, one activity note per held round; see
1178
+ # checks_hold_kind.
1179
+ log.info(
1180
+ "%s round %d: holding its %s — CI rollup at %s is %s (%dm of the "
1181
+ "%dm bound waited)",
1182
+ pr.slug, round_, VERDICT_APPROVE, judged[:8], rollup.summary,
1183
+ waited // 60, bound // 60,
1184
+ )
1185
+ self._note_checks_hold(pr, round_, judged, rollup)
1186
+ return ChecksGate(
1187
+ hold=Decision(
1188
+ Action.AWAITING_POST,
1189
+ f"round {round_} holds its {VERDICT_APPROVE} — the CI rollup "
1190
+ f"at {judged[:8]} is {rollup.summary}; "
1191
+ f"{int((bound - waited) // 60)}m of the wait bound left",
1192
+ round_,
1193
+ task_ref=task.ref,
1194
+ ),
1195
+ state=rollup.state,
1196
+ )
1197
+
1198
+ log.warning(
1199
+ "%s round %d: the CI rollup at %s is still %s after %dm — recording "
1200
+ "the %s envelope as a %s review, never an APPROVE on an unverified "
1201
+ "head",
1202
+ pr.slug, round_, judged[:8], rollup.summary, waited // 60,
1203
+ VERDICT_APPROVE, EVENT_COMMENT,
1204
+ )
1205
+ detail = (
1206
+ CHECKS_STILL_RUNNING.format(
1207
+ names=", ".join(f"`{c.name}`" for c in rollup.running) or "none"
1208
+ )
1209
+ if rollup.state == CHECKS_PENDING
1210
+ else CHECKS_UNREADABLE.format(why=rollup.unreadable or "no reason recorded")
1211
+ )
1212
+ return ChecksGate(
1213
+ event=EVENT_COMMENT,
1214
+ lead=CHECKS_UNSETTLED_LEAD.format(
1215
+ sha=judged[:8],
1216
+ detail=detail,
1217
+ waited=int(waited // 60),
1218
+ bound=int(bound // 60),
1219
+ ),
1220
+ state=rollup.state,
1221
+ detail=detail,
1222
+ )
1223
+
1224
+ def _page_unsettled_checks(
1225
+ self,
1226
+ pr: PullRequest,
1227
+ round_: int,
1228
+ judged: str,
1229
+ verdict: str,
1230
+ url: str,
1231
+ detail: str,
1232
+ ) -> None:
1233
+ """Page the operator ONCE for a round that degraded to a comment.
1234
+
1235
+ See CHECKS_UNSETTLED_PAGE for why this is owed: the degraded verdict
1236
+ consumes the review request, so the PR silently leaves the poll set with
1237
+ nothing downstream of it. The ledger row lands only AFTER the comment
1238
+ does, as with every ping here -- but note this one may not get a second
1239
+ chance, because the very post that made it necessary is what takes the
1240
+ PR out of the search. So a failure logs at ERROR (with the body, which is
1241
+ then the only place the diagnosis exists) rather than at warning.
1242
+ """
1243
+ kind = checks_unsettled_kind(round_, judged)
1244
+ if self.state.pinged(pr.full_name, pr.number, kind):
1245
+ return
1246
+ body = CHECKS_UNSETTLED_PAGE.format(
1247
+ round=round_,
1248
+ sha=judged[:8],
1249
+ detail=detail,
1250
+ url=url or "no url recorded",
1251
+ verdict=verdict,
1252
+ reviewer=self.github.login,
1253
+ )
1254
+ try:
1255
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
1256
+ except Exception as exc:
1257
+ log.error(
1258
+ "%s round %d: could not post the unsettled-checks page (%s). The "
1259
+ "degraded verdict consumed the review request, so this PR has "
1260
+ "left the poll set and the page may never be retried — it needed "
1261
+ "to say:\n%s",
1262
+ pr.slug, round_, exc, body,
1263
+ )
1264
+ return
1265
+ self.state.record_ping(pr.full_name, pr.number, kind)
1266
+
1267
+ def _note_checks_hold(
1268
+ self, pr: PullRequest, round_: int, judged: str, rollup: CheckRollup
1269
+ ) -> None:
1270
+ """Append ONE activity line for a round held on its checks.
1271
+
1272
+ Ledger row after the append, like every other activity note here, so a
1273
+ transient comment failure retries next poll instead of losing the line.
1274
+ """
1275
+ kind = checks_hold_kind(round_, judged)
1276
+ if self.state.pinged(pr.full_name, pr.number, kind):
1277
+ return
1278
+ if not self._append_activity(
1279
+ pr,
1280
+ f"- {_now()} — round {round_} — verdict held: the CI rollup at "
1281
+ f"`{judged[:8]}` is {rollup.summary}. An approve has to mean "
1282
+ f"reviewed AND green, so the round stays open "
1283
+ f"(bound: {self.config.checks_wait_seconds // 60}m).",
1284
+ ):
1285
+ return
1286
+ self.state.record_ping(pr.full_name, pr.number, kind)
1287
+
902
1288
  def _judged_head(self, pr: PullRequest, round_: int) -> str:
903
1289
  """The head round `round_`'s verdict is ABOUT — not the current one.
904
1290
 
@@ -1361,6 +1747,29 @@ class ReviewWatcher:
1361
1747
  if newest.state == "APPROVED":
1362
1748
  return "last GitHub review state is APPROVED"
1363
1749
 
1750
+ # A native verdict THIS daemon posted at this head that is NOT an
1751
+ # approve is the daemon's own refusal to approve the head, and it
1752
+ # outranks the envelope it was posted from. Today only the CI gate
1753
+ # produces that shape (issue #58): a red or never-concluded rollup turns
1754
+ # an approve envelope into a REQUEST_CHANGES or COMMENT review. Without
1755
+ # this, the very next poll would converge on the envelope, withdraw the
1756
+ # review request, and take the PR out of the loop on a verdict the
1757
+ # daemon deliberately declined to post as an APPROVE -- leaving nothing
1758
+ # to re-enter and no later green round to approve it.
1759
+ #
1760
+ # Deliberately narrowed to the two states the gate can produce.
1761
+ # `SUBMITTED_STATES` also includes DISMISSED, and a daemon-posted review
1762
+ # carries its `verdict_round` forever -- so a broader test would ALSO
1763
+ # change what a dismissed approve does to convergence (today the
1764
+ # envelope still converges it). That may well be wrong, but it is
1765
+ # pre-existing and its own question: TASK-939082213. This change is
1766
+ # exactly the one its rationale claims.
1767
+ if (
1768
+ newest.verdict_round is not None
1769
+ and newest.state in GATED_VERDICT_STATES
1770
+ ):
1771
+ return None
1772
+
1364
1773
  # Only checkable once a review task exists; before that there is
1365
1774
  # nowhere for a verdict to have been recorded.
1366
1775
  if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
@@ -107,6 +107,20 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
107
107
  -- no longer a commit of the PR (a force-push landed under it). The round is
108
108
  -- then released rather than held open forever; see loop._abandon_verdict.
109
109
  abandoned_at INTEGER,
110
+ -- When this round's APPROVE was FIRST held back because the judged head's
111
+ -- CI rollup had not settled. The wait bound is measured from here, so a
112
+ -- rollup that never concludes degrades the verdict to a comment at a fixed
113
+ -- distance from the first observation rather than from whenever the last
114
+ -- poll happened to land. NULL means the gate never held this round.
115
+ checks_held_at INTEGER,
116
+ -- WHICH unsettled condition that stamp belongs to ('pending' -- checks are
117
+ -- genuinely running -- or 'unknown' -- the rollup could not be read). The
118
+ -- bound is defined against the first observation of the condition being
119
+ -- waited on, and a transient read error is not that observation, so the
120
+ -- clock gets exactly one restart when an 'unknown' hold is promoted to a
121
+ -- 'pending' one. The policy lives in loop._gate_on_checks; this column is
122
+ -- what lets it be decided from the ledger instead of from memory.
123
+ checks_held_state TEXT,
110
124
  review_url TEXT,
111
125
  last_error TEXT,
112
126
  PRIMARY KEY (repo, number, round)
@@ -133,16 +147,24 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
133
147
  );
134
148
  """
135
149
 
136
- # Columns added to `poll_snapshots` after it first shipped. CREATE TABLE IF NOT
137
- # EXISTS is not a migration: an existing database keeps the ORIGINAL table, so
138
- # these are ALTERed in on open. Each carries a NOT NULL default, which is what
139
- # lets an ALTER backfill every historical row without a rewrite -- an old pass
140
- # genuinely posted no verdicts, so 0 is the true value, not a placeholder.
141
- _SNAPSHOT_ADDED_COLUMNS = (
142
- ("posted", "INTEGER NOT NULL DEFAULT 0"),
143
- ("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
144
- ("abandoned", "INTEGER NOT NULL DEFAULT 0"),
145
- )
150
+ # Columns added to a table after it first shipped. CREATE TABLE IF NOT EXISTS is
151
+ # not a migration: an existing database keeps the ORIGINAL table, so these are
152
+ # ALTERed in on open. A NOT NULL default is what lets an ALTER backfill every
153
+ # historical row without a rewrite -- an old pass genuinely posted no verdicts,
154
+ # so 0 is the true value, not a placeholder. Where "never happened" has no
155
+ # numeric truth (`checks_held_at`), the column is nullable instead and NULL
156
+ # means exactly that.
157
+ _ADDED_COLUMNS = {
158
+ "poll_snapshots": (
159
+ ("posted", "INTEGER NOT NULL DEFAULT 0"),
160
+ ("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
161
+ ("abandoned", "INTEGER NOT NULL DEFAULT 0"),
162
+ ),
163
+ "verdict_posts": (
164
+ ("checks_held_at", "INTEGER"),
165
+ ("checks_held_state", "TEXT"),
166
+ ),
167
+ }
146
168
 
147
169
 
148
170
  class State:
@@ -181,27 +203,26 @@ class State:
181
203
  if self._spawns_keyed_by_round():
182
204
  self._migrate_spawns()
183
205
  self._db.executescript(SCHEMA)
184
- self._migrate_snapshot_columns()
206
+ self._migrate_added_columns()
185
207
  self._db.commit()
186
208
 
187
- def _migrate_snapshot_columns(self) -> None:
188
- """Add any `poll_snapshots` column a pre-existing database predates.
209
+ def _migrate_added_columns(self) -> None:
210
+ """Add any column a pre-existing database predates.
189
211
 
190
- Runs after the schema script (which creates the table with the full
212
+ Runs after the schema script (which creates every table with its full
191
213
  column list on a fresh database, making this a no-op there) and is
192
214
  idempotent: the existing columns are read first, so a restart never
193
215
  re-ALTERs. Read-only openers never reach it -- the console must not
194
216
  migrate a database the daemon owns.
195
217
  """
196
- existing = {
197
- row["name"]
198
- for row in self._db.execute("PRAGMA table_info(poll_snapshots)").fetchall()
199
- }
200
- for name, decl in _SNAPSHOT_ADDED_COLUMNS:
201
- if name not in existing:
202
- self._db.execute(
203
- f"ALTER TABLE poll_snapshots ADD COLUMN {name} {decl}"
204
- )
218
+ for table, columns in _ADDED_COLUMNS.items():
219
+ existing = {
220
+ row["name"]
221
+ for row in self._db.execute(f"PRAGMA table_info({table})").fetchall()
222
+ }
223
+ for name, decl in columns:
224
+ if name not in existing:
225
+ self._db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")
205
226
 
206
227
  def _migrate_spawns(self) -> None:
207
228
  """Re-key an old round-keyed `spawns` by session, in ONE transaction.
@@ -488,6 +509,41 @@ class State:
488
509
  row = self.get_verdict_post(repo, number, round_)
489
510
  return int(row["attempts"]) if row else 0
490
511
 
512
+ def checks_hold(
513
+ self, repo: str, number: int, round_: int
514
+ ) -> "tuple[int | None, str | None]":
515
+ """When this round's approve was first held on CI, and on WHICH
516
+ condition -- (None, None) if it has never been held.
517
+
518
+ A read, deliberately: whether an existing stamp still applies is a
519
+ policy question about CI (see loop._gate_on_checks), and this table's job
520
+ is to remember the answer, not to make it.
521
+ """
522
+ row = self.get_verdict_post(repo, number, round_)
523
+ if row is None or not row["checks_held_at"]:
524
+ return None, None
525
+ return int(row["checks_held_at"]), (row["checks_held_state"] or None)
526
+
527
+ def record_checks_hold(
528
+ self, repo: str, number: int, round_: int, condition: str
529
+ ) -> int:
530
+ """Start (or restart) the hold clock for this round; return the stamp.
531
+
532
+ The caller decides WHEN to call this -- once when the hold begins, and at
533
+ most once more when an unreadable hold is promoted to a genuinely pending
534
+ one, because the bound is defined against the first observation of the
535
+ condition actually being waited on. Unconditional here so that policy
536
+ stays in one place instead of being half-expressed as a WHERE clause.
537
+ """
538
+ now = int(time.time())
539
+ self._db.execute(
540
+ "UPDATE verdict_posts SET checks_held_at = ?, checks_held_state = ? "
541
+ "WHERE repo=? AND number=? AND round=?",
542
+ (now, condition, repo, number, round_),
543
+ )
544
+ self._db.commit()
545
+ return now
546
+
491
547
  def record_verdict_post_abandoned(
492
548
  self, repo: str, number: int, round_: int, why: str
493
549
  ) -> None:
@@ -546,7 +602,8 @@ class State:
546
602
  """Verdict-post rows, newest observation first."""
547
603
  return self._read_rows(
548
604
  "SELECT repo, number, round, first_seen_at, head_sha, attempts, "
549
- "last_attempt_at, posted_at, abandoned_at, review_url, last_error "
605
+ "last_attempt_at, posted_at, abandoned_at, checks_held_at, "
606
+ "checks_held_state, review_url, last_error "
550
607
  "FROM verdict_posts "
551
608
  "ORDER BY first_seen_at DESC, number DESC",
552
609
  limit,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.6
3
+ Version: 0.16.7
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera