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.
- {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
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/__main__.py +9 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/config.py +26 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/ghclient.py +300 -6
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/loop.py +417 -8
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/state.py +81 -24
- alissa_tools_github_revloop-0.16.7/src/main/alissa/tools/github/revloop/version +1 -0
- {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
- alissa_tools_github_revloop-0.16.6/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.6 → alissa_tools_github_revloop-0.16.7}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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.
|
|
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}
|
|
558
|
-
f"got {event!r}
|
|
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
|
-
|
|
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
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
206
|
+
self._migrate_added_columns()
|
|
185
207
|
self._db.commit()
|
|
186
208
|
|
|
187
|
-
def
|
|
188
|
-
"""Add any
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
f"ALTER TABLE
|
|
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,
|
|
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,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.7
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.6
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|