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.
Files changed (36) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
  3. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/alissa.py +102 -5
  4. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/config.py +30 -0
  5. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/ghclient.py +82 -0
  6. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/loop.py +319 -6
  7. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/state.py +71 -0
  8. alissa_tools_github_revloop-0.31.0/src/main/alissa/tools/github/revloop/version +1 -0
  9. {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
  10. alissa_tools_github_revloop-0.30.0/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/LICENSE +0 -0
  12. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/MANIFEST.in +0 -0
  13. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/NOTICE +0 -0
  14. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/README.md +0 -0
  15. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/requirements.txt +0 -0
  16. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/setup.cfg +0 -0
  17. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/setup.py +0 -0
  18. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  19. {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
  20. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  21. {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
  22. {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
  23. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.30.0 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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._newest_verdict(data)
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
- """Pick the newest parseable verdict out of a task's evidence array.
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, str]] = []
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
- match.group(1).lower())
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
- verdict = self.alissa.latest_verdict(task.ref)
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 latest_verdict read the same envelopes with
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", gate_note,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera