alissa-tools-github-revloop 0.30.1__tar.gz → 0.31.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {alissa_tools_github_revloop-0.30.1/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.31.1}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/alissa.py +139 -5
  3. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/loop.py +192 -6
  4. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/state.py +57 -1
  5. alissa_tools_github_revloop-0.31.1/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_revloop-0.30.1/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  18. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  21. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  23. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
  26. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  29. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  30. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  31. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  32. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  34. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  35. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  36. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/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.1
3
+ Version: 0.31.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -233,6 +233,90 @@ _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
+ # The trailer's own grammar (issue #134) -- the bare-line sibling of
271
+ # _READINESS_RE. This is the line a native review ENDS with, whoever wrote
272
+ # it: the daemon's emitter (loop.readiness_trailer) when it posts the verdict
273
+ # itself, or the reviewer session's own `gh pr review` on the normal path.
274
+ # It is the consumer's grammar verbatim (README, "The `Merge-Readiness`
275
+ # trailer"): line-anchored, no bullet, no bold, no backticks, first match
276
+ # wins, value case-sensitive. The label is the ONE constant the emitter
277
+ # builds from, so the two cannot drift: a line the emitter writes is, by
278
+ # construction, a line this regex reads.
279
+ READINESS_TRAILER_LABEL = "Merge-Readiness:"
280
+ READINESS_MISSING = "missing"
281
+ _TRAILER_RE = re.compile(
282
+ r"^" + re.escape(READINESS_TRAILER_LABEL)
283
+ + r"[ \t]*(auto|operator)(?:[ \t]*[—-][ \t]*(.+))?[ \t]*$",
284
+ re.MULTILINE,
285
+ )
286
+
287
+
288
+ def parse_trailer(body: object) -> "tuple[str | None, str]":
289
+ """`(value, reason)` from the first bare `Merge-Readiness:` line in a
290
+ review body, or `(None, "")` when no line matches the trailer grammar.
291
+
292
+ Strict where parse_readiness is tolerant: the line inside backticks
293
+ mid-sentence (studio #1258) and the envelope's `- **Merge-Readiness:**`
294
+ bullet both read as MISSING here, because the consumer's regex is what
295
+ decides whether the merge edge sees the judgment at all. CRLF bodies (a
296
+ review typed into the web form) are normalised first; the grammar itself
297
+ is unchanged.
298
+ """
299
+ if not isinstance(body, str):
300
+ return (None, "")
301
+ match = _TRAILER_RE.search(body.replace("\r\n", "\n"))
302
+ if match is None:
303
+ return (None, "")
304
+ return (match.group(1), clean_readiness_reason(match.group(2)))
305
+
306
+
307
+ def parse_readiness(blob: object) -> "tuple[str | None, str]":
308
+ """`(value, reason)` from the first Merge-Readiness line in `blob`.
309
+
310
+ `value` is READINESS_AUTO / READINESS_OPERATOR, or None when no line
311
+ parses; `reason` is the cleaned trailing text (empty when there is none).
312
+ """
313
+ if not isinstance(blob, str):
314
+ return (None, "")
315
+ match = _READINESS_RE.search(blob)
316
+ if match is None:
317
+ return (None, "")
318
+ return (match.group(1), clean_readiness_reason(match.group(2)))
319
+
236
320
 
237
321
  @dataclass(frozen=True)
238
322
  class Task:
@@ -389,6 +473,23 @@ def _task_from_row(row: object) -> "Task | None":
389
473
  )
390
474
 
391
475
 
476
+ @dataclass(frozen=True)
477
+ class VerdictEnvelope:
478
+ """One parsed CR6 verdict envelope: the verdict, plus the merge-readiness
479
+ judgment the same envelope carries (issue #130).
480
+
481
+ `readiness` is READINESS_AUTO / READINESS_OPERATOR, or None when the
482
+ envelope has no parseable `Merge-Readiness` line -- the case the native
483
+ post fails closed on. `readiness_reason` is already one bounded,
484
+ backtick-free line (see clean_readiness_reason); empty when the envelope
485
+ gave none.
486
+ """
487
+
488
+ verdict: str
489
+ readiness: "str | None" = None
490
+ readiness_reason: str = ""
491
+
492
+
392
493
  @dataclass(frozen=True)
393
494
  class TaskDetail:
394
495
  """One task as `alissa task get` sees it: the task itself, plus everything
@@ -774,16 +875,29 @@ class Alissa:
774
875
  return matches[0]
775
876
 
776
877
  def latest_verdict(self, task_ref: str) -> str | None:
777
- """The newest CR6 verdict envelope on a review task, or None.
878
+ """The newest CR6 verdict envelope's verdict on a review task, or None.
778
879
 
779
880
  Returns VERDICT_APPROVE / VERDICT_REQUEST_CHANGES. This is the verdict
780
881
  of record: reviewers post comment-mode reviews, so the GitHub review
781
882
  state is always COMMENTED and cannot express approval at all.
782
883
 
884
+ The word alone; `latest_envelope` returns the whole record (verdict
885
+ plus merge-readiness) off the same read for the caller that posts it.
886
+
783
887
  Never raises. The daemon polls forever and this runs inside every pass,
784
888
  so absent, empty or malformed evidence degrades to "no verdict" rather
785
889
  than taking the loop down.
786
890
  """
891
+ envelope = self.latest_envelope(task_ref)
892
+ return None if envelope is None else envelope.verdict
893
+
894
+ def latest_envelope(self, task_ref: str) -> "VerdictEnvelope | None":
895
+ """The newest CR6 verdict envelope on a review task, parsed, or None.
896
+
897
+ Same read and same tolerance as `latest_verdict`; this is the record
898
+ the native post carries onto GitHub (issue #130), so it needs the
899
+ envelope's Merge-Readiness line alongside the verdict word.
900
+ """
787
901
  try:
788
902
  data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
789
903
  except CommandError as exc:
@@ -794,7 +908,7 @@ class Alissa:
794
908
  return None
795
909
 
796
910
  try:
797
- return self._newest_verdict(data)
911
+ return self._newest_envelope(data)
798
912
  except Exception: # pragma: no cover - defence in depth
799
913
  log.exception("could not parse verdict evidence for %s", task_ref)
800
914
  return None
@@ -839,10 +953,23 @@ class Alissa:
839
953
 
840
954
  @staticmethod
841
955
  def _newest_verdict(payload: object) -> str | None:
842
- """Pick the newest parseable verdict out of a task's evidence array.
956
+ """The newest parseable verdict WORD out of a task's evidence array;
957
+ `_newest_envelope` with the readiness dropped, for the callers that
958
+ only decide on the word."""
959
+ envelope = Alissa._newest_envelope(payload)
960
+ return None if envelope is None else envelope.verdict
961
+
962
+ @staticmethod
963
+ def _newest_envelope(payload: object) -> "VerdictEnvelope | None":
964
+ """Pick the newest parseable verdict envelope out of a task's evidence
965
+ array, with the Merge-Readiness judgment it carries (issue #130).
843
966
 
844
967
  Every layer is optional by design -- the payload shape is whatever the
845
968
  CLI printed, and a task with no evidence is the normal round-1 case.
969
+
970
+ The readiness is read off the SAME evidence item as the verdict (its
971
+ body first, then its title), never off a neighbour: a stray line on an
972
+ older envelope must not decorate a newer round's verdict.
846
973
  """
847
974
  if not isinstance(payload, dict):
848
975
  return None
@@ -850,7 +977,7 @@ class Alissa:
850
977
  if not isinstance(evidence, list):
851
978
  return None
852
979
 
853
- found: list[tuple[tuple[int, float], int, str]] = []
980
+ found: list[tuple[tuple[int, float], int, VerdictEnvelope]] = []
854
981
  for index, item in enumerate(evidence):
855
982
  if not isinstance(item, dict):
856
983
  continue
@@ -861,10 +988,17 @@ class Alissa:
861
988
  continue
862
989
  match = _VERDICT_RE.search(blob)
863
990
  if match:
991
+ readiness, reason = parse_readiness(content)
992
+ if readiness is None:
993
+ readiness, reason = parse_readiness(title)
864
994
  found.append(
865
995
  (Alissa._created_key(item.get("createdAt")),
866
996
  index,
867
- match.group(1).lower())
997
+ VerdictEnvelope(
998
+ verdict=match.group(1).lower(),
999
+ readiness=readiness,
1000
+ readiness_reason=reason,
1001
+ ))
868
1002
  )
869
1003
  break
870
1004
 
@@ -22,14 +22,20 @@ from enum import Enum
22
22
  from pathlib import Path
23
23
 
24
24
  from .alissa import (
25
+ READINESS_AUTO,
26
+ READINESS_MISSING,
27
+ READINESS_OPERATOR,
28
+ READINESS_TRAILER_LABEL,
25
29
  VERDICT_APPROVE,
26
30
  VERDICT_REQUEST_CHANGES,
31
+ VerdictEnvelope,
27
32
  Alissa,
28
33
  ManagedSession,
29
34
  SessionRef,
30
35
  Task,
31
36
  TaskDetail,
32
37
  is_review_task_for,
38
+ parse_trailer,
33
39
  session_repo_slug,
34
40
  )
35
41
  from .alissa_client import AlissaClient
@@ -391,10 +397,54 @@ NATIVE_VERDICT_BODY = (
391
397
  "Submitted by the review daemon under the configured reviewer identity, so "
392
398
  "this round has a verdict of record on GitHub. The round's findings and "
393
399
  "reasoning are in the reviewer session's own comments on this PR{task_note}."
394
- "{head_note}\n\n"
400
+ "{head_note}{readiness}\n\n"
395
401
  "{marker}"
396
402
  )
397
403
 
404
+ # The merge-readiness trailer (issue #130). orcloop's opt-in merge edge merges
405
+ # a PR only when the reviewer's APPROVE on the current head carries
406
+ # `Merge-Readiness: auto`; the judgment is the reviewer session's (the
407
+ # envelope's `- **Merge-Readiness:**` line) and this daemon only CARRIES it,
408
+ # in a grammar the consumer can parse: one plain-text line, line-anchored,
409
+ # the last non-empty line before the hidden verdict marker --
410
+ #
411
+ # Merge-Readiness: auto
412
+ # Merge-Readiness: operator — <one-line reason>
413
+ #
414
+ # The rules that make it safe to consume: it is emitted on APPROVE events
415
+ # ONLY (a request_changes envelope with a stray `auto` line carries nothing;
416
+ # an approve the checks gate downgraded carries nothing), and an envelope
417
+ # with no parseable line fails CLOSED to `operator`, with a reason that tells
418
+ # the operator why the merge waited on them.
419
+ #
420
+ # The label and the grammar live in alissa.py (READINESS_TRAILER_LABEL,
421
+ # parse_trailer): the emitter below builds from the label the regex is built
422
+ # from, and _observe_session_readiness reads a session's own review with that
423
+ # same regex (issue #134) -- one grammar, two writers, no drift.
424
+ READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
425
+
426
+
427
+ def readiness_trailer(envelope: "VerdictEnvelope | None") -> str:
428
+ """The line the native APPROVE carries for `envelope`'s judgment.
429
+
430
+ The caller has already decided the event is an APPROVE; this only maps
431
+ the envelope onto the grammar. `auto` is emitted bare -- the consumer
432
+ keys on the value, and an operator reads a reason only when the merge is
433
+ theirs to make. A missing or unparseable judgment (None envelope, or one
434
+ whose readiness did not parse) is `operator` with a reason saying so.
435
+ """
436
+ if envelope is not None and envelope.readiness == READINESS_AUTO:
437
+ return f"{READINESS_TRAILER_LABEL} {READINESS_AUTO}"
438
+ if envelope is not None and envelope.readiness == READINESS_OPERATOR:
439
+ if envelope.readiness_reason:
440
+ return (
441
+ f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — "
442
+ f"{envelope.readiness_reason}"
443
+ )
444
+ return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR}"
445
+ return f"{READINESS_TRAILER_LABEL} {READINESS_OPERATOR} — {READINESS_MISSING_REASON}"
446
+
447
+
398
448
  # Appended to the body when the round's review task is known.
399
449
  _VERDICT_TASK_NOTE = ", and the CR6 verdict envelope is on `{task_ref}`"
400
450
 
@@ -601,6 +651,30 @@ _RECORD_THE_CAP = (
601
651
  "from a stale template default. "
602
652
  )
603
653
 
654
+ # The merge-readiness line (issue #130, #134). The verdict envelope is where
655
+ # the reviewer's judgment lives. On the NORMAL path the session posts its own
656
+ # native review, and that body passes through nothing -- so the session must
657
+ # write the trailer itself, as the bare last non-empty line, in the consumer's
658
+ # grammar (studio #1258 wrote it inside backticks mid-sentence and the merge
659
+ # edge held the approve). The daemon copies the envelope's line onto a native
660
+ # review only on the rounds where it posts the verdict itself, and an
661
+ # envelope without the line posts as `operator` -- so a reviewer who skips it
662
+ # has silently withheld auto-merge. The two grammar sentences are the
663
+ # README's ("The `Merge-Readiness` trailer on a native approve"), verbatim.
664
+ _MERGE_READINESS_LINE = (
665
+ "Your verdict envelope MUST carry the skill's `- **Merge-Readiness:** auto | "
666
+ "operator — <reason>` line, AND your OWN native review (every `gh pr review` "
667
+ "form and the reviews-API POST alike) MUST END with the bare line "
668
+ "`Merge-Readiness: auto` or `Merge-Readiness: operator — <one-line reason>` "
669
+ "as its last non-empty line — plain text at the start of the line, not in "
670
+ "backticks, not bold, not mid-sentence — byte-equal in value and reason to "
671
+ "the envelope's line; `auto` only on an APPROVE of the reviewed head. The "
672
+ "merge edge reads that line off the review and holds an approve without "
673
+ "it. The daemon copies the envelope's line onto a native review only when "
674
+ "it posts the verdict itself, and an envelope without the line posts as "
675
+ "`operator`. "
676
+ )
677
+
604
678
  # -- the reviewer session's own CI gate (issue #84) ---------------------------
605
679
  #
606
680
  # _gate_on_checks (issue #58) gates the verdict the DAEMON posts. It cannot gate the
@@ -881,6 +955,7 @@ ROUND_1_DIRECTIVE = (
881
955
  "severity-tagged comments via gh pr review, record the verdict evidence, "
882
956
  "move the task to pending_validation. "
883
957
  + _RECORD_THE_CAP
958
+ + _MERGE_READINESS_LINE
884
959
  + "{credential}"
885
960
  + _CHECKS_BEFORE_VERDICT
886
961
  + "{checks}"
@@ -899,6 +974,7 @@ ROUND_K_DIRECTIVE = (
899
974
  "verify the fixes, sweep the new diff with the full rubric, record a "
900
975
  "round-{round} verdict envelope, move the task to pending_validation. "
901
976
  + _RECORD_THE_CAP
977
+ + _MERGE_READINESS_LINE
902
978
  + "{credential}"
903
979
  + _CHECKS_BEFORE_VERDICT
904
980
  + "{checks}"
@@ -2135,6 +2211,14 @@ class ReviewWatcher:
2135
2211
  # "that round has no native verdict", producing a duplicate post over a
2136
2212
  # session that closed its own round correctly.
2137
2213
  owed = completed - self.state.abandoned_rounds(pr.full_name, number)
2214
+
2215
+ # Observation only (issue #134): a session-posted APPROVE on the
2216
+ # current head is looked at once for the Merge-Readiness trailer the
2217
+ # merge edge reads. Above the native post and convergence because it
2218
+ # decides nothing -- it reports; and it must run on the pass that
2219
+ # converges, since a converged PR leaves the search set.
2220
+ self._observe_session_readiness(pr, my_reviews, completed)
2221
+
2138
2222
  if task is not None and owed > native:
2139
2223
  # Terminal for this pass either way. On a landed post the review
2140
2224
  # request it consumed drops the PR out of the search, so
@@ -3169,9 +3253,10 @@ class ReviewWatcher:
3169
3253
  Never raises past RateLimited: a broken post must stall this PR, not
3170
3254
  the whole poll pass.
3171
3255
  """
3172
- verdict = self.alissa.latest_verdict(task.ref)
3256
+ envelope = self.alissa.latest_envelope(task.ref)
3257
+ verdict = None if envelope is None else envelope.verdict
3173
3258
  if verdict not in (VERDICT_APPROVE, VERDICT_REQUEST_CHANGES):
3174
- # count_verdicts and latest_verdict read the same envelopes with
3259
+ # count_verdicts and latest_envelope read the same envelopes with
3175
3260
  # the same pattern, so this is nearly unreachable -- but "I know a
3176
3261
  # round finished and cannot tell you its verdict" must never be
3177
3262
  # resolved by guessing one onto the PR.
@@ -3266,6 +3351,11 @@ class ReviewWatcher:
3266
3351
  return gate.hold
3267
3352
  event = gate.event
3268
3353
 
3354
+ # The trailer follows the EVENT, not the envelope: an approve the
3355
+ # checks gate turned into a REQUEST_CHANGES or COMMENT carries none,
3356
+ # because `auto` on anything but an APPROVE is exactly what the
3357
+ # consumer must never see (issue #130).
3358
+ trailer = readiness_trailer(envelope) if event == EVENT_APPROVE else ""
3269
3359
  body = gate.lead + NATIVE_VERDICT_BODY.format(
3270
3360
  round=round_,
3271
3361
  verdict=verdict,
@@ -3275,6 +3365,7 @@ class ReviewWatcher:
3275
3365
  if moved
3276
3366
  else ""
3277
3367
  ),
3368
+ readiness=f"\n\n{trailer}" if trailer else "",
3278
3369
  marker=verdict_marker(round_),
3279
3370
  )
3280
3371
  try:
@@ -3312,14 +3403,20 @@ class ReviewWatcher:
3312
3403
  self.state.record_verdict(
3313
3404
  pr.full_name, pr.number, judged, int(time.time()), url or ""
3314
3405
  )
3406
+ # The readiness carried on the post, in the same three records. Only
3407
+ # an APPROVE carries one, so only an APPROVE names it.
3408
+ readiness_value = trailer[len(READINESS_TRAILER_LABEL):].strip()
3409
+ readiness_note = f" readiness={readiness_value}" if trailer else ""
3315
3410
  log.info(
3316
- "%s round %d closed: native %s review submitted as %s (%s)%s",
3317
- pr.slug, round_, event, self.github.login, url or "no url", gate_note,
3411
+ "%s round %d closed: native %s review submitted as %s (%s)%s%s",
3412
+ pr.slug, round_, event, self.github.login, url or "no url",
3413
+ readiness_note, gate_note,
3318
3414
  )
3319
3415
  self._append_activity(
3320
3416
  pr,
3321
3417
  f"- {_now()} — round {round_} — native `{event}` review submitted "
3322
- f"as `{self.github.login}` (verdict of record){gate_note}",
3418
+ f"as `{self.github.login}` (verdict of record){gate_note}"
3419
+ + (f" — merge-readiness: `{readiness_value}`" if trailer else ""),
3323
3420
  )
3324
3421
  if event == EVENT_COMMENT:
3325
3422
  # A degraded verdict takes the PR out of the loop (the review
@@ -3973,6 +4070,95 @@ class ReviewWatcher:
3973
4070
  deferred=True,
3974
4071
  )
3975
4072
 
4073
+ def _observe_session_readiness(
4074
+ self, pr: PullRequest, my_reviews: list[Review], round_: int
4075
+ ) -> None:
4076
+ """Say whether the session's own APPROVE carries the trailer (issue #134).
4077
+
4078
+ The merge edge reads `Merge-Readiness:` off the reviewer identity's
4079
+ APPROVE on the current head. On the normal path that review is the
4080
+ SESSION's -- posted by its own `gh pr review`, passing through nothing
4081
+ here -- so a body without the bare line (studio #1258 had it inside
4082
+ backticks mid-sentence) is an approve the edge holds, silently. The
4083
+ fix is the skill's and the directive's; this is the daemon SEEING it:
4084
+ one WARNING and one activity row per (PR, head) when the trailer is
4085
+ missing, the `readiness=auto|operator` term when it parses.
4086
+
4087
+ Observation only, by contract: never a review posted, never the
4088
+ session's review edited, never a round re-run. Scoped to the newest
4089
+ reviewer-identity APPROVED review on the current head -- the review the
4090
+ merge edge reads, whatever the identity wrote after it (a round-k
4091
+ reviewer that fell back to `--comment`, a follow-up write-up) -- and
4092
+ NOT the daemon's own post: that one is emitter-built and already
4093
+ reported `readiness=` at post time (a second row for it would be
4094
+ noise). Once per (PR, head) via the ledger flag beside the verdicts
4095
+ row: a new head re-arms, a re-poll of the same head is silent.
4096
+
4097
+ Emit, then record, like every other activity note here: the ledger
4098
+ flag lands only after the activity row did, so a transient comment
4099
+ failure retries next poll instead of losing the row; and never under
4100
+ `--dry-run`, where the row cannot land and a durable flag would let a
4101
+ diagnostic pass silence the daemon it was run to diagnose (see
4102
+ `_warn_identity_drift`). The flag rides a verdict row that
4103
+ `last_verdict_at` also reads, so it is stamped only with the review's
4104
+ own time -- an unreadable GitHub stamp keeps the log line and skips
4105
+ both the row and the flag rather than inventing a verdict at "now".
4106
+ """
4107
+ approves = [
4108
+ r for r in my_reviews
4109
+ if r.state == "APPROVED" and r.commit_id and r.commit_id == pr.head_sha
4110
+ ]
4111
+ newest = approves[-1] if approves else None
4112
+ if newest is None or newest.verdict_round is not None:
4113
+ return
4114
+ if self.state.readiness_observed(pr.full_name, pr.number, pr.head_sha) is not None:
4115
+ return
4116
+
4117
+ value, reason = parse_trailer(newest.body)
4118
+ head7 = pr.head_sha[:7]
4119
+ if value is None:
4120
+ readiness = READINESS_MISSING
4121
+ log.warning(
4122
+ "%s approve at %s by %s carries no Merge-Readiness trailer — the "
4123
+ "merge edge will hold it; the session must end its review body "
4124
+ "with the line (see directive)",
4125
+ pr.slug, head7, self.github.login,
4126
+ )
4127
+ line = (
4128
+ f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4129
+ f"`{self.github.login}` at `{head7}` — readiness=missing (no "
4130
+ f"`{READINESS_TRAILER_LABEL}` trailer on the review body; the merge "
4131
+ f"edge will hold it)"
4132
+ )
4133
+ else:
4134
+ readiness = value
4135
+ term = f"{value} — {reason}" if reason else value
4136
+ log.info(
4137
+ "%s approve at %s by %s carries readiness=%s",
4138
+ pr.slug, head7, self.github.login, term,
4139
+ )
4140
+ line = (
4141
+ f"- {_now()} — round {round_} — session-posted `APPROVE` review by "
4142
+ f"`{self.github.login}` at `{head7}` — readiness={term}"
4143
+ )
4144
+
4145
+ posted_at = _epoch(newest.submitted_at)
4146
+ if posted_at is None:
4147
+ # No stamp to key a verdict row on; the log line above is the
4148
+ # whole report for this poll, and the next poll says it again.
4149
+ log.debug(
4150
+ "%s: approve at %s has no readable submitted_at; readiness row "
4151
+ "and flag skipped", pr.slug, head7,
4152
+ )
4153
+ return
4154
+ if not self._append_activity(pr, line):
4155
+ return # retried next poll, like every other activity note
4156
+ if self.config.dry_run:
4157
+ return
4158
+ self.state.note_readiness(
4159
+ pr.full_name, pr.number, pr.head_sha, int(posted_at), readiness, newest.url,
4160
+ )
4161
+
3976
4162
  def _convergence_reason(
3977
4163
  self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
3978
4164
  ) -> str | None:
@@ -106,12 +106,20 @@ CREATE TABLE IF NOT EXISTS grants (
106
106
  -- never passes through the daemon). Rows are never pruned -- one per verdict
107
107
  -- ever landed -- and a lost row costs nothing: GitHub's reviews list is read
108
108
  -- alongside it and the gate takes the newer of the two.
109
+ --
110
+ -- `readiness` (issue #134) is the once-per-(PR, head) flag for the
111
+ -- Merge-Readiness observation: NULL until the loop has looked at a
112
+ -- session-posted APPROVE on that head, then `auto` / `operator` / `missing`
113
+ -- -- the last being the one the loop WARNs about, once, because the merge
114
+ -- edge will hold an approve with no trailer. Lives beside the verdict row
115
+ -- rather than in a table of its own: it is a fact about that verdict.
109
116
  CREATE TABLE IF NOT EXISTS verdicts (
110
117
  repo TEXT NOT NULL,
111
118
  number INTEGER NOT NULL,
112
119
  head_sha TEXT NOT NULL,
113
120
  posted_at INTEGER NOT NULL,
114
121
  review_url TEXT,
122
+ readiness TEXT,
115
123
  PRIMARY KEY (repo, number, head_sha, posted_at)
116
124
  );
117
125
 
@@ -350,6 +358,9 @@ _ADDED_COLUMNS = {
350
358
  ("checks_pending_at", "INTEGER"),
351
359
  ("verdict", "TEXT"),
352
360
  ),
361
+ "verdicts": (
362
+ ("readiness", "TEXT"),
363
+ ),
353
364
  }
354
365
 
355
366
 
@@ -777,6 +788,51 @@ class State:
777
788
  f"recording the verdict on {repo}#{number} at {head_sha[:8]}",
778
789
  )
779
790
 
791
+ def readiness_observed(self, repo: str, number: int, head_sha: str) -> str | None:
792
+ """The Merge-Readiness observation already made on this head
793
+ (`auto` / `operator` / `missing`), or None when the loop has not yet
794
+ looked at a session-posted APPROVE on it (issue #134). The once-per-
795
+ (PR, head) guard for the WARNING and its activity row."""
796
+ row = self._db.execute(
797
+ "SELECT readiness FROM verdicts "
798
+ "WHERE repo=? AND number=? AND head_sha=? AND readiness IS NOT NULL "
799
+ "ORDER BY posted_at DESC LIMIT 1",
800
+ (repo, number, head_sha),
801
+ ).fetchone()
802
+ return str(row["readiness"]) if row and row["readiness"] is not None else None
803
+
804
+ def note_readiness(
805
+ self, repo: str, number: int, head_sha: str, posted_at: int,
806
+ readiness: str, review_url: str = "",
807
+ ) -> bool:
808
+ """TELEMETRY: flag the verdict row for `head_sha` at `posted_at` with
809
+ the readiness the loop observed on it, creating the row if GitHub's
810
+ list showed the review before the ledger did (issue #134). Absorbed
811
+ like note_observed_verdict: a flag the ledger cannot take costs one
812
+ repeated WARNING on the next poll, not a wrong decision -- nothing
813
+ downstream reads the `readiness` column but the dedupe. The ROW is
814
+ another matter: it is a verdict row, and `last_verdict_at` reads its
815
+ `posted_at` for the post-verdict cooldown, so `posted_at` must be the
816
+ review's own GitHub stamp -- never a synthetic "now". The caller skips
817
+ the write when it has no such stamp. True on success."""
818
+ def write() -> None:
819
+ self._db.execute(
820
+ "INSERT OR IGNORE INTO verdicts "
821
+ "(repo, number, head_sha, posted_at, review_url) VALUES (?,?,?,?,?)",
822
+ (repo, number, head_sha, int(posted_at), review_url),
823
+ )
824
+ self._db.execute(
825
+ "UPDATE verdicts SET readiness=? "
826
+ "WHERE repo=? AND number=? AND head_sha=? AND posted_at=?",
827
+ (readiness, repo, number, head_sha, int(posted_at)),
828
+ )
829
+ self._db.commit()
830
+
831
+ return self._write_telemetry(
832
+ write,
833
+ f"recording readiness={readiness} on {repo}#{number} at {head_sha[:8]}",
834
+ )
835
+
780
836
  def last_verdict_at(self, repo: str, number: int, head_sha: str) -> int | None:
781
837
  """When the newest verdict of record on this head landed (epoch
782
838
  seconds), or None when the ledger knows of none on it."""
@@ -794,7 +850,7 @@ class State:
794
850
  with the `verdicts` table this raises `no such table`, not `[]` --
795
851
  whoever wires it into the console first should guard for that."""
796
852
  return self._read_rows(
797
- "SELECT repo, number, head_sha, posted_at, review_url FROM verdicts "
853
+ "SELECT repo, number, head_sha, posted_at, review_url, readiness FROM verdicts "
798
854
  "ORDER BY posted_at DESC, number DESC",
799
855
  limit,
800
856
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.30.1
3
+ Version: 0.31.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera