alissa-tools-github-reviewloop 0.8.0__tar.gz → 0.10.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 (23) hide show
  1. {alissa_tools_github_reviewloop-0.8.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.10.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +40 -0
  3. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/loop.py +254 -4
  4. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/state.py +34 -0
  5. alissa_tools_github_reviewloop-0.10.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  6. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +0 -1
  8. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/MANIFEST.in +0 -0
  9. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/README.md +0 -0
  10. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/requirements.txt +0 -0
  11. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/setup.cfg +0 -0
  12. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/setup.py +0 -0
  13. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
  14. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
  15. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/alissa.py +0 -0
  16. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
  17. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
  18. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
  19. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
  20. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -46,6 +46,17 @@ class PullRequest:
46
46
  return self.merged or self.state != "open"
47
47
 
48
48
 
49
+ @dataclass(frozen=True)
50
+ class IssueComment:
51
+ """An issue comment on a PR (a PR is an issue). Distinct from Review:
52
+ issue comments live on the issues endpoints and never create review
53
+ records, so posting one can never disturb round counting."""
54
+
55
+ id: int
56
+ author: str
57
+ body: str
58
+
59
+
49
60
  @dataclass(frozen=True)
50
61
  class Review:
51
62
  author: str
@@ -221,3 +232,32 @@ class GitHub:
221
232
  f"body={body}",
222
233
  ]
223
234
  )
235
+
236
+ def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
237
+ data = (
238
+ self._api(
239
+ "-X",
240
+ "GET",
241
+ f"repos/{owner}/{repo}/issues/{number}/comments",
242
+ "-f",
243
+ "per_page=100",
244
+ )
245
+ or []
246
+ )
247
+ return [
248
+ IssueComment(
249
+ id=int(c.get("id") or 0),
250
+ author=(c.get("user") or {}).get("login", ""),
251
+ body=c.get("body") or "",
252
+ )
253
+ for c in data
254
+ ]
255
+
256
+ def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
257
+ self._api(
258
+ "-X",
259
+ "PATCH",
260
+ f"repos/{owner}/{repo}/issues/comments/{comment_id}",
261
+ "-f",
262
+ f"body={body}",
263
+ )
@@ -27,9 +27,25 @@ from .state import State
27
27
  log = logging.getLogger(__name__)
28
28
 
29
29
  # A reviewer session that has not submitted after this long is presumed dead
30
- # (skill failure mode: "reviewer session stalls"). The round is re-enqueued.
30
+ # (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
31
+ # but only with a second signal agreeing: the timer alone cannot tell a dead
32
+ # session from a slow one, and a timer-only re-enqueue double-spends the round
33
+ # (two sessions review it, both submit -- observed live twice: double round-2
34
+ # approves on devloop's PR #11, double approves on this repo's PR #19). See
35
+ # _defer_stale_round for the liveness signal.
31
36
  STALE_ROUND_SECONDS = 90 * 60
32
37
 
38
+ # The floor under the liveness deferral: a live session defers the stale
39
+ # respawn indefinitely -- correct for a genuinely slow round, silent forever
40
+ # for a session that is wedged but still registers tmux activity. Once the
41
+ # newest spawn's age reaches this multiple of STALE_ROUND_SECONDS, the loop
42
+ # posts one "stalled" operator comment per deferral episode (stalled_kind)
43
+ # and keeps deferring. 2 means the deferral itself has lasted a full extra
44
+ # stale window beyond the point the timer first fired -- long enough that a
45
+ # healthy round has almost always submitted by then, early enough that a
46
+ # wedged one surfaces the same day.
47
+ STALLED_DEFER_MULTIPLE = 2
48
+
33
49
  # The sweep only reaps a session that has been idle AND quiet this long. The
34
50
  # GitHub review count increments the moment a review is submitted, but the
35
51
  # reviewer still has close-out work after that (CR6 envelope, task status) --
@@ -96,6 +112,64 @@ ESCALATION_COMMENT = (
96
112
  "Last verdict: `{last_state}` at `{sha}`."
97
113
  )
98
114
 
115
+ STALLED_COMMENT = (
116
+ "**Review round stalled?** — round {round} has been in flight {minutes} min "
117
+ "(stale window: {stale} min), but its reviewer session `{session}` still "
118
+ "shows signs of life, so the daemon keeps deferring the respawn — "
119
+ "respawning over a live session double-spends the round: two reviewers "
120
+ "work it, both submit. Is that session actually making progress? Operator "
121
+ "options: inspect it (`alissa tmux ls`) and, if it is wedged, kill it "
122
+ "(`alissa tmux kill {session}`) so the respawn proceeds next poll, or "
123
+ "finish the round by hand."
124
+ )
125
+
126
+ # The hidden marker that identifies THE activity comment on a PR. Find-or-create
127
+ # keys on it (plus own authorship -- anyone can paste the marker into their own
128
+ # comment, and a spoofed marker must never be PATCHed), so every append lands in
129
+ # the same single comment however many rounds run.
130
+ ACTIVITY_MARKER = "<!-- alissa-reviewloop:activity -->"
131
+
132
+ ACTIVITY_HEADER = (
133
+ ACTIVITY_MARKER + "\n"
134
+ "**Review-loop activity** — mechanical spawn/round log; the daemon appends "
135
+ "a line each time it queues (or defers) a reviewer round on this PR."
136
+ )
137
+
138
+ # The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
139
+ # the cap-out escalation (terminal per head), a stall can recur, so the kind
140
+ # is narrowed per episode -- see stalled_kind.
141
+ ESCALATION_STALLED = "stalled"
142
+
143
+
144
+ def deferral_activity_kind(session: str) -> str:
145
+ """The ping-ledger kind that dedupes ONE deferral episode's activity line.
146
+
147
+ A deferral is re-decided every poll, so appending per decision would grow
148
+ the activity comment by one identical line a minute for as long as the
149
+ session holds out. One line per episode carries the same information --
150
+ "the daemon is deferring behind this session" -- and the session name is
151
+ the episode identity (nonce-unique per spawn), exactly as stalled_kind
152
+ reasons for the operator ping. Recorded only after the append lands, so a
153
+ transient failure retries next poll.
154
+ """
155
+ return f"activity-deferred:{session}"
156
+
157
+
158
+ def stalled_kind(session: str) -> str:
159
+ """The ping-ledger kind that dedupes ONE deferral episode's operator ping.
160
+
161
+ Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
162
+ spawn of every round can wedge mid-flight, and episode k's ping must not
163
+ silence episode k+1's. Keyed on the bare kind (or even on the round), the
164
+ re-enqueue of a round that wedges AGAIN would defer silently forever. The
165
+ session name already IS the episode identity -- nonce-unique per spawn
166
+ (see session_name) -- so it folds into the key. Delivery contract: the
167
+ ledger row lands only AFTER the comment posts (see _escalate_stalled), so
168
+ a transient comment failure retries next poll and the ping lands exactly
169
+ once per episode.
170
+ """
171
+ return f"{ESCALATION_STALLED}:{session}"
172
+
99
173
 
100
174
  class Action(str, Enum):
101
175
  SPAWNED = "spawned"
@@ -192,15 +266,97 @@ class ReviewWatcher:
192
266
  if age is not None and age < STALE_ROUND_SECONDS:
193
267
  return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
194
268
  if age is not None:
269
+ deferred = self._defer_stale_round(pr, round_, age)
270
+ if deferred is not None:
271
+ return deferred
195
272
  log.warning(
196
273
  "%s round %d has been in flight %.0f min with no submitted review "
197
- "— re-enqueuing (reviewer session presumed stalled)",
274
+ "and its session is gone or finished — re-enqueuing (reviewer "
275
+ "session presumed dead)",
198
276
  pr.slug,
199
277
  round_,
200
278
  age / 60,
201
279
  )
202
280
 
203
- return self._spawn(pr, round_, task)
281
+ return self._spawn(pr, round_, task, reenqueued=age is not None)
282
+
283
+ def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
284
+ """The liveness signal under the stale timer: a deferral, or None to
285
+ respawn.
286
+
287
+ Staleness needs TWO signals, not one: the ledger timer says the
288
+ newest spawn is old, but elapsed time alone cannot tell a dead
289
+ session from a slow one -- a thorough round can outlast
290
+ STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
291
+ over the still-working first one; two sessions review the same
292
+ round and both submit. So before respawning, consult external
293
+ evidence of life: the round's recorded session in the live list
294
+ (the reap sweep's own probe). Busy, or idle without a real quiet
295
+ period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
296
+ round is alive, defer with a reason. Gone, or idle-finished -> dead,
297
+ respawn (the sweep separately handles any corpse). An unprobeable
298
+ live list defers too: respawning on missing evidence is exactly the
299
+ double-spend, and the probe retries next poll.
300
+
301
+ The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
302
+ stale windows with the session still alive, one operator ping per
303
+ deferral episode (stalled_kind) -- then keep deferring. This method
304
+ never respawns over a live session; only the operator killing the
305
+ session (or it finishing/dying) unblocks the respawn.
306
+ """
307
+ row = self.state.get_spawn(pr.full_name, pr.number, round_)
308
+ if row is None: # age came from this row; belt and braces
309
+ return None
310
+ session = row["session"]
311
+ try:
312
+ live = {s.name: s for s in self.alissa.list_review_sessions()}
313
+ except CommandError as exc:
314
+ log.warning(
315
+ "%s round %d is stale but the session list is unavailable (%s) "
316
+ "— deferring the respawn rather than risking a double-spawned "
317
+ "round; the probe retries next poll",
318
+ pr.slug,
319
+ round_,
320
+ exc,
321
+ )
322
+ return Decision(
323
+ Action.IN_FLIGHT,
324
+ f"round {round_} is stale but liveness is unprobeable — deferring",
325
+ round_,
326
+ )
327
+
328
+ ses = live.get(session)
329
+ if ses is None:
330
+ return None # session gone -> presumed dead -> respawn
331
+ if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
332
+ return None # idle-finished: it died without submitting -> respawn
333
+
334
+ if (
335
+ age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
336
+ and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
337
+ ):
338
+ self._escalate_stalled(pr, round_, session, age)
339
+
340
+ life = "active" if ses.is_idle else "busy"
341
+ if not self.state.pinged(pr.full_name, pr.number, deferral_activity_kind(session)):
342
+ appended = self._append_activity(
343
+ pr,
344
+ self._activity_line(
345
+ session, round_, f"deferred — session `{session}` still {life}"
346
+ ),
347
+ )
348
+ if appended:
349
+ self.state.record_ping(
350
+ pr.full_name, pr.number, deferral_activity_kind(session)
351
+ )
352
+
353
+ return Decision(
354
+ Action.IN_FLIGHT,
355
+ f"round {round_} is stale ({int(age / 60)} min) but session "
356
+ f"{session} is still {life} — not "
357
+ f"respawning over a live reviewer",
358
+ round_,
359
+ )
204
360
 
205
361
  def _convergence_reason(
206
362
  self, my_reviews: list[Review], task: Task | None, head_sha: str
@@ -379,7 +535,9 @@ class ReviewWatcher:
379
535
 
380
536
  # -- actions -----------------------------------------------------------
381
537
 
382
- def _spawn(self, pr: PullRequest, round_: int, task: Task | None) -> Decision:
538
+ def _spawn(
539
+ self, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
540
+ ) -> Decision:
383
541
  if task is None:
384
542
  if self.config.on_missing_review_task == ON_MISSING_SKIP:
385
543
  return Decision(
@@ -427,6 +585,13 @@ class ReviewWatcher:
427
585
  task_ref=task.ref if task else None,
428
586
  )
429
587
 
588
+ # AFTER the enqueue on purpose: the activity comment is telemetry and
589
+ # must never gate the spawn it reports on.
590
+ context = (
591
+ "re-enqueued — previous session presumed dead" if reenqueued else "spawned"
592
+ )
593
+ self._append_activity(pr, self._activity_line(name, round_, context))
594
+
430
595
  return Decision(
431
596
  Action.SPAWNED,
432
597
  f"session {name} → {task.ref if task else 'no task'}",
@@ -505,6 +670,91 @@ class ReviewWatcher:
505
670
 
506
671
  return warnings
507
672
 
673
+ def _activity_line(self, session: str, round_: int, context: str) -> str:
674
+ ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
675
+ return f"- {ts} — `{session}` — round {round_} of {self.config.round_cap} — {context}"
676
+
677
+ def _append_activity(self, pr: PullRequest, line: str) -> bool:
678
+ """Append one line to THE activity comment on the PR; True if it landed.
679
+
680
+ Find-or-create: the PR's issue comments are filtered to OWN authorship
681
+ AND the hidden marker, and the line is PATCH-appended to the first
682
+ match; with no match, one marker-carrying comment is created. A marker
683
+ pasted by anyone else fails the author filter and is never touched.
684
+
685
+ Best-effort by contract: this is telemetry about a spawn that has
686
+ already happened, so no failure here may surface to the caller. The
687
+ except is deliberately broad -- _api turns rate-limit errors into
688
+ RateLimited (not CommandError), and letting that fly out of evaluate()
689
+ would fail the whole poll pass over a log line.
690
+ """
691
+ if self.config.dry_run:
692
+ log.info("[dry-run] would append activity line on %s: %s", pr.slug, line)
693
+ return False
694
+ try:
695
+ mine = [
696
+ c
697
+ for c in self.github.issue_comments(pr.owner, pr.repo, pr.number)
698
+ if c.author == self.github.login and ACTIVITY_MARKER in c.body
699
+ ]
700
+ if mine:
701
+ self.github.update_comment(
702
+ pr.owner, pr.repo, mine[0].id, mine[0].body + "\n" + line
703
+ )
704
+ else:
705
+ self.github.comment(
706
+ pr.owner, pr.repo, pr.number, ACTIVITY_HEADER + "\n" + line
707
+ )
708
+ except Exception as exc:
709
+ log.warning("could not append activity line on %s: %s", pr.slug, exc)
710
+ return False
711
+ return True
712
+
713
+ def _escalate_stalled(
714
+ self, pr: PullRequest, round_: int, session: str, age: float
715
+ ) -> None:
716
+ """Operator ping when the liveness deferral itself runs long: the
717
+ session showing life is the only thing holding the respawn back, so
718
+ a human must check whether it is progressing or wedged. Posted once
719
+ per deferral EPISODE (the ping ledger row is keyed
720
+ stalled_kind(session); a re-enqueued round that stalls again is a
721
+ new session, so it pings again), and the row is recorded only AFTER
722
+ the comment posts: this ping is the operator's only signal for the
723
+ episode, so a transient comment failure must retry next poll --
724
+ exactly-once delivered, unlike the cap-out page (a terminal state,
725
+ recorded despite failure). The decision stays a deferral either way
726
+ -- this comments, it never respawns."""
727
+ body = STALLED_COMMENT.format(
728
+ round=round_,
729
+ minutes=int(age / 60),
730
+ stale=STALE_ROUND_SECONDS // 60,
731
+ session=session,
732
+ )
733
+ log.warning(
734
+ "STALLED %s round %d has been deferred %.0f min behind live session "
735
+ "%s — escalating to operator (once per episode)",
736
+ pr.slug,
737
+ round_,
738
+ age / 60,
739
+ session,
740
+ )
741
+
742
+ if self.config.dry_run:
743
+ log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
744
+ return
745
+
746
+ try:
747
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
748
+ except CommandError as exc:
749
+ log.error(
750
+ "could not post the stalled-round comment on %s: %s — not "
751
+ "recording the episode; the ping retries next poll",
752
+ pr.slug,
753
+ exc,
754
+ )
755
+ return
756
+ self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
757
+
508
758
  def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
509
759
  body = ESCALATION_COMMENT.format(
510
760
  rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
@@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS reaps (
44
44
  session TEXT NOT NULL PRIMARY KEY,
45
45
  reaped_at INTEGER NOT NULL
46
46
  );
47
+
48
+ CREATE TABLE IF NOT EXISTS pings (
49
+ repo TEXT NOT NULL,
50
+ number INTEGER NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ pinged_at INTEGER NOT NULL,
53
+ PRIMARY KEY (repo, number, kind)
54
+ );
47
55
  """
48
56
 
49
57
 
@@ -168,6 +176,32 @@ class State:
168
176
  )
169
177
  self._db.commit()
170
178
 
179
+ def pinged(self, repo: str, number: int, kind: str) -> bool:
180
+ """Whether this KIND of operator ping already went out for the PR.
181
+
182
+ Kind is free-form TEXT (devloop's escalation-kind pattern): a caller
183
+ narrows a kind's dedupe scope by folding identity into the string --
184
+ e.g. one stalled ping per deferral episode, "stalled:<session>" (see
185
+ loop.stalled_kind). Kept apart from `escalations`, whose key is
186
+ (repo, number, head_sha) and whose rows page terminal states.
187
+ """
188
+ row = self._db.execute(
189
+ "SELECT 1 FROM pings WHERE repo=? AND number=? AND kind=?",
190
+ (repo, number, kind),
191
+ ).fetchone()
192
+ return row is not None
193
+
194
+ def record_ping(self, repo: str, number: int, kind: str) -> None:
195
+ """Idempotent per kind: OR IGNORE keeps the FIRST ping's timestamp,
196
+ so `pinged_at` is an audit field for when the episode was first
197
+ raised, not the most recent re-raise."""
198
+ self._db.execute(
199
+ "INSERT OR IGNORE INTO pings (repo, number, kind, pinged_at) "
200
+ "VALUES (?,?,?,?)",
201
+ (repo, number, kind, int(time.time())),
202
+ )
203
+ self._db.commit()
204
+
171
205
  def escalated(self, repo: str, number: int, head_sha: str) -> bool:
172
206
  row = self._db.execute(
173
207
  "SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera