alissa-tools-github-reviewloop 0.1.0__tar.gz → 0.2.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.1.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.2.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/alissa.py +86 -4
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +31 -2
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/loop.py +40 -9
- alissa_tools_github_reviewloop-0.2.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/state.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -15,10 +15,27 @@ log = logging.getLogger(__name__)
|
|
|
15
15
|
# A review task is "open" while it can still receive a verdict.
|
|
16
16
|
OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
|
|
17
17
|
|
|
18
|
+
# CR6 verdict envelope outcomes.
|
|
19
|
+
VERDICT_APPROVE = "approve"
|
|
20
|
+
VERDICT_REQUEST_CHANGES = "request_changes"
|
|
21
|
+
|
|
22
|
+
# Envelope titles and bodies both read:
|
|
23
|
+
# Review verdict: <org>/<repo>#<n> — request_changes (round 3, ...)
|
|
24
|
+
# # Review verdict: <org>/<repo>#<n> — approve
|
|
25
|
+
# The separator is an em-dash in practice; en-dash and hyphen are accepted too
|
|
26
|
+
# so a hand-written envelope does not silently fail to parse.
|
|
27
|
+
# Kept to a single line on purpose: the verdict word sits on the same line as
|
|
28
|
+
# the "Review verdict:" lead-in, so matching across newlines could only pick up
|
|
29
|
+
# a later round's wording out of order.
|
|
30
|
+
_VERDICT_RE = re.compile(
|
|
31
|
+
r"Review\s+verdict\s*:[^\n]*?[—–-]\s*(approve|request_changes)\b",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
18
35
|
|
|
19
36
|
@dataclass(frozen=True)
|
|
20
37
|
class Task:
|
|
21
|
-
ref: str # TASK-<
|
|
38
|
+
ref: str # TASK-<taskNumber>
|
|
22
39
|
title: str
|
|
23
40
|
status: str
|
|
24
41
|
|
|
@@ -40,12 +57,14 @@ class Alissa:
|
|
|
40
57
|
data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
|
|
41
58
|
tasks = []
|
|
42
59
|
for row in data:
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
# `taskNumber` is the ref the API resolves; `taskSeq` is a display
|
|
61
|
+
# ordinal and 404s as `TASK-<seq>`.
|
|
62
|
+
number = row.get("taskNumber")
|
|
63
|
+
if number is None:
|
|
45
64
|
continue
|
|
46
65
|
tasks.append(
|
|
47
66
|
Task(
|
|
48
|
-
ref=f"TASK-{
|
|
67
|
+
ref=f"TASK-{number}",
|
|
49
68
|
title=row.get("title", ""),
|
|
50
69
|
status=row.get("status", ""),
|
|
51
70
|
)
|
|
@@ -72,6 +91,69 @@ class Alissa:
|
|
|
72
91
|
)
|
|
73
92
|
return matches[0]
|
|
74
93
|
|
|
94
|
+
def latest_verdict(self, task_ref: str) -> str | None:
|
|
95
|
+
"""The newest CR6 verdict envelope on a review task, or None.
|
|
96
|
+
|
|
97
|
+
Returns VERDICT_APPROVE / VERDICT_REQUEST_CHANGES. This is the verdict
|
|
98
|
+
of record: reviewers post comment-mode reviews, so the GitHub review
|
|
99
|
+
state is always COMMENTED and cannot express approval at all.
|
|
100
|
+
|
|
101
|
+
Never raises. The daemon polls forever and this runs inside every pass,
|
|
102
|
+
so absent, empty or malformed evidence degrades to "no verdict" rather
|
|
103
|
+
than taking the loop down.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
|
|
107
|
+
except CommandError as exc:
|
|
108
|
+
log.warning("could not read verdict evidence for %s: %s", task_ref, exc)
|
|
109
|
+
return None
|
|
110
|
+
except Exception: # pragma: no cover - defence in depth
|
|
111
|
+
log.exception("unexpected failure reading verdict evidence for %s", task_ref)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
return self._newest_verdict(data)
|
|
116
|
+
except Exception: # pragma: no cover - defence in depth
|
|
117
|
+
log.exception("could not parse verdict evidence for %s", task_ref)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _newest_verdict(payload: object) -> str | None:
|
|
122
|
+
"""Pick the newest parseable verdict out of a task's evidence array.
|
|
123
|
+
|
|
124
|
+
Every layer is optional by design -- the payload shape is whatever the
|
|
125
|
+
CLI printed, and a task with no evidence is the normal round-1 case.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(payload, dict):
|
|
128
|
+
return None
|
|
129
|
+
evidence = payload.get("evidence")
|
|
130
|
+
if not isinstance(evidence, list):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
found: list[tuple[str, str]] = []
|
|
134
|
+
for item in evidence:
|
|
135
|
+
if not isinstance(item, dict):
|
|
136
|
+
continue
|
|
137
|
+
title = item.get("title")
|
|
138
|
+
content = item.get("markdownContent")
|
|
139
|
+
for blob in (title, content):
|
|
140
|
+
if not isinstance(blob, str):
|
|
141
|
+
continue
|
|
142
|
+
match = _VERDICT_RE.search(blob)
|
|
143
|
+
if match:
|
|
144
|
+
created = item.get("createdAt")
|
|
145
|
+
found.append(
|
|
146
|
+
(created if isinstance(created, str) else "", match.group(1).lower())
|
|
147
|
+
)
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
if not found:
|
|
151
|
+
return None
|
|
152
|
+
# ISO-8601 timestamps sort lexicographically. Undated evidence sorts
|
|
153
|
+
# first (empty string), so a dated envelope always wins over one that
|
|
154
|
+
# lost its timestamp.
|
|
155
|
+
return max(found, key=lambda pair: pair[0])[1]
|
|
156
|
+
|
|
75
157
|
def enqueue_reviewer(
|
|
76
158
|
self,
|
|
77
159
|
*,
|
|
@@ -45,6 +45,17 @@ class Review:
|
|
|
45
45
|
commit_id: str
|
|
46
46
|
submitted_at: str
|
|
47
47
|
url: str
|
|
48
|
+
body: str = ""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_substantive(self) -> bool:
|
|
52
|
+
"""A real review round, not a side effect of an inline comment.
|
|
53
|
+
|
|
54
|
+
Posting a standalone inline comment on a PR creates its own review
|
|
55
|
+
record with an empty body, so review records outnumber rounds. The
|
|
56
|
+
round-closing review always carries the verdict write-up in its body.
|
|
57
|
+
"""
|
|
58
|
+
return bool(self.body.strip())
|
|
48
59
|
|
|
49
60
|
|
|
50
61
|
class RateLimited(RuntimeError):
|
|
@@ -159,16 +170,34 @@ class GitHub:
|
|
|
159
170
|
commit_id=r.get("commit_id") or "",
|
|
160
171
|
submitted_at=r.get("submitted_at") or "",
|
|
161
172
|
url=r.get("html_url", ""),
|
|
173
|
+
body=r.get("body") or "",
|
|
162
174
|
)
|
|
163
175
|
for r in data
|
|
164
176
|
]
|
|
165
177
|
|
|
166
178
|
def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
167
|
-
"""My submitted reviews, oldest first -- one per
|
|
179
|
+
"""My substantive submitted reviews, oldest first -- one per round.
|
|
180
|
+
|
|
181
|
+
Empty-bodied records are dropped: a standalone inline comment creates
|
|
182
|
+
its own zero-body review record, so counting raw records overcounts
|
|
183
|
+
rounds badly. On fahera-mx/studio.alissa.app#210 three real rounds
|
|
184
|
+
produced six records (round 1 plus three inline-comment artifacts, then
|
|
185
|
+
rounds 2 and 3), and round 3's reviewer was told it was "round 6 of
|
|
186
|
+
cap 10".
|
|
187
|
+
|
|
188
|
+
Do NOT dedupe by `commit_id` instead -- it looks like the natural
|
|
189
|
+
grouping key but it UNDERCOUNTS. A round reviews whatever head is
|
|
190
|
+
current, and consecutive rounds routinely land on the same commit when
|
|
191
|
+
the implementer triages findings without pushing: on #210 rounds 2 and
|
|
192
|
+
3 both carry head 805398a and would collapse into one. Body presence
|
|
193
|
+
tracks "a reviewer wrote a verdict"; commit identity does not.
|
|
194
|
+
"""
|
|
168
195
|
mine = [
|
|
169
196
|
r
|
|
170
197
|
for r in self.reviews(owner, repo, number)
|
|
171
|
-
if r.author == self.login
|
|
198
|
+
if r.author == self.login
|
|
199
|
+
and r.state in SUBMITTED_STATES
|
|
200
|
+
and r.is_substantive
|
|
172
201
|
]
|
|
173
202
|
return sorted(mine, key=lambda r: r.submitted_at)
|
|
174
203
|
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
One pass = poll GitHub for pending review requests, decide per PR whether a
|
|
4
4
|
fresh reviewer round is owed, and enqueue it. Rounds are derived from GitHub
|
|
5
|
-
(one submitted review per round
|
|
5
|
+
(one *substantive* submitted review per round -- empty-bodied records are
|
|
6
|
+
inline-comment artifacts, not rounds), not from local bookkeeping. Convergence
|
|
7
|
+
comes from either the GitHub review state or the CR6 verdict envelope on the
|
|
8
|
+
Alissa review task.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
from __future__ import annotations
|
|
@@ -14,9 +17,9 @@ from dataclasses import dataclass
|
|
|
14
17
|
from enum import Enum
|
|
15
18
|
from pathlib import Path
|
|
16
19
|
|
|
17
|
-
from .alissa import Alissa
|
|
20
|
+
from .alissa import VERDICT_APPROVE, Alissa, Task
|
|
18
21
|
from .config import HUB_ADD, ON_MISSING_SKIP, Config
|
|
19
|
-
from .ghclient import GitHub, PullRequest, RateLimited
|
|
22
|
+
from .ghclient import GitHub, PullRequest, RateLimited, Review
|
|
20
23
|
from .proc import CommandError
|
|
21
24
|
from .state import State
|
|
22
25
|
|
|
@@ -114,8 +117,13 @@ class ReviewWatcher:
|
|
|
114
117
|
my_reviews = self.github.my_reviews(owner, repo, number)
|
|
115
118
|
completed = len(my_reviews)
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
# Looked up here rather than inside _spawn: convergence needs the ref
|
|
121
|
+
# too. _spawn still handles `task is None` exactly as before.
|
|
122
|
+
task = self.alissa.find_review_task(owner, repo, number)
|
|
123
|
+
|
|
124
|
+
converged = self._convergence_reason(my_reviews, task)
|
|
125
|
+
if converged is not None:
|
|
126
|
+
return Decision(Action.CONVERGED, converged, completed)
|
|
119
127
|
|
|
120
128
|
# CR9: never queue round cap+1.
|
|
121
129
|
if completed >= self.config.round_cap:
|
|
@@ -138,13 +146,36 @@ class ReviewWatcher:
|
|
|
138
146
|
age / 60,
|
|
139
147
|
)
|
|
140
148
|
|
|
141
|
-
return self._spawn(pr, round_)
|
|
149
|
+
return self._spawn(pr, round_, task)
|
|
142
150
|
|
|
143
|
-
|
|
151
|
+
def _convergence_reason(self, my_reviews: list[Review], task: Task | None) -> str | None:
|
|
152
|
+
"""Why the loop is done, or None if it is not.
|
|
153
|
+
|
|
154
|
+
Two independent signals, because neither alone is sufficient:
|
|
155
|
+
|
|
156
|
+
* The GitHub review state. Authoritative when it says APPROVED, but
|
|
157
|
+
reviewers work in comment mode, which can only ever produce
|
|
158
|
+
COMMENTED -- #210 has zero APPROVED records across its whole history.
|
|
159
|
+
On its own this made convergence unreachable: every PR, however
|
|
160
|
+
clean, ran to the round cap and escalated.
|
|
161
|
+
* The CR6 verdict envelope on the Alissa review task. The review skill
|
|
162
|
+
declares this the verdict of record, and unlike the GitHub state it
|
|
163
|
+
can actually express approval, so it is the signal that closes the
|
|
164
|
+
loop in practice.
|
|
165
|
+
"""
|
|
166
|
+
if my_reviews and my_reviews[-1].state == "APPROVED":
|
|
167
|
+
return "last GitHub review state is APPROVED"
|
|
168
|
+
|
|
169
|
+
# Only checkable once a review task exists; before that there is
|
|
170
|
+
# nowhere for a verdict to have been recorded.
|
|
171
|
+
if task is not None and self.alissa.latest_verdict(task.ref) == VERDICT_APPROVE:
|
|
172
|
+
return f"newest verdict envelope on {task.ref} reads approve"
|
|
144
173
|
|
|
145
|
-
|
|
146
|
-
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
# -- actions -----------------------------------------------------------
|
|
147
177
|
|
|
178
|
+
def _spawn(self, pr: PullRequest, round_: int, task: Task | None) -> Decision:
|
|
148
179
|
if task is None:
|
|
149
180
|
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
150
181
|
return Decision(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.1.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.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
|