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.
- {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
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/alissa.py +139 -5
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/loop.py +192 -6
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/state.py +57 -1
- alissa_tools_github_revloop-0.31.1/src/main/alissa/tools/github/revloop/version +1 -0
- {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
- alissa_tools_github_revloop-0.30.1/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/README.md +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/setup.py +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {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
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/bows.py +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {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
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {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
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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.
|
|
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
|
-
"""
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
|
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
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.31.1
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.30.1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|