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.
- {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
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/alissa.py +63 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/loop.py +69 -9
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/state.py +18 -0
- alissa_tools_github_reviewloop-0.6.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {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
- alissa_tools_github_reviewloop-0.4.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {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
- {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
- {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
- {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
|
@@ -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
|
|
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
|
-
#
|
|
137
|
-
#
|
|
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=?",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.6.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.4.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.4.0 → alissa_tools_github_reviewloop-0.6.0}/requirements.txt
RENAMED
|
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
|