alissa-tools-github-reviewloop 0.4.0__tar.gz → 0.6.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.4.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.6.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/alissa.py +63 -0
  3. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/loop.py +69 -9
  4. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/state.py +18 -0
  5. alissa_tools_github_reviewloop-0.6.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  6. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_reviewloop-0.4.0/src/main/alissa/tools/github/reviewloop/version +0 -1
  8. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/MANIFEST.in +0 -0
  9. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/README.md +0 -0
  10. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/requirements.txt +0 -0
  11. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/setup.cfg +0 -0
  12. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/setup.py +0 -0
  13. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
  14. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
  15. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
  16. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +0 -0
  17. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
  18. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
  19. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
  20. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
  21. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
  22. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
  23. {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.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.4.0
3
+ Version: 0.6.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -154,6 +154,48 @@ class Alissa:
154
154
  # lost its timestamp.
155
155
  return max(found, key=lambda pair: pair[0])[1]
156
156
 
157
+ def count_verdicts(self, task_ref: str) -> int:
158
+ """How many CR6 verdict envelopes are on the review task.
159
+
160
+ One envelope per completed round (CR7, append-only), so this is the
161
+ authoritative round count -- unlike the GitHub review count it cannot be
162
+ thrown off by an empty-bodied review or two reviews in one cycle. Never
163
+ raises: absent, empty, or malformed evidence degrades to 0 (round 1)
164
+ rather than taking the loop down.
165
+ """
166
+ try:
167
+ data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
168
+ except CommandError as exc:
169
+ log.warning("could not read verdict evidence for %s: %s", task_ref, exc)
170
+ return 0
171
+ except Exception: # pragma: no cover - defence in depth
172
+ log.exception("unexpected failure reading verdict evidence for %s", task_ref)
173
+ return 0
174
+ try:
175
+ return self._count_verdicts(data)
176
+ except Exception: # pragma: no cover - defence in depth
177
+ log.exception("could not count verdict evidence for %s", task_ref)
178
+ return 0
179
+
180
+ @staticmethod
181
+ def _count_verdicts(payload: object) -> int:
182
+ """Count evidence items carrying a verdict envelope. Mirrors
183
+ `_newest_verdict`'s tolerant parsing -- one match per item at most."""
184
+ if not isinstance(payload, dict):
185
+ return 0
186
+ evidence = payload.get("evidence")
187
+ if not isinstance(evidence, list):
188
+ return 0
189
+ count = 0
190
+ for item in evidence:
191
+ if not isinstance(item, dict):
192
+ continue
193
+ for blob in (item.get("title"), item.get("markdownContent")):
194
+ if isinstance(blob, str) and _VERDICT_RE.search(blob):
195
+ count += 1
196
+ break
197
+ return count
198
+
157
199
  def enqueue_reviewer(
158
200
  self,
159
201
  *,
@@ -185,6 +227,27 @@ class Alissa:
185
227
 
186
228
  run(argv, timeout=60)
187
229
 
230
+ # Reviewers are one-shot per round (CR3): once the session finishes and is
231
+ # reaped, it must never be respawned. Make that explicit so a self-kill or
232
+ # a daemon reap can't trigger a respawn loop. Best-effort — an older CLI
233
+ # without `queue set` should not fail the enqueue.
234
+ try:
235
+ run(["alissa", "tmux", "queue", "set", session, "respawn", "off"],
236
+ timeout=30, check=False)
237
+ except CommandError: # pragma: no cover - defence in depth
238
+ log.warning("could not set respawn off for %s", session)
239
+
240
+ def kill_session(self, session: str, *, dry_run: bool = False) -> None:
241
+ """Kill a finished reviewer's managed session to free its worker slot.
242
+
243
+ Best-effort and idempotent-friendly: the session may already be gone (the
244
+ reviewer self-killed), so a non-zero exit is not an error here.
245
+ """
246
+ if dry_run:
247
+ log.info("[dry-run] would kill session %s", session)
248
+ return
249
+ run(["alissa", "tmux", "kill", session], timeout=30, check=False)
250
+
188
251
  def add_repo_to_workspace(
189
252
  self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
190
253
  ) -> None:
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import re
15
+ import secrets
15
16
  import time
16
17
  from dataclasses import dataclass
17
18
  from enum import Enum
@@ -43,6 +44,16 @@ _CLOSE_THE_ROUND = (
43
44
  "request_changes. "
44
45
  )
45
46
 
47
+ # Reviewers are one-shot per round (CR3), so a finished session should not linger
48
+ # holding a worker slot. The daemon reaps it as a backstop, but the fast path is
49
+ # the reviewer releasing its own slot as its very last action. {session} is the
50
+ # reviewer's own managed session name, injected at spawn.
51
+ _RELEASE_SLOT = (
52
+ "FINALLY, and only once the round is fully closed above (review registered "
53
+ "AND verdict recorded), release your worker slot as your last action: run "
54
+ "`alissa tmux kill {session}`. Do nothing after it."
55
+ )
56
+
46
57
  ROUND_1_DIRECTIVE = (
47
58
  "You are a PR REVIEWER, not an implementer. {assignment} "
48
59
  "Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
@@ -51,7 +62,8 @@ ROUND_1_DIRECTIVE = (
51
62
  "move the task to pending_validation. "
52
63
  + _CLOSE_THE_ROUND +
53
64
  "NEVER push commits, merge, or change PR state. "
54
- "Do NOT create further ali-* sessions."
65
+ "Do NOT create further ali-* sessions. "
66
+ + _RELEASE_SLOT
55
67
  )
56
68
 
57
69
  ROUND_K_DIRECTIVE = (
@@ -63,7 +75,8 @@ ROUND_K_DIRECTIVE = (
63
75
  "round-{round} verdict envelope, move the task to pending_validation. "
64
76
  + _CLOSE_THE_ROUND +
65
77
  "NEVER push commits, merge, or change PR state. "
66
- "Do NOT create further ali-* sessions."
78
+ "Do NOT create further ali-* sessions. "
79
+ + _RELEASE_SLOT
67
80
  )
68
81
 
69
82
  ESCALATION_COMMENT = (
@@ -93,9 +106,17 @@ class Decision:
93
106
 
94
107
 
95
108
  def session_name(pr: PullRequest, round_: int) -> str:
96
- """tmux-safe, unique per (repo, pr, round)."""
109
+ """A tmux-safe reviewer session name, unique per spawn.
110
+
111
+ The `review-<repo>-pr<n>-r<round>` prefix stays human-readable, but a short
112
+ random nonce is appended so a re-used or miscounted round number can never
113
+ collide with a still-live session (a collision wedges the worker -- the
114
+ original 'stuck' failure). Safe to be non-deterministic: the generated name
115
+ is recorded in the spawn ledger and is what gets reaped / self-killed, so the
116
+ daemon never re-derives it.
117
+ """
97
118
  repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
98
- return f"review-{repo}-pr{pr.number}-r{round_}"
119
+ return f"review-{repo}-pr{pr.number}-r{round_}-{secrets.token_hex(3)}"
99
120
 
100
121
 
101
122
  class ReviewWatcher:
@@ -131,11 +152,25 @@ class ReviewWatcher:
131
152
  )
132
153
 
133
154
  my_reviews = self.github.my_reviews(owner, repo, number)
134
- completed = len(my_reviews)
135
155
 
136
- # Looked up here rather than inside _spawn: convergence needs the ref
137
- # too. _spawn still handles `task is None` exactly as before.
156
+ # The review task (CR2) is the round record: one verdict envelope per
157
+ # round (CR7), so counting envelopes is the authoritative "rounds
158
+ # completed" -- immune to the GitHub heuristics that drift. A round whose
159
+ # review has an empty body undercounts (round_ repeats -> the session name
160
+ # collides -> the worker wedges); two reviews in one cycle overcount.
161
+ # Fall back to the substantive-review count only before the review task
162
+ # exists (round 1). Looked up here (not in _spawn) because both the count
163
+ # and convergence need it.
138
164
  task = self.alissa.find_review_task(owner, repo, number)
165
+ completed = (
166
+ self.alissa.count_verdicts(task.ref) if task is not None
167
+ else len(my_reviews)
168
+ )
169
+
170
+ # Backstop: reap the sessions of rounds that have already landed a review
171
+ # (the fast path is the reviewer self-killing, but it may forget). A round
172
+ # <= completed is done; its session, if we still have it, can be killed.
173
+ self._reap_finished(pr.full_name, number, completed)
139
174
 
140
175
  converged = self._convergence_reason(my_reviews, task)
141
176
  if converged is not None:
@@ -189,6 +224,31 @@ class ReviewWatcher:
189
224
 
190
225
  return None
191
226
 
227
+ def _reap_finished(self, repo: str, number: int, completed: int) -> None:
228
+ """Kill the managed session of every round whose review has landed.
229
+
230
+ A round `<= completed` is done (its review was submitted), so its session
231
+ is finished and should not hold a worker slot. The reviewer self-kills as
232
+ the fast path; this is the backstop for when it forgets. Idempotent — each
233
+ session is killed at most once (recorded in the ledger) — never runs in
234
+ dry-run, and best-effort: a reap failure (or an already-gone session) must
235
+ not take the poll down.
236
+ """
237
+ if self.config.dry_run or completed < 1:
238
+ return
239
+ for round_ in range(1, completed + 1):
240
+ row = self.state.get_spawn(repo, number, round_)
241
+ if row is None or self.state.is_reaped(row["session"]):
242
+ continue
243
+ session = row["session"]
244
+ try:
245
+ self.alissa.kill_session(session)
246
+ except Exception: # pragma: no cover - defence in depth
247
+ log.exception("failed to reap session %s", session)
248
+ continue
249
+ self.state.record_reap(session)
250
+ log.info("reaped finished reviewer session %s (round %d done)", session, round_)
251
+
192
252
  # -- actions -----------------------------------------------------------
193
253
 
194
254
  def _spawn(self, pr: PullRequest, round_: int, task: Task | None) -> Decision:
@@ -210,16 +270,16 @@ class ReviewWatcher:
210
270
  else:
211
271
  assignment = f"You've been assigned Alissa review task {task.ref}."
212
272
 
273
+ name = session_name(pr, round_)
213
274
  template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
214
275
  directive = template.format(
215
- assignment=assignment, round=round_, cap=self.config.round_cap
276
+ assignment=assignment, round=round_, cap=self.config.round_cap, session=name
216
277
  )
217
278
 
218
279
  hub, problem = self._ensure_hub(pr)
219
280
  if problem is not None:
220
281
  return Decision(Action.SKIPPED, problem, round_)
221
282
 
222
- name = session_name(pr, round_)
223
283
  self.alissa.enqueue_reviewer(
224
284
  session=name,
225
285
  directive=directive,
@@ -31,6 +31,11 @@ CREATE TABLE IF NOT EXISTS escalations (
31
31
  escalated_at INTEGER NOT NULL,
32
32
  PRIMARY KEY (repo, number, head_sha)
33
33
  );
34
+
35
+ CREATE TABLE IF NOT EXISTS reaps (
36
+ session TEXT NOT NULL PRIMARY KEY,
37
+ reaped_at INTEGER NOT NULL
38
+ );
34
39
  """
35
40
 
36
41
 
@@ -81,6 +86,19 @@ class State:
81
86
  )
82
87
  self._db.commit()
83
88
 
89
+ def is_reaped(self, session: str) -> bool:
90
+ row = self._db.execute(
91
+ "SELECT 1 FROM reaps WHERE session=?", (session,)
92
+ ).fetchone()
93
+ return row is not None
94
+
95
+ def record_reap(self, session: str) -> None:
96
+ self._db.execute(
97
+ "INSERT OR REPLACE INTO reaps (session, reaped_at) VALUES (?,?)",
98
+ (session, int(time.time())),
99
+ )
100
+ self._db.commit()
101
+
84
102
  def escalated(self, repo: str, number: int, head_sha: str) -> bool:
85
103
  row = self._db.execute(
86
104
  "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.4.0
3
+ Version: 0.6.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera