alissa-tools-github-revloop 0.30.0__tar.gz → 0.30.1__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.30.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.30.1}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/config.py +30 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/ghclient.py +82 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/loop.py +244 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/state.py +71 -0
- alissa_tools_github_revloop-0.30.1/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/README.md +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/setup.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.30.1}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -201,6 +201,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
201
201
|
"evidence; 0 queues immediately and relies on the directive alone",
|
|
202
202
|
)
|
|
203
203
|
|
|
204
|
+
over.add_argument(
|
|
205
|
+
"--verdict-cooldown-s",
|
|
206
|
+
type=int,
|
|
207
|
+
metavar="SECONDS",
|
|
208
|
+
help="how long after a verdict lands on a head no round may be queued "
|
|
209
|
+
"on that same head, whatever the PR's requested_reviewers snapshot "
|
|
210
|
+
"says; 0 leaves the review_requested timeline check to decide alone",
|
|
211
|
+
)
|
|
212
|
+
|
|
204
213
|
over.add_argument(
|
|
205
214
|
"--review-task-miss-ttl-polls",
|
|
206
215
|
type=int,
|
|
@@ -321,6 +330,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
321
330
|
"max_concurrent_sessions": args.max_concurrent_sessions,
|
|
322
331
|
"checks_wait_seconds": args.checks_wait_seconds,
|
|
323
332
|
"checks_spawn_wait_seconds": args.checks_spawn_wait_seconds,
|
|
333
|
+
"verdict_cooldown_s": args.verdict_cooldown_s,
|
|
324
334
|
"review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
|
|
325
335
|
"task_list_self_scope": args.task_list_self_scope,
|
|
326
336
|
"task_list_bow_id": args.task_list_bow_id,
|
|
@@ -190,6 +190,7 @@ CONFIG_KEYS = (
|
|
|
190
190
|
"max_concurrent_sessions",
|
|
191
191
|
"checks_wait_seconds",
|
|
192
192
|
"checks_spawn_wait_seconds",
|
|
193
|
+
"verdict_cooldown_s",
|
|
193
194
|
"review_task_miss_ttl_polls",
|
|
194
195
|
"task_list_self_scope",
|
|
195
196
|
"task_list_bow_id",
|
|
@@ -539,6 +540,24 @@ DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
|
|
|
539
540
|
# still-running rollup, which is the directive-only posture.
|
|
540
541
|
DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
|
|
541
542
|
|
|
543
|
+
# How long, after a verdict lands on a head, NO round may be queued on that same
|
|
544
|
+
# head -- whatever the PR's `requested_reviewers` snapshot says (issue #128).
|
|
545
|
+
#
|
|
546
|
+
# GitHub consumes a review request the moment the requested identity submits a
|
|
547
|
+
# review, but not atomically with the daemon's reads: on studio #1243 a
|
|
548
|
+
# `request_changes` verdict landed at 14:35:32 and the poll ten seconds later
|
|
549
|
+
# still saw the reviewer in `requested_reviewers`, queued round 2 on the SAME
|
|
550
|
+
# head, and that phantom round bounced on the CR8 triage gate -- which made the
|
|
551
|
+
# devloop spawn a second fix session on one branch. The cooldown is the blunt
|
|
552
|
+
# guard against that propagation lag: two polls' worth at the default cadence,
|
|
553
|
+
# long enough for the request the verdict consumed to be gone from every read.
|
|
554
|
+
# The precise guard is the timeline check in loop._admit_round, which admits a
|
|
555
|
+
# same-head round only on a `review_requested` event NEWER than the verdict;
|
|
556
|
+
# the cooldown spares that check a GitHub call in exactly the window where the
|
|
557
|
+
# stale snapshot is most likely. 0 disables the cooldown and leaves the timeline
|
|
558
|
+
# check to decide alone.
|
|
559
|
+
DEFAULT_VERDICT_COOLDOWN_SECONDS = 120
|
|
560
|
+
|
|
542
561
|
# How many polls a PR with NO review task may be taken on trust before the
|
|
543
562
|
# daemon searches the task corpus for one again (issue #87).
|
|
544
563
|
#
|
|
@@ -778,6 +797,12 @@ class Config:
|
|
|
778
797
|
# timer to configure.
|
|
779
798
|
checks_spawn_wait_seconds: int = DEFAULT_CHECKS_SPAWN_WAIT_SECONDS
|
|
780
799
|
|
|
800
|
+
# How long after a verdict lands on a head no round may be queued on that
|
|
801
|
+
# same head, regardless of the PR's `requested_reviewers` snapshot; see
|
|
802
|
+
# DEFAULT_VERDICT_COOLDOWN_SECONDS. 0 is legal and means "no cooldown": the
|
|
803
|
+
# timeline check alone decides whether a same-head round is admitted.
|
|
804
|
+
verdict_cooldown_s: int = DEFAULT_VERDICT_COOLDOWN_SECONDS
|
|
805
|
+
|
|
781
806
|
# How many polls a PR with no review task is taken on trust before the
|
|
782
807
|
# corpus is searched again; see DEFAULT_REVIEW_TASK_MISS_TTL_POLLS. Floor 1
|
|
783
808
|
# -- there is no value that turns the negative cache off.
|
|
@@ -1145,6 +1170,10 @@ class Config:
|
|
|
1145
1170
|
f"checks_spawn_wait_seconds must be >= 0, got {spawn_wait}"
|
|
1146
1171
|
)
|
|
1147
1172
|
|
|
1173
|
+
cooldown = int(raw.get("verdict_cooldown_s", cls.verdict_cooldown_s))
|
|
1174
|
+
if cooldown < 0:
|
|
1175
|
+
raise ValueError(f"verdict_cooldown_s must be >= 0, got {cooldown}")
|
|
1176
|
+
|
|
1148
1177
|
miss_ttl = int(
|
|
1149
1178
|
raw.get("review_task_miss_ttl_polls", cls.review_task_miss_ttl_polls)
|
|
1150
1179
|
)
|
|
@@ -1213,6 +1242,7 @@ class Config:
|
|
|
1213
1242
|
max_concurrent_sessions=max_sessions,
|
|
1214
1243
|
checks_wait_seconds=checks_wait,
|
|
1215
1244
|
checks_spawn_wait_seconds=spawn_wait,
|
|
1245
|
+
verdict_cooldown_s=cooldown,
|
|
1216
1246
|
review_task_miss_ttl_polls=miss_ttl,
|
|
1217
1247
|
task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
|
|
1218
1248
|
task_list_bow_id=bow_id,
|
|
@@ -41,6 +41,20 @@ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
|
41
41
|
# concatenates one JSON document per page and would not parse.
|
|
42
42
|
PER_PAGE = 100
|
|
43
43
|
COMMENT_PAGE_LIMIT = 20
|
|
44
|
+
# The issue timeline grows with every label, push, comment and request over a
|
|
45
|
+
# PR's life; the round-admission gate reads it for the newest
|
|
46
|
+
# `review_requested` event and the bound keeps a long-lived PR from turning one
|
|
47
|
+
# poll into an unbounded walk. The endpoint pages oldest-first with no
|
|
48
|
+
# direction parameter, so past the bound it is the NEWEST events that go
|
|
49
|
+
# unread, and the newest is exactly what the gate is asking about. So a walk
|
|
50
|
+
# that hits the bound does NOT answer with the newest request it happened to
|
|
51
|
+
# see (an old one, which would refuse every same-head round on that PR
|
|
52
|
+
# forever): it raises TimelineTruncated, and the gate treats "could not see
|
|
53
|
+
# the newest request" the same way whether it arrives as silence or as an
|
|
54
|
+
# exception -- see loop._fresh_request_at. Whether the bound was actually
|
|
55
|
+
# exceeded is settled by ONE probe past it, so exactly 2,000 events is a
|
|
56
|
+
# complete read and not a false alarm (PR #129 round 1).
|
|
57
|
+
TIMELINE_PAGE_LIMIT = 20
|
|
44
58
|
|
|
45
59
|
# The compare endpoint's HARD cap on the `files` array, and it is a cap, not a
|
|
46
60
|
# page: `files` is not paginated at all. Measured against api.github.com on
|
|
@@ -435,6 +449,13 @@ class RateLimited(RuntimeError):
|
|
|
435
449
|
pass
|
|
436
450
|
|
|
437
451
|
|
|
452
|
+
class TimelineTruncated(RuntimeError):
|
|
453
|
+
"""The issue timeline has more events than TIMELINE_PAGE_LIMIT pages hold,
|
|
454
|
+
so the newest `review_requested` may lie past what was read. Raised
|
|
455
|
+
INSTEAD of a stale answer: a caller that compares request timestamps must
|
|
456
|
+
not mistake "the newest within the bound" for "the newest"."""
|
|
457
|
+
|
|
458
|
+
|
|
438
459
|
class IdentityMismatch(RuntimeError):
|
|
439
460
|
"""Configured reviewer identity disagrees with the gh token."""
|
|
440
461
|
|
|
@@ -1187,6 +1208,67 @@ class GitHub:
|
|
|
1187
1208
|
env=self._env(),
|
|
1188
1209
|
)
|
|
1189
1210
|
|
|
1211
|
+
def review_requested_at(
|
|
1212
|
+
self, owner: str, repo: str, number: int, login: str
|
|
1213
|
+
) -> str | None:
|
|
1214
|
+
"""When `login` was most recently asked to review the PR, as the
|
|
1215
|
+
timeline's `created_at` (ISO-8601), or None if it never was.
|
|
1216
|
+
|
|
1217
|
+
Read from `GET /repos/{owner}/{repo}/issues/{n}/timeline`, keeping only
|
|
1218
|
+
`review_requested` events whose `requested_reviewer` is `login` (a team
|
|
1219
|
+
request carries `requested_team` instead and is nobody's round). The
|
|
1220
|
+
PR's own `requested_reviewers` array is NOT this: GitHub removes the
|
|
1221
|
+
request there when the review lands, with a propagation lag the daemon
|
|
1222
|
+
can read through, whereas the timeline event is immutable history --
|
|
1223
|
+
the request's timestamp can be compared with the verdict's (issue
|
|
1224
|
+
#128). Pages oldest-first like issue_comments, bounded by
|
|
1225
|
+
TIMELINE_PAGE_LIMIT -- and past the bound it raises TimelineTruncated
|
|
1226
|
+
rather than answering with an older request than the true newest
|
|
1227
|
+
(the reasoning is at the constant). The bound is checked with one
|
|
1228
|
+
probe past the last page, so a timeline of exactly
|
|
1229
|
+
TIMELINE_PAGE_LIMIT * PER_PAGE events reads as complete.
|
|
1230
|
+
"""
|
|
1231
|
+
wanted = login.casefold()
|
|
1232
|
+
newest: str | None = None
|
|
1233
|
+
for page in range(1, TIMELINE_PAGE_LIMIT + 2):
|
|
1234
|
+
data = self._timeline_page(owner, repo, number, page)
|
|
1235
|
+
if page > TIMELINE_PAGE_LIMIT:
|
|
1236
|
+
# The probe: anything here means the bound was exceeded.
|
|
1237
|
+
if data:
|
|
1238
|
+
raise TimelineTruncated(
|
|
1239
|
+
f"{owner}/{repo}#{number} has more than "
|
|
1240
|
+
f"{TIMELINE_PAGE_LIMIT * PER_PAGE} timeline events — the "
|
|
1241
|
+
f"newest review request to {login} may lie past the "
|
|
1242
|
+
f"{TIMELINE_PAGE_LIMIT * PER_PAGE} that were read"
|
|
1243
|
+
)
|
|
1244
|
+
break
|
|
1245
|
+
for event in data:
|
|
1246
|
+
if (event or {}).get("event") != "review_requested":
|
|
1247
|
+
continue
|
|
1248
|
+
who = ((event.get("requested_reviewer") or {}).get("login") or "")
|
|
1249
|
+
if who.casefold() != wanted:
|
|
1250
|
+
continue
|
|
1251
|
+
at = event.get("created_at") or ""
|
|
1252
|
+
if at and (newest is None or at > newest):
|
|
1253
|
+
newest = at
|
|
1254
|
+
if len(data) < PER_PAGE:
|
|
1255
|
+
break
|
|
1256
|
+
return newest
|
|
1257
|
+
|
|
1258
|
+
def _timeline_page(self, owner: str, repo: str, number: int, page: int) -> list:
|
|
1259
|
+
return (
|
|
1260
|
+
self._api(
|
|
1261
|
+
"-X",
|
|
1262
|
+
"GET",
|
|
1263
|
+
f"repos/{owner}/{repo}/issues/{number}/timeline",
|
|
1264
|
+
"-f",
|
|
1265
|
+
f"per_page={PER_PAGE}",
|
|
1266
|
+
"-f",
|
|
1267
|
+
f"page={page}",
|
|
1268
|
+
)
|
|
1269
|
+
or []
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1190
1272
|
def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
|
|
1191
1273
|
"""Every issue comment on the PR, oldest first -- see COMMENT_PAGE_LIMIT
|
|
1192
1274
|
for why this pages instead of reading the first 100 and hoping."""
|
|
@@ -16,6 +16,7 @@ import secrets
|
|
|
16
16
|
import sqlite3
|
|
17
17
|
import time
|
|
18
18
|
from collections import Counter
|
|
19
|
+
from datetime import datetime, timezone
|
|
19
20
|
from dataclasses import dataclass, field, replace
|
|
20
21
|
from enum import Enum
|
|
21
22
|
from pathlib import Path
|
|
@@ -55,6 +56,7 @@ from .ghclient import (
|
|
|
55
56
|
PullRequest,
|
|
56
57
|
RateLimited,
|
|
57
58
|
Review,
|
|
59
|
+
TimelineTruncated,
|
|
58
60
|
TruncatedListing,
|
|
59
61
|
countable_rounds,
|
|
60
62
|
verdict_marker,
|
|
@@ -211,6 +213,18 @@ DEFERRAL_CLEARED = (
|
|
|
211
213
|
)
|
|
212
214
|
|
|
213
215
|
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class VerdictOfRecord:
|
|
218
|
+
"""The newest verdict on a PR's current head, as the round-admission gate
|
|
219
|
+
needs it (issue #128): `at` is the newer of GitHub's `submitted_at` and
|
|
220
|
+
the ledger's local stamp -- the cooldown's clock -- and `github_at` is
|
|
221
|
+
GitHub's stamp alone (None while GitHub's reviews list has not yet shown
|
|
222
|
+
the verdict), the only one comparable with a timeline `created_at`."""
|
|
223
|
+
|
|
224
|
+
at: float
|
|
225
|
+
github_at: float | None
|
|
226
|
+
|
|
227
|
+
|
|
214
228
|
@dataclass(frozen=True)
|
|
215
229
|
class Waiting:
|
|
216
230
|
"""One round's place in the spawn queue, from its FIRST deferral.
|
|
@@ -1251,6 +1265,26 @@ def _now() -> str:
|
|
|
1251
1265
|
return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
1252
1266
|
|
|
1253
1267
|
|
|
1268
|
+
def _epoch(stamp: str) -> float | None:
|
|
1269
|
+
"""A GitHub ISO-8601 timestamp (`2026-09-15T14:35:32Z`) as epoch seconds,
|
|
1270
|
+
or None for an empty or unparseable one. A naive stamp is read as UTC,
|
|
1271
|
+
which is what GitHub emits."""
|
|
1272
|
+
if not stamp:
|
|
1273
|
+
return None
|
|
1274
|
+
try:
|
|
1275
|
+
parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
1276
|
+
except ValueError:
|
|
1277
|
+
return None
|
|
1278
|
+
if parsed.tzinfo is None:
|
|
1279
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1280
|
+
return parsed.timestamp()
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _stamp(epoch: float) -> str:
|
|
1284
|
+
"""The inverse of _epoch, for log lines: UTC, seconds, `Z`."""
|
|
1285
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1286
|
+
|
|
1287
|
+
|
|
1254
1288
|
def grant_activity_kind(comment_id: int) -> str:
|
|
1255
1289
|
"""The ping-ledger kind that dedupes ONE grant's activity line.
|
|
1256
1290
|
|
|
@@ -1705,6 +1739,18 @@ class ReviewWatcher:
|
|
|
1705
1739
|
# has listed this pass (the preflight refresh, or a sweep whose list
|
|
1706
1740
|
# failed), and the refresh lists for itself.
|
|
1707
1741
|
self._pass_roster: list[ManagedSession] | None = None
|
|
1742
|
+
# (repo full name, PR number) -> when the reviewer login was most
|
|
1743
|
+
# recently asked to review that PR, per the issue timeline (an ISO
|
|
1744
|
+
# stamp, or None for never) -- the round-admission gate's one GitHub
|
|
1745
|
+
# read, memoised so a PR costs at most one timeline walk per pass
|
|
1746
|
+
# (issue #128). Cleared with the other per-pass memos in poll_once.
|
|
1747
|
+
self._pass_requests: dict[tuple[str, int], str | None] = {}
|
|
1748
|
+
# (repo full name, PR number) -> (head, verdict epoch) of the last
|
|
1749
|
+
# stale request the gate announced at INFO for that PR, so the line
|
|
1750
|
+
# goes out once per ignored request and repeats at debug while the
|
|
1751
|
+
# same request keeps being seen. In-memory on purpose: a restart
|
|
1752
|
+
# costs one repeated line, not a ledger write per poll.
|
|
1753
|
+
self._ignored_requests: dict[tuple[str, int], tuple[str, float]] = {}
|
|
1708
1754
|
# (repo full name, PR number) -> where that round sits in the spawn
|
|
1709
1755
|
# queue, from the first pass that deferred it. Cross-pass and
|
|
1710
1756
|
# in-memory: it is a FAIRNESS ORDER, not a decision the daemon must
|
|
@@ -2170,6 +2216,17 @@ class ReviewWatcher:
|
|
|
2170
2216
|
if deferred is not None:
|
|
2171
2217
|
return deferred
|
|
2172
2218
|
|
|
2219
|
+
# THE ROUND-ADMISSION GATE (issue #128): a round on a head that already
|
|
2220
|
+
# carries a verdict needs FRESH evidence of a request -- a
|
|
2221
|
+
# `review_requested` event newer than that verdict -- and never starts
|
|
2222
|
+
# inside the post-verdict cooldown at all. Below the in-flight and
|
|
2223
|
+
# stale-round reads (a running round costs no timeline call) and above
|
|
2224
|
+
# every other refusal, because a round this gate turns away must
|
|
2225
|
+
# consume neither a rollup nor a place in the slot queue.
|
|
2226
|
+
refused = self._admit_round(pr, my_reviews, round_)
|
|
2227
|
+
if refused is not None:
|
|
2228
|
+
return refused
|
|
2229
|
+
|
|
2173
2230
|
# THE SPAWN GATE, and it sits here -- past every branch that decides
|
|
2174
2231
|
# WHETHER a round is owed, immediately before the one that acts.
|
|
2175
2232
|
# Upstream of it the loop is only reading; downstream it starts an
|
|
@@ -2307,6 +2364,186 @@ class ReviewWatcher:
|
|
|
2307
2364
|
round_,
|
|
2308
2365
|
)
|
|
2309
2366
|
|
|
2367
|
+
# -- round admission (issue #128) ----------------------------------------
|
|
2368
|
+
|
|
2369
|
+
def _admit_round(
|
|
2370
|
+
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
2371
|
+
) -> Decision | None:
|
|
2372
|
+
"""Refuse a round the PR's review-request snapshot alone would start,
|
|
2373
|
+
or None to carry on.
|
|
2374
|
+
|
|
2375
|
+
The search set is `review-requested:@me`, and the loop's edge trigger
|
|
2376
|
+
has always been GitHub consuming that request when the requested
|
|
2377
|
+
identity submits a review. That consumption is not atomic with the
|
|
2378
|
+
daemon's reads: on studio #1243 round 1 posted `request_changes` at
|
|
2379
|
+
14:35:32 on head 0d9d66b7, the poll at 14:35:42 still found the PR in
|
|
2380
|
+
the search with the reviewer in `requested_reviewers`, and queued round
|
|
2381
|
+
2 on the same head -- a round nobody asked for, which then bounced on
|
|
2382
|
+
the CR8 triage gate and made the devloop spawn a second fix session on
|
|
2383
|
+
one branch. `requested_reviewers` is therefore a HINT here, never the
|
|
2384
|
+
verdict. A round on head H is admitted only when
|
|
2385
|
+
|
|
2386
|
+
(a) no verdict of record exists for (PR, H) -- a push re-arms exactly
|
|
2387
|
+
as before, since the newest review then judges an older commit; or
|
|
2388
|
+
(b) the issue timeline shows a `review_requested` event for the
|
|
2389
|
+
reviewer login NEWER than the last verdict on H.
|
|
2390
|
+
|
|
2391
|
+
And inside `verdict_cooldown_s` of that verdict nothing is admitted on
|
|
2392
|
+
H at all, whatever the timeline says: the cooldown alone would have
|
|
2393
|
+
prevented #1243, and (b) is what makes the gate correct rather than
|
|
2394
|
+
merely rare. The verdict's timestamp is the newer of GitHub's own
|
|
2395
|
+
review record and the ledger's (the daemon stamps its native posts the
|
|
2396
|
+
moment they land, before GitHub's reviews list catches up).
|
|
2397
|
+
|
|
2398
|
+
A refused round is a SKIPPED decision and holds no queue place, like
|
|
2399
|
+
the other pre-start refusals. Each ignored request is announced once at
|
|
2400
|
+
INFO -- "stale request" must be tellable from "idle" in the log -- and
|
|
2401
|
+
repeated at debug while the same request keeps being seen.
|
|
2402
|
+
|
|
2403
|
+
Two clocks, kept apart (PR #129 round 1): the cooldown is a duration
|
|
2404
|
+
on THIS host's wall clock, measured from the newer of GitHub's stamp
|
|
2405
|
+
and the ledger's local one; the comparison in (b) is GitHub's
|
|
2406
|
+
`created_at` against GitHub's `submitted_at`, both GitHub's clock, so
|
|
2407
|
+
a host running ahead of GitHub cannot make its own native post look
|
|
2408
|
+
newer than a genuine re-request that followed it. The ledger's local
|
|
2409
|
+
stamp enters (b) only while GitHub's reviews list has not yet shown
|
|
2410
|
+
the verdict at all -- the window the ledger exists for.
|
|
2411
|
+
"""
|
|
2412
|
+
verdict = self._last_verdict_at(pr, my_reviews)
|
|
2413
|
+
if verdict is None:
|
|
2414
|
+
return None
|
|
2415
|
+
verdict_at = verdict.at
|
|
2416
|
+
|
|
2417
|
+
key = (pr.full_name, pr.number)
|
|
2418
|
+
since = time.time() - verdict_at
|
|
2419
|
+
cooldown = self.config.verdict_cooldown_s
|
|
2420
|
+
# A verdict stamped in the FUTURE (clock skew between GitHub and this
|
|
2421
|
+
# host) is not inside the window measured from it; the timeline check
|
|
2422
|
+
# below still decides, so nothing is lost by not guessing.
|
|
2423
|
+
if 0 <= since < cooldown:
|
|
2424
|
+
self._waiting.pop(key, None)
|
|
2425
|
+
log.info(
|
|
2426
|
+
"%s round %d: the last verdict on head %s landed %.0fs ago (%s) "
|
|
2427
|
+
"— inside the %ds post-verdict cooldown, not queuing whatever "
|
|
2428
|
+
"the review-request snapshot says",
|
|
2429
|
+
pr.slug, round_, pr.head_sha[:8], since, _stamp(verdict_at), cooldown,
|
|
2430
|
+
)
|
|
2431
|
+
return Decision(
|
|
2432
|
+
Action.SKIPPED,
|
|
2433
|
+
f"head {pr.head_sha[:8]} had a verdict {int(since)}s ago — inside "
|
|
2434
|
+
f"the {cooldown}s post-verdict cooldown",
|
|
2435
|
+
round_,
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
requested = self._fresh_request_at(pr)
|
|
2439
|
+
if requested is None:
|
|
2440
|
+
# The newest request could not be seen -- the timeline was
|
|
2441
|
+
# unreadable or longer than the bound (logged there). The snapshot
|
|
2442
|
+
# is all that is left, and past the cooldown it is the pre-#128
|
|
2443
|
+
# trigger: a wedged loop on every read failure is the worse trade.
|
|
2444
|
+
return None
|
|
2445
|
+
request_at = _epoch(requested)
|
|
2446
|
+
verdict_at = verdict.github_at if verdict.github_at is not None else verdict.at
|
|
2447
|
+
if request_at is not None and request_at > verdict_at:
|
|
2448
|
+
return None
|
|
2449
|
+
|
|
2450
|
+
self._waiting.pop(key, None)
|
|
2451
|
+
seen = (
|
|
2452
|
+
f"request seen at {requested}"
|
|
2453
|
+
if request_at is not None
|
|
2454
|
+
else f"no review_requested event for {self.github.login} on the timeline"
|
|
2455
|
+
)
|
|
2456
|
+
message = (
|
|
2457
|
+
"%s round %d: %s is older than the last verdict at %s on head %s "
|
|
2458
|
+
"— waiting for a fresh re-request"
|
|
2459
|
+
)
|
|
2460
|
+
args = (pr.slug, round_, seen, _stamp(verdict_at), pr.head_sha[:8])
|
|
2461
|
+
if self._ignored_requests.get(key) == (pr.head_sha, verdict_at):
|
|
2462
|
+
log.debug(message, *args)
|
|
2463
|
+
else:
|
|
2464
|
+
self._ignored_requests[key] = (pr.head_sha, verdict_at)
|
|
2465
|
+
log.info(message, *args)
|
|
2466
|
+
return Decision(
|
|
2467
|
+
Action.SKIPPED,
|
|
2468
|
+
f"{seen} is older than the last verdict on head {pr.head_sha[:8]} "
|
|
2469
|
+
f"— waiting for a fresh re-request",
|
|
2470
|
+
round_,
|
|
2471
|
+
)
|
|
2472
|
+
|
|
2473
|
+
def _last_verdict_at(
|
|
2474
|
+
self, pr: PullRequest, my_reviews: list[Review]
|
|
2475
|
+
) -> VerdictOfRecord | None:
|
|
2476
|
+
"""The newest verdict of record on the CURRENT head, or None when no
|
|
2477
|
+
reviewer-identity review judges this head.
|
|
2478
|
+
|
|
2479
|
+
Two sources, the newer wins for the cooldown. GitHub's reviews list is
|
|
2480
|
+
the authority for a verdict the reviewer SESSION posted (it never
|
|
2481
|
+
passes through the daemon), and the newest review is the one that
|
|
2482
|
+
matters: judging an older commit, it is not a verdict on this head at
|
|
2483
|
+
all. The ledger covers the daemon's own native posts during the window
|
|
2484
|
+
before GitHub's list shows them. A GitHub-observed verdict is written
|
|
2485
|
+
back to the ledger as telemetry, so the ledger converges on GitHub's
|
|
2486
|
+
truth and a row lost either way costs nothing. GitHub's own stamp is
|
|
2487
|
+
carried out separately because it is the only one in the timeline's
|
|
2488
|
+
clock domain -- see `_admit_round`.
|
|
2489
|
+
"""
|
|
2490
|
+
newest = my_reviews[-1] if my_reviews else None
|
|
2491
|
+
seen: float | None = None
|
|
2492
|
+
if newest is not None and newest.commit_id and newest.commit_id == pr.head_sha:
|
|
2493
|
+
seen = _epoch(newest.submitted_at)
|
|
2494
|
+
if seen is not None:
|
|
2495
|
+
self.state.note_observed_verdict(
|
|
2496
|
+
pr.full_name, pr.number, pr.head_sha, int(seen), newest.url
|
|
2497
|
+
)
|
|
2498
|
+
ledger = self.state.last_verdict_at(pr.full_name, pr.number, pr.head_sha)
|
|
2499
|
+
candidates = [at for at in (seen, ledger) if at is not None]
|
|
2500
|
+
if not candidates:
|
|
2501
|
+
return None
|
|
2502
|
+
return VerdictOfRecord(at=max(candidates), github_at=seen)
|
|
2503
|
+
|
|
2504
|
+
def _fresh_request_at(self, pr: PullRequest) -> str | None:
|
|
2505
|
+
"""When this login was most recently asked to review the PR, from the
|
|
2506
|
+
issue timeline, memoised for the pass: one timeline walk per candidate
|
|
2507
|
+
PR per poll, however many times the PR is evaluated. None when the
|
|
2508
|
+
read failed (a RateLimited still propagates to run_forever's backoff,
|
|
2509
|
+
exactly as every other read path lets it)."""
|
|
2510
|
+
key = (pr.full_name, pr.number)
|
|
2511
|
+
if key in self._pass_requests:
|
|
2512
|
+
return self._pass_requests[key]
|
|
2513
|
+
try:
|
|
2514
|
+
requested = self.github.review_requested_at(
|
|
2515
|
+
pr.owner, pr.repo, pr.number, self.github.login
|
|
2516
|
+
)
|
|
2517
|
+
except RateLimited:
|
|
2518
|
+
raise
|
|
2519
|
+
except TimelineTruncated as exc:
|
|
2520
|
+
# Same epistemic state as an unreadable timeline -- the newest
|
|
2521
|
+
# request cannot be seen -- so the same answer: fail OPEN. The
|
|
2522
|
+
# alternative, comparing against the newest request WITHIN the
|
|
2523
|
+
# bound, refuses every same-head round on a long-timelined PR
|
|
2524
|
+
# forever with nothing but a warning (PR #129 round 1, major).
|
|
2525
|
+
log.warning(
|
|
2526
|
+
"%s: %s — admitting the round on the review-request snapshot "
|
|
2527
|
+
"alone, which is what the post-verdict cooldown guards",
|
|
2528
|
+
pr.slug, exc,
|
|
2529
|
+
)
|
|
2530
|
+
requested = None
|
|
2531
|
+
except Exception as exc:
|
|
2532
|
+
log.warning(
|
|
2533
|
+
"%s: could not read the issue timeline for review requests to "
|
|
2534
|
+
"%s (%s) — admitting the round on the review-request snapshot "
|
|
2535
|
+
"alone, which is what the post-verdict cooldown guards",
|
|
2536
|
+
pr.slug, self.github.login, exc,
|
|
2537
|
+
)
|
|
2538
|
+
requested = None
|
|
2539
|
+
else:
|
|
2540
|
+
# A timeline with no request at all is a real answer -- the
|
|
2541
|
+
# snapshot said "requested" and history says nobody did -- and it
|
|
2542
|
+
# is memoised as the empty string so the miss is not re-read.
|
|
2543
|
+
requested = requested or ""
|
|
2544
|
+
self._pass_requests[key] = requested
|
|
2545
|
+
return requested
|
|
2546
|
+
|
|
2310
2547
|
def _refused_before_start(
|
|
2311
2548
|
self, pr: PullRequest, round_: int, task: Task | None
|
|
2312
2549
|
) -> Decision | None:
|
|
@@ -3069,6 +3306,12 @@ class ReviewWatcher:
|
|
|
3069
3306
|
self.state.record_verdict_post(
|
|
3070
3307
|
pr.full_name, pr.number, round_, url, verdict=verdict
|
|
3071
3308
|
)
|
|
3309
|
+
# The round-admission gate's cooldown is measured from HERE for the
|
|
3310
|
+
# daemon's own posts, so it holds even while GitHub's reviews list
|
|
3311
|
+
# still lags the POST it just accepted (issue #128).
|
|
3312
|
+
self.state.record_verdict(
|
|
3313
|
+
pr.full_name, pr.number, judged, int(time.time()), url or ""
|
|
3314
|
+
)
|
|
3072
3315
|
log.info(
|
|
3073
3316
|
"%s round %d closed: native %s review submitted as %s (%s)%s",
|
|
3074
3317
|
pr.slug, round_, event, self.github.login, url or "no url", gate_note,
|
|
@@ -4920,6 +5163,7 @@ class ReviewWatcher:
|
|
|
4920
5163
|
self._session_census = None
|
|
4921
5164
|
self._census_probed = self._census_warned = False
|
|
4922
5165
|
self._pass_roster = None
|
|
5166
|
+
self._pass_requests = {}
|
|
4923
5167
|
|
|
4924
5168
|
# THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
|
|
4925
5169
|
# may run when the ledger cannot record what it does.
|
|
@@ -95,6 +95,26 @@ CREATE TABLE IF NOT EXISTS grants (
|
|
|
95
95
|
PRIMARY KEY (repo, number, comment_id)
|
|
96
96
|
);
|
|
97
97
|
|
|
98
|
+
-- Every verdict of record the daemon knows about, keyed by the head it
|
|
99
|
+
-- judged (issue #128): the round-admission gate refuses to queue a round on a
|
|
100
|
+
-- head inside `verdict_cooldown_s` of the newest verdict on it, and past the
|
|
101
|
+
-- cooldown admits one only on a `review_requested` event NEWER than that
|
|
102
|
+
-- verdict. Two writers: loop._close_round_natively stamps its own post the
|
|
103
|
+
-- moment it lands (so the cooldown holds even while GitHub's reviews list
|
|
104
|
+
-- still lags the POST), and loop.evaluate stamps every reviewer-identity
|
|
105
|
+
-- review it observes on the current head (a session's own `gh pr review`
|
|
106
|
+
-- never passes through the daemon). Rows are never pruned -- one per verdict
|
|
107
|
+
-- ever landed -- and a lost row costs nothing: GitHub's reviews list is read
|
|
108
|
+
-- alongside it and the gate takes the newer of the two.
|
|
109
|
+
CREATE TABLE IF NOT EXISTS verdicts (
|
|
110
|
+
repo TEXT NOT NULL,
|
|
111
|
+
number INTEGER NOT NULL,
|
|
112
|
+
head_sha TEXT NOT NULL,
|
|
113
|
+
posted_at INTEGER NOT NULL,
|
|
114
|
+
review_url TEXT,
|
|
115
|
+
PRIMARY KEY (repo, number, head_sha, posted_at)
|
|
116
|
+
);
|
|
117
|
+
|
|
98
118
|
CREATE TABLE IF NOT EXISTS pings (
|
|
99
119
|
repo TEXT NOT NULL,
|
|
100
120
|
number INTEGER NOT NULL,
|
|
@@ -728,6 +748,57 @@ class State:
|
|
|
728
748
|
)
|
|
729
749
|
self._db.commit()
|
|
730
750
|
|
|
751
|
+
def record_verdict(
|
|
752
|
+
self, repo: str, number: int, head_sha: str, posted_at: int,
|
|
753
|
+
review_url: str = "",
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Remember that a verdict of record landed on `head_sha` at
|
|
756
|
+
`posted_at` (epoch seconds). Idempotent per (PR, head, timestamp), so
|
|
757
|
+
the same GitHub review observed on every poll writes one row."""
|
|
758
|
+
self._db.execute(
|
|
759
|
+
"INSERT OR IGNORE INTO verdicts "
|
|
760
|
+
"(repo, number, head_sha, posted_at, review_url) VALUES (?,?,?,?,?)",
|
|
761
|
+
(repo, number, head_sha, int(posted_at), review_url),
|
|
762
|
+
)
|
|
763
|
+
self._db.commit()
|
|
764
|
+
|
|
765
|
+
def note_observed_verdict(
|
|
766
|
+
self, repo: str, number: int, head_sha: str, posted_at: int,
|
|
767
|
+
review_url: str = "",
|
|
768
|
+
) -> bool:
|
|
769
|
+
"""TELEMETRY write-back of a verdict the loop saw on GitHub, so the
|
|
770
|
+
ledger converges on GitHub's truth (issue #128). Absorbed like every
|
|
771
|
+
other telemetry write: GitHub already holds the record, so a row the
|
|
772
|
+
ledger cannot take costs nothing. `record_verdict` itself stays the
|
|
773
|
+
hard write the daemon's own native post makes -- that row IS the
|
|
774
|
+
record until GitHub's reviews list catches up. True on success."""
|
|
775
|
+
return self._write_telemetry(
|
|
776
|
+
lambda: self.record_verdict(repo, number, head_sha, posted_at, review_url),
|
|
777
|
+
f"recording the verdict on {repo}#{number} at {head_sha[:8]}",
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def last_verdict_at(self, repo: str, number: int, head_sha: str) -> int | None:
|
|
781
|
+
"""When the newest verdict of record on this head landed (epoch
|
|
782
|
+
seconds), or None when the ledger knows of none on it."""
|
|
783
|
+
row = self._db.execute(
|
|
784
|
+
"SELECT MAX(posted_at) AS at FROM verdicts "
|
|
785
|
+
"WHERE repo=? AND number=? AND head_sha=?",
|
|
786
|
+
(repo, number, head_sha),
|
|
787
|
+
).fetchone()
|
|
788
|
+
return int(row["at"]) if row and row["at"] is not None else None
|
|
789
|
+
|
|
790
|
+
def read_verdicts(self, limit: int | None = None) -> list[dict]:
|
|
791
|
+
"""Verdict-of-record rows, newest first. For inspection; nothing in
|
|
792
|
+
the loop reads it. Mind that a `read_only=True` opener skips SCHEMA,
|
|
793
|
+
so against a ledger whose daemon has not yet restarted onto a version
|
|
794
|
+
with the `verdicts` table this raises `no such table`, not `[]` --
|
|
795
|
+
whoever wires it into the console first should guard for that."""
|
|
796
|
+
return self._read_rows(
|
|
797
|
+
"SELECT repo, number, head_sha, posted_at, review_url FROM verdicts "
|
|
798
|
+
"ORDER BY posted_at DESC, number DESC",
|
|
799
|
+
limit,
|
|
800
|
+
)
|
|
801
|
+
|
|
731
802
|
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
732
803
|
"""Whether this head has been paged at all. Not the whole dedupe story
|
|
733
804
|
once re-entry grants exist -- a grant consumed without an approve is a
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.30.1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.30.0
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|