alissa-tools-github-revloop 0.30.1__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.1/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.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/alissa.py +102 -5
  3. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/loop.py +75 -6
  4. alissa_tools_github_revloop-0.31.0/src/main/alissa/tools/github/revloop/version +1 -0
  5. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_revloop-0.30.1/src/main/alissa/tools/github/revloop/version +0 -1
  7. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/LICENSE +0 -0
  8. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/MANIFEST.in +0 -0
  9. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/NOTICE +0 -0
  10. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/README.md +0 -0
  11. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/requirements.txt +0 -0
  12. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/setup.cfg +0 -0
  13. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/setup.py +0 -0
  14. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  15. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  16. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  17. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  18. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
  19. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  20. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  21. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  22. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  23. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  24. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  25. {alissa_tools_github_revloop-0.30.1 → alissa_tools_github_revloop-0.31.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  26. {alissa_tools_github_revloop-0.30.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1 → 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.1
3
+ Version: 0.31.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
 
@@ -22,8 +22,11 @@ from enum import Enum
22
22
  from pathlib import Path
23
23
 
24
24
  from .alissa import (
25
+ READINESS_AUTO,
26
+ READINESS_OPERATOR,
25
27
  VERDICT_APPROVE,
26
28
  VERDICT_REQUEST_CHANGES,
29
+ VerdictEnvelope,
27
30
  Alissa,
28
31
  ManagedSession,
29
32
  SessionRef,
@@ -391,10 +394,50 @@ NATIVE_VERDICT_BODY = (
391
394
  "Submitted by the review daemon under the configured reviewer identity, so "
392
395
  "this round has a verdict of record on GitHub. The round's findings and "
393
396
  "reasoning are in the reviewer session's own comments on this PR{task_note}."
394
- "{head_note}\n\n"
397
+ "{head_note}{readiness}\n\n"
395
398
  "{marker}"
396
399
  )
397
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
+
398
441
  # Appended to the body when the round's review task is known.
399
442
  _VERDICT_TASK_NOTE = ", and the CR6 verdict envelope is on `{task_ref}`"
400
443
 
@@ -601,6 +644,17 @@ _RECORD_THE_CAP = (
601
644
  "from a stale template default. "
602
645
  )
603
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
+
604
658
  # -- the reviewer session's own CI gate (issue #84) ---------------------------
605
659
  #
606
660
  # _gate_on_checks (issue #58) gates the verdict the DAEMON posts. It cannot gate the
@@ -881,6 +935,7 @@ ROUND_1_DIRECTIVE = (
881
935
  "severity-tagged comments via gh pr review, record the verdict evidence, "
882
936
  "move the task to pending_validation. "
883
937
  + _RECORD_THE_CAP
938
+ + _MERGE_READINESS_LINE
884
939
  + "{credential}"
885
940
  + _CHECKS_BEFORE_VERDICT
886
941
  + "{checks}"
@@ -899,6 +954,7 @@ ROUND_K_DIRECTIVE = (
899
954
  "verify the fixes, sweep the new diff with the full rubric, record a "
900
955
  "round-{round} verdict envelope, move the task to pending_validation. "
901
956
  + _RECORD_THE_CAP
957
+ + _MERGE_READINESS_LINE
902
958
  + "{credential}"
903
959
  + _CHECKS_BEFORE_VERDICT
904
960
  + "{checks}"
@@ -3169,9 +3225,10 @@ class ReviewWatcher:
3169
3225
  Never raises past RateLimited: a broken post must stall this PR, not
3170
3226
  the whole poll pass.
3171
3227
  """
3172
- 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
3173
3230
  if verdict not in (VERDICT_APPROVE, VERDICT_REQUEST_CHANGES):
3174
- # count_verdicts and latest_verdict read the same envelopes with
3231
+ # count_verdicts and latest_envelope read the same envelopes with
3175
3232
  # the same pattern, so this is nearly unreachable -- but "I know a
3176
3233
  # round finished and cannot tell you its verdict" must never be
3177
3234
  # resolved by guessing one onto the PR.
@@ -3266,6 +3323,11 @@ class ReviewWatcher:
3266
3323
  return gate.hold
3267
3324
  event = gate.event
3268
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 ""
3269
3331
  body = gate.lead + NATIVE_VERDICT_BODY.format(
3270
3332
  round=round_,
3271
3333
  verdict=verdict,
@@ -3275,6 +3337,7 @@ class ReviewWatcher:
3275
3337
  if moved
3276
3338
  else ""
3277
3339
  ),
3340
+ readiness=f"\n\n{trailer}" if trailer else "",
3278
3341
  marker=verdict_marker(round_),
3279
3342
  )
3280
3343
  try:
@@ -3312,14 +3375,20 @@ class ReviewWatcher:
3312
3375
  self.state.record_verdict(
3313
3376
  pr.full_name, pr.number, judged, int(time.time()), url or ""
3314
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 ""
3315
3382
  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,
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,
3318
3386
  )
3319
3387
  self._append_activity(
3320
3388
  pr,
3321
3389
  f"- {_now()} — round {round_} — native `{event}` review submitted "
3322
- 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 ""),
3323
3392
  )
3324
3393
  if event == EVENT_COMMENT:
3325
3394
  # A degraded verdict takes the PR out of the loop (the review
@@ -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.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera