alissa-tools-github-revloop 0.31.0__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.31.0/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.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/alissa.py +37 -0
  3. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/loop.py +125 -8
  4. {alissa_tools_github_revloop-0.31.0 → 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.31.0 → 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.31.0/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/alissa_client.py +0 -0
  18. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/bows.py +0 -0
  19. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/config.py +0 -0
  20. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/fleet_vitals.py +0 -0
  21. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  22. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/loop_events.py +0 -0
  23. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  24. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  25. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/version.py +0 -0
  26. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  27. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  28. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  29. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  30. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  31. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  32. {alissa_tools_github_revloop-0.31.0 → alissa_tools_github_revloop-0.31.1}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  33. {alissa_tools_github_revloop-0.31.0 → 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.31.0 → 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.31.0 → 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.31.0 → 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.31.0
3
+ Version: 0.31.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -267,6 +267,43 @@ def clean_readiness_reason(text: object) -> str:
267
267
  return flat[:MAX_READINESS_REASON_CHARS].rstrip()
268
268
 
269
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
+
270
307
  def parse_readiness(blob: object) -> "tuple[str | None, str]":
271
308
  """`(value, reason)` from the first Merge-Readiness line in `blob`.
272
309
 
@@ -23,7 +23,9 @@ from pathlib import Path
23
23
 
24
24
  from .alissa import (
25
25
  READINESS_AUTO,
26
+ READINESS_MISSING,
26
27
  READINESS_OPERATOR,
28
+ READINESS_TRAILER_LABEL,
27
29
  VERDICT_APPROVE,
28
30
  VERDICT_REQUEST_CHANGES,
29
31
  VerdictEnvelope,
@@ -33,6 +35,7 @@ from .alissa import (
33
35
  Task,
34
36
  TaskDetail,
35
37
  is_review_task_for,
38
+ parse_trailer,
36
39
  session_repo_slug,
37
40
  )
38
41
  from .alissa_client import AlissaClient
@@ -413,7 +416,11 @@ NATIVE_VERDICT_BODY = (
413
416
  # an approve the checks gate downgraded carries nothing), and an envelope
414
417
  # with no parseable line fails CLOSED to `operator`, with a reason that tells
415
418
  # the operator why the merge waited on them.
416
- READINESS_TRAILER_LABEL = "Merge-Readiness:"
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.
417
424
  READINESS_MISSING_REASON = "envelope carries no Merge-Readiness line"
418
425
 
419
426
 
@@ -644,15 +651,28 @@ _RECORD_THE_CAP = (
644
651
  "from a stale template default. "
645
652
  )
646
653
 
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.
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.
651
664
  _MERGE_READINESS_LINE = (
652
665
  "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`. "
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`. "
656
676
  )
657
677
 
658
678
  # -- the reviewer session's own CI gate (issue #84) ---------------------------
@@ -2191,6 +2211,14 @@ class ReviewWatcher:
2191
2211
  # "that round has no native verdict", producing a duplicate post over a
2192
2212
  # session that closed its own round correctly.
2193
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
+
2194
2222
  if task is not None and owed > native:
2195
2223
  # Terminal for this pass either way. On a landed post the review
2196
2224
  # request it consumed drops the PR out of the search, so
@@ -4042,6 +4070,95 @@ class ReviewWatcher:
4042
4070
  deferred=True,
4043
4071
  )
4044
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
+
4045
4162
  def _convergence_reason(
4046
4163
  self, my_reviews: list[Review], resolved: "ResolvedTask", head_sha: str
4047
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.31.0
3
+ Version: 0.31.1
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera