alissa-tools-github-revloop 0.30.0__tar.gz → 0.31.0__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.31.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/alissa.py +102 -5
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/config.py +30 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/ghclient.py +82 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/loop.py +319 -6
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/state.py +71 -0
- alissa_tools_github_revloop-0.31.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0/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.31.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/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.31.0}/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.31.0}/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,
|
|
@@ -233,6 +233,53 @@ _VERDICT_RE = re.compile(
|
|
|
233
233
|
re.IGNORECASE,
|
|
234
234
|
)
|
|
235
235
|
|
|
236
|
+
# The envelope's merge-readiness judgment (issue #130). The alissa-code-review
|
|
237
|
+
# skill writes it as one list line in the verdict envelope:
|
|
238
|
+
# - **Merge-Readiness:** auto
|
|
239
|
+
# - **Merge-Readiness:** operator — touches convex/schema.ts
|
|
240
|
+
# Tolerant of the markdown around the label -- an optional bullet, optional
|
|
241
|
+
# `**` bold around the label (with the colon inside or outside it), optional
|
|
242
|
+
# bold around the value -- but NOT of the value's case: `Auto` is not a
|
|
243
|
+
# judgment this daemon will carry, so it reads as missing and fails closed to
|
|
244
|
+
# `operator` downstream. Line-anchored, first match wins, like the consumer's
|
|
245
|
+
# own grammar; the reason runs to the end of the line and no further.
|
|
246
|
+
READINESS_AUTO = "auto"
|
|
247
|
+
READINESS_OPERATOR = "operator"
|
|
248
|
+
_READINESS_RE = re.compile(
|
|
249
|
+
r"^[ \t]*(?:[-*+][ \t]+)?(?:\*\*)?[ \t]*Merge-Readiness[ \t]*:?[ \t]*(?:\*\*)?"
|
|
250
|
+
r"[ \t]*:?[ \t]*(?:\*\*)?(auto|operator)\b(?:\*\*)?"
|
|
251
|
+
r"(?:[ \t]*[—–-][ \t]*(.*?))?[ \t]*$",
|
|
252
|
+
re.MULTILINE,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# The reason lands in a GitHub review body (as a line-anchored trailer) and in
|
|
256
|
+
# a log line, so it is bounded and flattened at the parser: one line, no
|
|
257
|
+
# backticks (a fence would swallow the trailer and everything after it), and
|
|
258
|
+
# no more than this many characters.
|
|
259
|
+
MAX_READINESS_REASON_CHARS = 200
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def clean_readiness_reason(text: object) -> str:
|
|
263
|
+
"""Flatten a Merge-Readiness reason to one bounded, backtick-free line."""
|
|
264
|
+
if not isinstance(text, str):
|
|
265
|
+
return ""
|
|
266
|
+
flat = " ".join(text.replace("`", "").split())
|
|
267
|
+
return flat[:MAX_READINESS_REASON_CHARS].rstrip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parse_readiness(blob: object) -> "tuple[str | None, str]":
|
|
271
|
+
"""`(value, reason)` from the first Merge-Readiness line in `blob`.
|
|
272
|
+
|
|
273
|
+
`value` is READINESS_AUTO / READINESS_OPERATOR, or None when no line
|
|
274
|
+
parses; `reason` is the cleaned trailing text (empty when there is none).
|
|
275
|
+
"""
|
|
276
|
+
if not isinstance(blob, str):
|
|
277
|
+
return (None, "")
|
|
278
|
+
match = _READINESS_RE.search(blob)
|
|
279
|
+
if match is None:
|
|
280
|
+
return (None, "")
|
|
281
|
+
return (match.group(1), clean_readiness_reason(match.group(2)))
|
|
282
|
+
|
|
236
283
|
|
|
237
284
|
@dataclass(frozen=True)
|
|
238
285
|
class Task:
|
|
@@ -389,6 +436,23 @@ def _task_from_row(row: object) -> "Task | None":
|
|
|
389
436
|
)
|
|
390
437
|
|
|
391
438
|
|
|
439
|
+
@dataclass(frozen=True)
|
|
440
|
+
class VerdictEnvelope:
|
|
441
|
+
"""One parsed CR6 verdict envelope: the verdict, plus the merge-readiness
|
|
442
|
+
judgment the same envelope carries (issue #130).
|
|
443
|
+
|
|
444
|
+
`readiness` is READINESS_AUTO / READINESS_OPERATOR, or None when the
|
|
445
|
+
envelope has no parseable `Merge-Readiness` line -- the case the native
|
|
446
|
+
post fails closed on. `readiness_reason` is already one bounded,
|
|
447
|
+
backtick-free line (see clean_readiness_reason); empty when the envelope
|
|
448
|
+
gave none.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
verdict: str
|
|
452
|
+
readiness: "str | None" = None
|
|
453
|
+
readiness_reason: str = ""
|
|
454
|
+
|
|
455
|
+
|
|
392
456
|
@dataclass(frozen=True)
|
|
393
457
|
class TaskDetail:
|
|
394
458
|
"""One task as `alissa task get` sees it: the task itself, plus everything
|
|
@@ -774,16 +838,29 @@ class Alissa:
|
|
|
774
838
|
return matches[0]
|
|
775
839
|
|
|
776
840
|
def latest_verdict(self, task_ref: str) -> str | None:
|
|
777
|
-
"""The newest CR6 verdict envelope on a review task, or None.
|
|
841
|
+
"""The newest CR6 verdict envelope's verdict on a review task, or None.
|
|
778
842
|
|
|
779
843
|
Returns VERDICT_APPROVE / VERDICT_REQUEST_CHANGES. This is the verdict
|
|
780
844
|
of record: reviewers post comment-mode reviews, so the GitHub review
|
|
781
845
|
state is always COMMENTED and cannot express approval at all.
|
|
782
846
|
|
|
847
|
+
The word alone; `latest_envelope` returns the whole record (verdict
|
|
848
|
+
plus merge-readiness) off the same read for the caller that posts it.
|
|
849
|
+
|
|
783
850
|
Never raises. The daemon polls forever and this runs inside every pass,
|
|
784
851
|
so absent, empty or malformed evidence degrades to "no verdict" rather
|
|
785
852
|
than taking the loop down.
|
|
786
853
|
"""
|
|
854
|
+
envelope = self.latest_envelope(task_ref)
|
|
855
|
+
return None if envelope is None else envelope.verdict
|
|
856
|
+
|
|
857
|
+
def latest_envelope(self, task_ref: str) -> "VerdictEnvelope | None":
|
|
858
|
+
"""The newest CR6 verdict envelope on a review task, parsed, or None.
|
|
859
|
+
|
|
860
|
+
Same read and same tolerance as `latest_verdict`; this is the record
|
|
861
|
+
the native post carries onto GitHub (issue #130), so it needs the
|
|
862
|
+
envelope's Merge-Readiness line alongside the verdict word.
|
|
863
|
+
"""
|
|
787
864
|
try:
|
|
788
865
|
data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
|
|
789
866
|
except CommandError as exc:
|
|
@@ -794,7 +871,7 @@ class Alissa:
|
|
|
794
871
|
return None
|
|
795
872
|
|
|
796
873
|
try:
|
|
797
|
-
return self.
|
|
874
|
+
return self._newest_envelope(data)
|
|
798
875
|
except Exception: # pragma: no cover - defence in depth
|
|
799
876
|
log.exception("could not parse verdict evidence for %s", task_ref)
|
|
800
877
|
return None
|
|
@@ -839,10 +916,23 @@ class Alissa:
|
|
|
839
916
|
|
|
840
917
|
@staticmethod
|
|
841
918
|
def _newest_verdict(payload: object) -> str | None:
|
|
842
|
-
"""
|
|
919
|
+
"""The newest parseable verdict WORD out of a task's evidence array;
|
|
920
|
+
`_newest_envelope` with the readiness dropped, for the callers that
|
|
921
|
+
only decide on the word."""
|
|
922
|
+
envelope = Alissa._newest_envelope(payload)
|
|
923
|
+
return None if envelope is None else envelope.verdict
|
|
924
|
+
|
|
925
|
+
@staticmethod
|
|
926
|
+
def _newest_envelope(payload: object) -> "VerdictEnvelope | None":
|
|
927
|
+
"""Pick the newest parseable verdict envelope out of a task's evidence
|
|
928
|
+
array, with the Merge-Readiness judgment it carries (issue #130).
|
|
843
929
|
|
|
844
930
|
Every layer is optional by design -- the payload shape is whatever the
|
|
845
931
|
CLI printed, and a task with no evidence is the normal round-1 case.
|
|
932
|
+
|
|
933
|
+
The readiness is read off the SAME evidence item as the verdict (its
|
|
934
|
+
body first, then its title), never off a neighbour: a stray line on an
|
|
935
|
+
older envelope must not decorate a newer round's verdict.
|
|
846
936
|
"""
|
|
847
937
|
if not isinstance(payload, dict):
|
|
848
938
|
return None
|
|
@@ -850,7 +940,7 @@ class Alissa:
|
|
|
850
940
|
if not isinstance(evidence, list):
|
|
851
941
|
return None
|
|
852
942
|
|
|
853
|
-
found: list[tuple[tuple[int, float], int,
|
|
943
|
+
found: list[tuple[tuple[int, float], int, VerdictEnvelope]] = []
|
|
854
944
|
for index, item in enumerate(evidence):
|
|
855
945
|
if not isinstance(item, dict):
|
|
856
946
|
continue
|
|
@@ -861,10 +951,17 @@ class Alissa:
|
|
|
861
951
|
continue
|
|
862
952
|
match = _VERDICT_RE.search(blob)
|
|
863
953
|
if match:
|
|
954
|
+
readiness, reason = parse_readiness(content)
|
|
955
|
+
if readiness is None:
|
|
956
|
+
readiness, reason = parse_readiness(title)
|
|
864
957
|
found.append(
|
|
865
958
|
(Alissa._created_key(item.get("createdAt")),
|
|
866
959
|
index,
|
|
867
|
-
|
|
960
|
+
VerdictEnvelope(
|
|
961
|
+
verdict=match.group(1).lower(),
|
|
962
|
+
readiness=readiness,
|
|
963
|
+
readiness_reason=reason,
|
|
964
|
+
))
|
|
868
965
|
)
|
|
869
966
|
break
|
|
870
967
|
|
|
@@ -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,13 +16,17 @@ 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
|
|
22
23
|
|
|
23
24
|
from .alissa import (
|
|
25
|
+
READINESS_AUTO,
|
|
26
|
+
READINESS_OPERATOR,
|
|
24
27
|
VERDICT_APPROVE,
|
|
25
28
|
VERDICT_REQUEST_CHANGES,
|
|
29
|
+
VerdictEnvelope,
|
|
26
30
|
Alissa,
|
|
27
31
|
ManagedSession,
|
|
28
32
|
SessionRef,
|
|
@@ -55,6 +59,7 @@ from .ghclient import (
|
|
|
55
59
|
PullRequest,
|
|
56
60
|
RateLimited,
|
|
57
61
|
Review,
|
|
62
|
+
TimelineTruncated,
|
|
58
63
|
TruncatedListing,
|
|
59
64
|
countable_rounds,
|
|
60
65
|
verdict_marker,
|
|
@@ -211,6 +216,18 @@ DEFERRAL_CLEARED = (
|
|
|
211
216
|
)
|
|
212
217
|
|
|
213
218
|
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class VerdictOfRecord:
|
|
221
|
+
"""The newest verdict on a PR's current head, as the round-admission gate
|
|
222
|
+
needs it (issue #128): `at` is the newer of GitHub's `submitted_at` and
|
|
223
|
+
the ledger's local stamp -- the cooldown's clock -- and `github_at` is
|
|
224
|
+
GitHub's stamp alone (None while GitHub's reviews list has not yet shown
|
|
225
|
+
the verdict), the only one comparable with a timeline `created_at`."""
|
|
226
|
+
|
|
227
|
+
at: float
|
|
228
|
+
github_at: float | None
|
|
229
|
+
|
|
230
|
+
|
|
214
231
|
@dataclass(frozen=True)
|
|
215
232
|
class Waiting:
|
|
216
233
|
"""One round's place in the spawn queue, from its FIRST deferral.
|
|
@@ -377,10 +394,50 @@ NATIVE_VERDICT_BODY = (
|
|
|
377
394
|
"Submitted by the review daemon under the configured reviewer identity, so "
|
|
378
395
|
"this round has a verdict of record on GitHub. The round's findings and "
|
|
379
396
|
"reasoning are in the reviewer session's own comments on this PR{task_note}."
|
|
380
|
-
"{head_note}\n\n"
|
|
397
|
+
"{head_note}{readiness}\n\n"
|
|
381
398
|
"{marker}"
|
|
382
399
|
)
|
|
383
400
|
|
|
401
|
+
# The merge-readiness trailer (issue #130). orcloop's opt-in merge edge merges
|
|
402
|
+
# a PR only when the reviewer's APPROVE on the current head carries
|
|
403
|
+
# `Merge-Readiness: auto`; the judgment is the reviewer session's (the
|
|
404
|
+
# envelope's `- **Merge-Readiness:**` line) and this daemon only CARRIES it,
|
|
405
|
+
# in a grammar the consumer can parse: one plain-text line, line-anchored,
|
|
406
|
+
# the last non-empty line before the hidden verdict marker --
|
|
407
|
+
#
|
|
408
|
+
# Merge-Readiness: auto
|
|
409
|
+
# Merge-Readiness: operator — <one-line reason>
|
|
410
|
+
#
|
|
411
|
+
# The rules that make it safe to consume: it is emitted on APPROVE events
|
|
412
|
+
# ONLY (a request_changes envelope with a stray `auto` line carries nothing;
|
|
413
|
+
# an approve the checks gate downgraded carries nothing), and an envelope
|
|
414
|
+
# with no parseable line fails CLOSED to `operator`, with a reason that tells
|
|
415
|
+
# the operator why the merge waited on them.
|
|
416
|
+
READINESS_TRAILER_LABEL = "Merge-Readiness:"
|
|
417
|
+
READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
|
|
421
|
+
"""The line the native APPROVE carries for `envelope`'s judgment.
|
|
422
|
+
|
|
423
|
+
The caller has already decided the event is an APPROVE; this only maps
|
|
424
|
+
the envelope onto the grammar. `auto` is emitted bare -- the consumer
|
|
425
|
+
keys on the value, and an operator reads a reason only when the merge is
|
|
426
|
+
theirs to make. A missing or unparseable judgment (None envelope, or one
|
|
427
|
+
whose readiness did not parse) is `operator` with a reason saying so.
|
|
428
|
+
"""
|
|
429
|
+
if envelope is not None and envelope.readiness == READINESS_AUTO:
|
|
430
|
+
return f"{READINESS_TRAILER_LABEL} {READINESS_AUTO}"
|
|
431
|
+
if envelope is not None and envelope.readiness == READINESS_OPERATOR:
|
|
432
|
+
if envelope.readiness_reason:
|
|
433
|
+
return (
|
|
434
|
+
f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — "
|
|
435
|
+
f"{envelope.readiness_reason}"
|
|
436
|
+
)
|
|
437
|
+
return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR}"
|
|
438
|
+
return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — {READINESS_MISSING_REASON}"
|
|
439
|
+
|
|
440
|
+
|
|
384
441
|
# Appended to the body when the round's review task is known.
|
|
385
442
|
_VERDICT_TASK_NOTE = ", and the CR6 verdict envelope is on `{task_ref}`"
|
|
386
443
|
|
|
@@ -587,6 +644,17 @@ _RECORD_THE_CAP = (
|
|
|
587
644
|
"from a stale template default. "
|
|
588
645
|
)
|
|
589
646
|
|
|
647
|
+
# The merge-readiness line (issue #130). The verdict envelope is where the
|
|
648
|
+
# reviewer's judgment lives; the daemon copies it onto the native review as a
|
|
649
|
+
# trailer the merge edge reads, and an envelope without the line posts as
|
|
650
|
+
# `operator` -- so a reviewer who skips it has silently withheld auto-merge.
|
|
651
|
+
_MERGE_READINESS_LINE = (
|
|
652
|
+
"Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
|
|
653
|
+
"operator — <reason>` line — the daemon copies it onto the native review as "
|
|
654
|
+
"the `Merge-Readiness:` trailer the merge edge reads, and an envelope "
|
|
655
|
+
"without the line posts as `operator`. "
|
|
656
|
+
)
|
|
657
|
+
|
|
590
658
|
# -- the reviewer session's own CI gate (issue #84) ---------------------------
|
|
591
659
|
#
|
|
592
660
|
# _gate_on_checks (issue #58) gates the verdict the DAEMON posts. It cannot gate the
|
|
@@ -867,6 +935,7 @@ ROUND_1_DIRECTIVE = (
|
|
|
867
935
|
"severity-tagged comments via gh pr review, record the verdict evidence, "
|
|
868
936
|
"move the task to pending_validation. "
|
|
869
937
|
+ _RECORD_THE_CAP
|
|
938
|
+
+ _MERGE_READINESS_LINE
|
|
870
939
|
+ "{credential}"
|
|
871
940
|
+ _CHECKS_BEFORE_VERDICT
|
|
872
941
|
+ "{checks}"
|
|
@@ -885,6 +954,7 @@ ROUND_K_DIRECTIVE = (
|
|
|
885
954
|
"verify the fixes, sweep the new diff with the full rubric, record a "
|
|
886
955
|
"round-{round} verdict envelope, move the task to pending_validation. "
|
|
887
956
|
+ _RECORD_THE_CAP
|
|
957
|
+
+ _MERGE_READINESS_LINE
|
|
888
958
|
+ "{credential}"
|
|
889
959
|
+ _CHECKS_BEFORE_VERDICT
|
|
890
960
|
+ "{checks}"
|
|
@@ -1251,6 +1321,26 @@ def _now() -> str:
|
|
|
1251
1321
|
return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
1252
1322
|
|
|
1253
1323
|
|
|
1324
|
+
def _epoch(stamp: str) -> float | None:
|
|
1325
|
+
"""A GitHub ISO-8601 timestamp (`2026-09-15T14:35:32Z`) as epoch seconds,
|
|
1326
|
+
or None for an empty or unparseable one. A naive stamp is read as UTC,
|
|
1327
|
+
which is what GitHub emits."""
|
|
1328
|
+
if not stamp:
|
|
1329
|
+
return None
|
|
1330
|
+
try:
|
|
1331
|
+
parsed = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
1332
|
+
except ValueError:
|
|
1333
|
+
return None
|
|
1334
|
+
if parsed.tzinfo is None:
|
|
1335
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
1336
|
+
return parsed.timestamp()
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def _stamp(epoch: float) -> str:
|
|
1340
|
+
"""The inverse of _epoch, for log lines: UTC, seconds, `Z`."""
|
|
1341
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1342
|
+
|
|
1343
|
+
|
|
1254
1344
|
def grant_activity_kind(comment_id: int) -> str:
|
|
1255
1345
|
"""The ping-ledger kind that dedupes ONE grant's activity line.
|
|
1256
1346
|
|
|
@@ -1705,6 +1795,18 @@ class ReviewWatcher:
|
|
|
1705
1795
|
# has listed this pass (the preflight refresh, or a sweep whose list
|
|
1706
1796
|
# failed), and the refresh lists for itself.
|
|
1707
1797
|
self._pass_roster: list[ManagedSession] | None = None
|
|
1798
|
+
# (repo full name, PR number) -> when the reviewer login was most
|
|
1799
|
+
# recently asked to review that PR, per the issue timeline (an ISO
|
|
1800
|
+
# stamp, or None for never) -- the round-admission gate's one GitHub
|
|
1801
|
+
# read, memoised so a PR costs at most one timeline walk per pass
|
|
1802
|
+
# (issue #128). Cleared with the other per-pass memos in poll_once.
|
|
1803
|
+
self._pass_requests: dict[tuple[str, int], str | None] = {}
|
|
1804
|
+
# (repo full name, PR number) -> (head, verdict epoch) of the last
|
|
1805
|
+
# stale request the gate announced at INFO for that PR, so the line
|
|
1806
|
+
# goes out once per ignored request and repeats at debug while the
|
|
1807
|
+
# same request keeps being seen. In-memory on purpose: a restart
|
|
1808
|
+
# costs one repeated line, not a ledger write per poll.
|
|
1809
|
+
self._ignored_requests: dict[tuple[str, int], tuple[str, float]] = {}
|
|
1708
1810
|
# (repo full name, PR number) -> where that round sits in the spawn
|
|
1709
1811
|
# queue, from the first pass that deferred it. Cross-pass and
|
|
1710
1812
|
# in-memory: it is a FAIRNESS ORDER, not a decision the daemon must
|
|
@@ -2170,6 +2272,17 @@ class ReviewWatcher:
|
|
|
2170
2272
|
if deferred is not None:
|
|
2171
2273
|
return deferred
|
|
2172
2274
|
|
|
2275
|
+
# THE ROUND-ADMISSION GATE (issue #128): a round on a head that already
|
|
2276
|
+
# carries a verdict needs FRESH evidence of a request -- a
|
|
2277
|
+
# `review_requested` event newer than that verdict -- and never starts
|
|
2278
|
+
# inside the post-verdict cooldown at all. Below the in-flight and
|
|
2279
|
+
# stale-round reads (a running round costs no timeline call) and above
|
|
2280
|
+
# every other refusal, because a round this gate turns away must
|
|
2281
|
+
# consume neither a rollup nor a place in the slot queue.
|
|
2282
|
+
refused = self._admit_round(pr, my_reviews, round_)
|
|
2283
|
+
if refused is not None:
|
|
2284
|
+
return refused
|
|
2285
|
+
|
|
2173
2286
|
# THE SPAWN GATE, and it sits here -- past every branch that decides
|
|
2174
2287
|
# WHETHER a round is owed, immediately before the one that acts.
|
|
2175
2288
|
# Upstream of it the loop is only reading; downstream it starts an
|
|
@@ -2307,6 +2420,186 @@ class ReviewWatcher:
|
|
|
2307
2420
|
round_,
|
|
2308
2421
|
)
|
|
2309
2422
|
|
|
2423
|
+
# -- round admission (issue #128) ----------------------------------------
|
|
2424
|
+
|
|
2425
|
+
def _admit_round(
|
|
2426
|
+
self, pr: PullRequest, my_reviews: list[Review], round_: int
|
|
2427
|
+
) -> Decision | None:
|
|
2428
|
+
"""Refuse a round the PR's review-request snapshot alone would start,
|
|
2429
|
+
or None to carry on.
|
|
2430
|
+
|
|
2431
|
+
The search set is `review-requested:@me`, and the loop's edge trigger
|
|
2432
|
+
has always been GitHub consuming that request when the requested
|
|
2433
|
+
identity submits a review. That consumption is not atomic with the
|
|
2434
|
+
daemon's reads: on studio #1243 round 1 posted `request_changes` at
|
|
2435
|
+
14:35:32 on head 0d9d66b7, the poll at 14:35:42 still found the PR in
|
|
2436
|
+
the search with the reviewer in `requested_reviewers`, and queued round
|
|
2437
|
+
2 on the same head -- a round nobody asked for, which then bounced on
|
|
2438
|
+
the CR8 triage gate and made the devloop spawn a second fix session on
|
|
2439
|
+
one branch. `requested_reviewers` is therefore a HINT here, never the
|
|
2440
|
+
verdict. A round on head H is admitted only when
|
|
2441
|
+
|
|
2442
|
+
(a) no verdict of record exists for (PR, H) -- a push re-arms exactly
|
|
2443
|
+
as before, since the newest review then judges an older commit; or
|
|
2444
|
+
(b) the issue timeline shows a `review_requested` event for the
|
|
2445
|
+
reviewer login NEWER than the last verdict on H.
|
|
2446
|
+
|
|
2447
|
+
And inside `verdict_cooldown_s` of that verdict nothing is admitted on
|
|
2448
|
+
H at all, whatever the timeline says: the cooldown alone would have
|
|
2449
|
+
prevented #1243, and (b) is what makes the gate correct rather than
|
|
2450
|
+
merely rare. The verdict's timestamp is the newer of GitHub's own
|
|
2451
|
+
review record and the ledger's (the daemon stamps its native posts the
|
|
2452
|
+
moment they land, before GitHub's reviews list catches up).
|
|
2453
|
+
|
|
2454
|
+
A refused round is a SKIPPED decision and holds no queue place, like
|
|
2455
|
+
the other pre-start refusals. Each ignored request is announced once at
|
|
2456
|
+
INFO -- "stale request" must be tellable from "idle" in the log -- and
|
|
2457
|
+
repeated at debug while the same request keeps being seen.
|
|
2458
|
+
|
|
2459
|
+
Two clocks, kept apart (PR #129 round 1): the cooldown is a duration
|
|
2460
|
+
on THIS host's wall clock, measured from the newer of GitHub's stamp
|
|
2461
|
+
and the ledger's local one; the comparison in (b) is GitHub's
|
|
2462
|
+
`created_at` against GitHub's `submitted_at`, both GitHub's clock, so
|
|
2463
|
+
a host running ahead of GitHub cannot make its own native post look
|
|
2464
|
+
newer than a genuine re-request that followed it. The ledger's local
|
|
2465
|
+
stamp enters (b) only while GitHub's reviews list has not yet shown
|
|
2466
|
+
the verdict at all -- the window the ledger exists for.
|
|
2467
|
+
"""
|
|
2468
|
+
verdict = self._last_verdict_at(pr, my_reviews)
|
|
2469
|
+
if verdict is None:
|
|
2470
|
+
return None
|
|
2471
|
+
verdict_at = verdict.at
|
|
2472
|
+
|
|
2473
|
+
key = (pr.full_name, pr.number)
|
|
2474
|
+
since = time.time() - verdict_at
|
|
2475
|
+
cooldown = self.config.verdict_cooldown_s
|
|
2476
|
+
# A verdict stamped in the FUTURE (clock skew between GitHub and this
|
|
2477
|
+
# host) is not inside the window measured from it; the timeline check
|
|
2478
|
+
# below still decides, so nothing is lost by not guessing.
|
|
2479
|
+
if 0 <= since < cooldown:
|
|
2480
|
+
self._waiting.pop(key, None)
|
|
2481
|
+
log.info(
|
|
2482
|
+
"%s round %d: the last verdict on head %s landed %.0fs ago (%s) "
|
|
2483
|
+
"— inside the %ds post-verdict cooldown, not queuing whatever "
|
|
2484
|
+
"the review-request snapshot says",
|
|
2485
|
+
pr.slug, round_, pr.head_sha[:8], since, _stamp(verdict_at), cooldown,
|
|
2486
|
+
)
|
|
2487
|
+
return Decision(
|
|
2488
|
+
Action.SKIPPED,
|
|
2489
|
+
f"head {pr.head_sha[:8]} had a verdict {int(since)}s ago — inside "
|
|
2490
|
+
f"the {cooldown}s post-verdict cooldown",
|
|
2491
|
+
round_,
|
|
2492
|
+
)
|
|
2493
|
+
|
|
2494
|
+
requested = self._fresh_request_at(pr)
|
|
2495
|
+
if requested is None:
|
|
2496
|
+
# The newest request could not be seen -- the timeline was
|
|
2497
|
+
# unreadable or longer than the bound (logged there). The snapshot
|
|
2498
|
+
# is all that is left, and past the cooldown it is the pre-#128
|
|
2499
|
+
# trigger: a wedged loop on every read failure is the worse trade.
|
|
2500
|
+
return None
|
|
2501
|
+
request_at = _epoch(requested)
|
|
2502
|
+
verdict_at = verdict.github_at if verdict.github_at is not None else verdict.at
|
|
2503
|
+
if request_at is not None and request_at > verdict_at:
|
|
2504
|
+
return None
|
|
2505
|
+
|
|
2506
|
+
self._waiting.pop(key, None)
|
|
2507
|
+
seen = (
|
|
2508
|
+
f"request seen at {requested}"
|
|
2509
|
+
if request_at is not None
|
|
2510
|
+
else f"no review_requested event for {self.github.login} on the timeline"
|
|
2511
|
+
)
|
|
2512
|
+
message = (
|
|
2513
|
+
"%s round %d: %s is older than the last verdict at %s on head %s "
|
|
2514
|
+
"— waiting for a fresh re-request"
|
|
2515
|
+
)
|
|
2516
|
+
args = (pr.slug, round_, seen, _stamp(verdict_at), pr.head_sha[:8])
|
|
2517
|
+
if self._ignored_requests.get(key) == (pr.head_sha, verdict_at):
|
|
2518
|
+
log.debug(message, *args)
|
|
2519
|
+
else:
|
|
2520
|
+
self._ignored_requests[key] = (pr.head_sha, verdict_at)
|
|
2521
|
+
log.info(message, *args)
|
|
2522
|
+
return Decision(
|
|
2523
|
+
Action.SKIPPED,
|
|
2524
|
+
f"{seen} is older than the last verdict on head {pr.head_sha[:8]} "
|
|
2525
|
+
f"— waiting for a fresh re-request",
|
|
2526
|
+
round_,
|
|
2527
|
+
)
|
|
2528
|
+
|
|
2529
|
+
def _last_verdict_at(
|
|
2530
|
+
self, pr: PullRequest, my_reviews: list[Review]
|
|
2531
|
+
) -> VerdictOfRecord | None:
|
|
2532
|
+
"""The newest verdict of record on the CURRENT head, or None when no
|
|
2533
|
+
reviewer-identity review judges this head.
|
|
2534
|
+
|
|
2535
|
+
Two sources, the newer wins for the cooldown. GitHub's reviews list is
|
|
2536
|
+
the authority for a verdict the reviewer SESSION posted (it never
|
|
2537
|
+
passes through the daemon), and the newest review is the one that
|
|
2538
|
+
matters: judging an older commit, it is not a verdict on this head at
|
|
2539
|
+
all. The ledger covers the daemon's own native posts during the window
|
|
2540
|
+
before GitHub's list shows them. A GitHub-observed verdict is written
|
|
2541
|
+
back to the ledger as telemetry, so the ledger converges on GitHub's
|
|
2542
|
+
truth and a row lost either way costs nothing. GitHub's own stamp is
|
|
2543
|
+
carried out separately because it is the only one in the timeline's
|
|
2544
|
+
clock domain -- see `_admit_round`.
|
|
2545
|
+
"""
|
|
2546
|
+
newest = my_reviews[-1] if my_reviews else None
|
|
2547
|
+
seen: float | None = None
|
|
2548
|
+
if newest is not None and newest.commit_id and newest.commit_id == pr.head_sha:
|
|
2549
|
+
seen = _epoch(newest.submitted_at)
|
|
2550
|
+
if seen is not None:
|
|
2551
|
+
self.state.note_observed_verdict(
|
|
2552
|
+
pr.full_name, pr.number, pr.head_sha, int(seen), newest.url
|
|
2553
|
+
)
|
|
2554
|
+
ledger = self.state.last_verdict_at(pr.full_name, pr.number, pr.head_sha)
|
|
2555
|
+
candidates = [at for at in (seen, ledger) if at is not None]
|
|
2556
|
+
if not candidates:
|
|
2557
|
+
return None
|
|
2558
|
+
return VerdictOfRecord(at=max(candidates), github_at=seen)
|
|
2559
|
+
|
|
2560
|
+
def _fresh_request_at(self, pr: PullRequest) -> str | None:
|
|
2561
|
+
"""When this login was most recently asked to review the PR, from the
|
|
2562
|
+
issue timeline, memoised for the pass: one timeline walk per candidate
|
|
2563
|
+
PR per poll, however many times the PR is evaluated. None when the
|
|
2564
|
+
read failed (a RateLimited still propagates to run_forever's backoff,
|
|
2565
|
+
exactly as every other read path lets it)."""
|
|
2566
|
+
key = (pr.full_name, pr.number)
|
|
2567
|
+
if key in self._pass_requests:
|
|
2568
|
+
return self._pass_requests[key]
|
|
2569
|
+
try:
|
|
2570
|
+
requested = self.github.review_requested_at(
|
|
2571
|
+
pr.owner, pr.repo, pr.number, self.github.login
|
|
2572
|
+
)
|
|
2573
|
+
except RateLimited:
|
|
2574
|
+
raise
|
|
2575
|
+
except TimelineTruncated as exc:
|
|
2576
|
+
# Same epistemic state as an unreadable timeline -- the newest
|
|
2577
|
+
# request cannot be seen -- so the same answer: fail OPEN. The
|
|
2578
|
+
# alternative, comparing against the newest request WITHIN the
|
|
2579
|
+
# bound, refuses every same-head round on a long-timelined PR
|
|
2580
|
+
# forever with nothing but a warning (PR #129 round 1, major).
|
|
2581
|
+
log.warning(
|
|
2582
|
+
"%s: %s — admitting the round on the review-request snapshot "
|
|
2583
|
+
"alone, which is what the post-verdict cooldown guards",
|
|
2584
|
+
pr.slug, exc,
|
|
2585
|
+
)
|
|
2586
|
+
requested = None
|
|
2587
|
+
except Exception as exc:
|
|
2588
|
+
log.warning(
|
|
2589
|
+
"%s: could not read the issue timeline for review requests to "
|
|
2590
|
+
"%s (%s) — admitting the round on the review-request snapshot "
|
|
2591
|
+
"alone, which is what the post-verdict cooldown guards",
|
|
2592
|
+
pr.slug, self.github.login, exc,
|
|
2593
|
+
)
|
|
2594
|
+
requested = None
|
|
2595
|
+
else:
|
|
2596
|
+
# A timeline with no request at all is a real answer -- the
|
|
2597
|
+
# snapshot said "requested" and history says nobody did -- and it
|
|
2598
|
+
# is memoised as the empty string so the miss is not re-read.
|
|
2599
|
+
requested = requested or ""
|
|
2600
|
+
self._pass_requests[key] = requested
|
|
2601
|
+
return requested
|
|
2602
|
+
|
|
2310
2603
|
def _refused_before_start(
|
|
2311
2604
|
self, pr: PullRequest, round_: int, task: Task | None
|
|
2312
2605
|
) -> Decision | None:
|
|
@@ -2932,9 +3225,10 @@ class ReviewWatcher:
|
|
|
2932
3225
|
Never raises past RateLimited: a broken post must stall this PR, not
|
|
2933
3226
|
the whole poll pass.
|
|
2934
3227
|
"""
|
|
2935
|
-
|
|
3228
|
+
envelope = self.alissa.latest_envelope(task.ref)
|
|
3229
|
+
verdict = None if envelope is None else envelope.verdict
|
|
2936
3230
|
if verdict not in (VERDICT_APPROVE, VERDICT_REQUEST_CHANGES):
|
|
2937
|
-
# count_verdicts and
|
|
3231
|
+
# count_verdicts and latest_envelope read the same envelopes with
|
|
2938
3232
|
# the same pattern, so this is nearly unreachable -- but "I know a
|
|
2939
3233
|
# round finished and cannot tell you its verdict" must never be
|
|
2940
3234
|
# resolved by guessing one onto the PR.
|
|
@@ -3029,6 +3323,11 @@ class ReviewWatcher:
|
|
|
3029
3323
|
return gate.hold
|
|
3030
3324
|
event = gate.event
|
|
3031
3325
|
|
|
3326
|
+
# The trailer follows the EVENT, not the envelope: an approve the
|
|
3327
|
+
# checks gate turned into a REQUEST_CHANGES or COMMENT carries none,
|
|
3328
|
+
# because `auto` on anything but an APPROVE is exactly what the
|
|
3329
|
+
# consumer must never see (issue #130).
|
|
3330
|
+
trailer = readiness_trailer(envelope) if event == EVENT_APPROVE else ""
|
|
3032
3331
|
body = gate.lead + NATIVE_VERDICT_BODY.format(
|
|
3033
3332
|
round=round_,
|
|
3034
3333
|
verdict=verdict,
|
|
@@ -3038,6 +3337,7 @@ class ReviewWatcher:
|
|
|
3038
3337
|
if moved
|
|
3039
3338
|
else ""
|
|
3040
3339
|
),
|
|
3340
|
+
readiness=f"\n\n{trailer}" if trailer else "",
|
|
3041
3341
|
marker=verdict_marker(round_),
|
|
3042
3342
|
)
|
|
3043
3343
|
try:
|
|
@@ -3069,14 +3369,26 @@ class ReviewWatcher:
|
|
|
3069
3369
|
self.state.record_verdict_post(
|
|
3070
3370
|
pr.full_name, pr.number, round_, url, verdict=verdict
|
|
3071
3371
|
)
|
|
3372
|
+
# The round-admission gate's cooldown is measured from HERE for the
|
|
3373
|
+
# daemon's own posts, so it holds even while GitHub's reviews list
|
|
3374
|
+
# still lags the POST it just accepted (issue #128).
|
|
3375
|
+
self.state.record_verdict(
|
|
3376
|
+
pr.full_name, pr.number, judged, int(time.time()), url or ""
|
|
3377
|
+
)
|
|
3378
|
+
# The readiness carried on the post, in the same three records. Only
|
|
3379
|
+
# an APPROVE carries one, so only an APPROVE names it.
|
|
3380
|
+
readiness_value = trailer[len(READINESS_TRAILER_LABEL):].strip()
|
|
3381
|
+
readiness_note = f" readiness={readiness_value}" if trailer else ""
|
|
3072
3382
|
log.info(
|
|
3073
|
-
"%s round %d closed: native %s review submitted as %s (%s)%s",
|
|
3074
|
-
pr.slug, round_, event, self.github.login, url or "no url",
|
|
3383
|
+
"%s round %d closed: native %s review submitted as %s (%s)%s%s",
|
|
3384
|
+
pr.slug, round_, event, self.github.login, url or "no url",
|
|
3385
|
+
readiness_note, gate_note,
|
|
3075
3386
|
)
|
|
3076
3387
|
self._append_activity(
|
|
3077
3388
|
pr,
|
|
3078
3389
|
f"- {_now()} — round {round_} — native `{event}` review submitted "
|
|
3079
|
-
f"as `{self.github.login}` (verdict of record){gate_note}"
|
|
3390
|
+
f"as `{self.github.login}` (verdict of record){gate_note}"
|
|
3391
|
+
+ (f" — merge-readiness: `{readiness_value}`" if trailer else ""),
|
|
3080
3392
|
)
|
|
3081
3393
|
if event == EVENT_COMMENT:
|
|
3082
3394
|
# A degraded verdict takes the PR out of the loop (the review
|
|
@@ -4920,6 +5232,7 @@ class ReviewWatcher:
|
|
|
4920
5232
|
self._session_census = None
|
|
4921
5233
|
self._census_probed = self._census_warned = False
|
|
4922
5234
|
self._pass_roster = None
|
|
5235
|
+
self._pass_requests = {}
|
|
4923
5236
|
|
|
4924
5237
|
# THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
|
|
4925
5238
|
# 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.31.0
|
|
@@ -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
|