alissa-tools-github-reviewloop 0.1.1__tar.gz → 0.3.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.1/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.3.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/setup.py +1 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/alissa.py +80 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +31 -2
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/loop.py +40 -9
- alissa_tools_github_reviewloop-0.3.0/src/main/alissa/tools/github/reviewloop/prreview.py +228 -0
- alissa_tools_github_reviewloop-0.3.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +1 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +1 -0
- alissa_tools_github_reviewloop-0.1.1/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/state.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -15,6 +15,23 @@ 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:
|
|
@@ -74,6 +91,69 @@ class Alissa:
|
|
|
74
91
|
)
|
|
75
92
|
return matches[0]
|
|
76
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
|
+
|
|
77
157
|
def enqueue_reviewer(
|
|
78
158
|
self,
|
|
79
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,228 @@
|
|
|
1
|
+
"""alissa-pr-review: the implementer-side driver for ONE review round.
|
|
2
|
+
|
|
3
|
+
The reviewer daemon (`alissa-reviewloop`) is autonomous but only the *reviewer*
|
|
4
|
+
half: a review request in, a review out. This command is the *implementer* half,
|
|
5
|
+
run by the dev session that just finished the work — it fires the trigger and
|
|
6
|
+
blocks on the verdict, so the loop closes without a second always-on daemon.
|
|
7
|
+
|
|
8
|
+
One invocation = one round:
|
|
9
|
+
|
|
10
|
+
1. resolve the PR from the branch
|
|
11
|
+
2. flip it ready-for-review (from draft)
|
|
12
|
+
3. request the reviewer ← this is the daemon's edge-trigger
|
|
13
|
+
4. block until a NEW review round lands, or the timeout
|
|
14
|
+
|
|
15
|
+
It reads the verdict from the **review task envelope**, never from GitHub's
|
|
16
|
+
review state: reviewers work in comment mode, so the GitHub state is always
|
|
17
|
+
COMMENTED and cannot express approval. The round-completion signal is the
|
|
18
|
+
reviewer's substantive-review count going up; the verdict word then comes from
|
|
19
|
+
`Alissa.latest_verdict` — the exact logic the daemon uses, so the two halves
|
|
20
|
+
cannot disagree.
|
|
21
|
+
|
|
22
|
+
The triage -> fix -> re-enter loop and the round cap live in the caller (see the
|
|
23
|
+
`run-the-review-loop` how-to): on `request_changes` the dev triages, fixes, and
|
|
24
|
+
runs this again for round k+1.
|
|
25
|
+
|
|
26
|
+
Exit codes: 0 approve · 1 request_changes · 2 timeout/no verdict · 3 usage/setup
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
from .alissa import Alissa, VERDICT_APPROVE, VERDICT_REQUEST_CHANGES
|
|
38
|
+
from .ghclient import GitHub
|
|
39
|
+
from .proc import CommandError, run, run_json
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# After a new review lands, the verdict envelope is written on the task around
|
|
44
|
+
# the same moment; give it a short grace window to appear before giving up.
|
|
45
|
+
_ENVELOPE_GRACE_SECONDS = 120
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_pr(branch: str | None) -> tuple[str, str, int, str, bool]:
|
|
49
|
+
"""(owner, repo, number, url, is_draft) for `branch` (or the current branch).
|
|
50
|
+
|
|
51
|
+
Runs in the repo working tree, so `gh` infers the repo; the head branch
|
|
52
|
+
selects the PR. owner/repo come from the PR URL, which is the base repo.
|
|
53
|
+
"""
|
|
54
|
+
argv = ["gh", "pr", "view"]
|
|
55
|
+
if branch:
|
|
56
|
+
argv.append(branch)
|
|
57
|
+
argv += ["--json", "url,number,isDraft,state"]
|
|
58
|
+
data = run_json(argv)
|
|
59
|
+
if not data:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"no pull request found for {'branch ' + branch if branch else 'the current branch'}"
|
|
62
|
+
)
|
|
63
|
+
url = data.get("url", "")
|
|
64
|
+
match = re.search(r"github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)", url)
|
|
65
|
+
if not match:
|
|
66
|
+
raise ValueError(f"could not parse owner/repo from PR url {url!r}")
|
|
67
|
+
if data.get("state") not in (None, "OPEN"):
|
|
68
|
+
raise ValueError(f"PR {url} is {data.get('state')}, not open — nothing to review")
|
|
69
|
+
return match.group(1), match.group(2), int(match.group(3)), url, bool(data.get("isDraft"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str | None:
|
|
73
|
+
"""The CR2 review task for this PR, by title, regardless of status.
|
|
74
|
+
|
|
75
|
+
Deliberately not `Alissa.find_review_task` (which filters to open tasks): we
|
|
76
|
+
still want the ref just after the task is validated, to read its verdict.
|
|
77
|
+
"""
|
|
78
|
+
pattern = re.compile(
|
|
79
|
+
rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
|
|
80
|
+
)
|
|
81
|
+
matches = [t for t in alissa.list_tasks() if pattern.match(t.title)]
|
|
82
|
+
return matches[0].ref if matches else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reviewer_review_count(gh: GitHub, owner: str, repo: str, number: int, reviewer: str) -> int:
|
|
86
|
+
"""Substantive reviews the reviewer has submitted — one per completed round.
|
|
87
|
+
|
|
88
|
+
Same 'substantive' rule the daemon counts by (empty-bodied inline-comment
|
|
89
|
+
artifacts don't close a round), so a fresh round is a clean +1 edge.
|
|
90
|
+
"""
|
|
91
|
+
return sum(
|
|
92
|
+
1
|
|
93
|
+
for r in gh.reviews(owner, repo, number)
|
|
94
|
+
if r.author == reviewer and r.is_substantive
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
99
|
+
p = argparse.ArgumentParser(
|
|
100
|
+
prog="alissa-pr-review",
|
|
101
|
+
description="Implementer-side: flip a PR ready, request a reviewer, and "
|
|
102
|
+
"block until the review verdict lands (one round of the adversarial loop).",
|
|
103
|
+
)
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"--reviewer",
|
|
106
|
+
required=True,
|
|
107
|
+
metavar="LOGIN",
|
|
108
|
+
help="GitHub login to request review from (e.g. alissa-app)",
|
|
109
|
+
)
|
|
110
|
+
p.add_argument(
|
|
111
|
+
"--branch",
|
|
112
|
+
metavar="NAME",
|
|
113
|
+
help="head branch of the PR (default: the current branch)",
|
|
114
|
+
)
|
|
115
|
+
p.add_argument(
|
|
116
|
+
"--timeout",
|
|
117
|
+
type=int,
|
|
118
|
+
default=2700,
|
|
119
|
+
metavar="SECONDS",
|
|
120
|
+
help="give up waiting after this long (default: 2700 = 45 min)",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"--poll-interval",
|
|
124
|
+
type=int,
|
|
125
|
+
default=30,
|
|
126
|
+
metavar="SECONDS",
|
|
127
|
+
help="seconds between checks (default: 30)",
|
|
128
|
+
)
|
|
129
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
130
|
+
return p
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
args = build_parser().parse_args(argv)
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
137
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
138
|
+
datefmt="%H:%M:%S",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
gh = GitHub()
|
|
142
|
+
alissa = Alissa()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Identity guard: GitHub forbids requesting review from the PR author, so
|
|
146
|
+
# the dev's gh token must not be the reviewer.
|
|
147
|
+
dev = gh.token_login()
|
|
148
|
+
if dev == args.reviewer:
|
|
149
|
+
print(
|
|
150
|
+
f"identity error: the gh token belongs to {dev!r}, the same as "
|
|
151
|
+
f"--reviewer. GitHub forbids requesting review from the PR author; "
|
|
152
|
+
f"run this as a different account than the reviewer.",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
return 3
|
|
156
|
+
|
|
157
|
+
owner, repo, number, url, is_draft = resolve_pr(args.branch)
|
|
158
|
+
slug = f"{owner}/{repo}#{number}"
|
|
159
|
+
log.info("PR %s (%s), reviewing as reviewer=%s, dev=%s", slug, url, args.reviewer, dev)
|
|
160
|
+
|
|
161
|
+
if is_draft:
|
|
162
|
+
log.info("flipping %s ready-for-review", slug)
|
|
163
|
+
run(["gh", "pr", "ready", str(number)])
|
|
164
|
+
|
|
165
|
+
before = _reviewer_review_count(gh, owner, repo, number, args.reviewer)
|
|
166
|
+
log.info("requesting review from %s (round %d)", args.reviewer, before + 1)
|
|
167
|
+
run(["gh", "pr", "edit", str(number), "--add-reviewer", args.reviewer])
|
|
168
|
+
|
|
169
|
+
deadline = time.time() + args.timeout
|
|
170
|
+
ref: str | None = None
|
|
171
|
+
round_landed_at: float | None = None
|
|
172
|
+
|
|
173
|
+
while time.time() < deadline:
|
|
174
|
+
time.sleep(args.poll_interval)
|
|
175
|
+
|
|
176
|
+
if round_landed_at is None:
|
|
177
|
+
now = _reviewer_review_count(gh, owner, repo, number, args.reviewer)
|
|
178
|
+
if now > before:
|
|
179
|
+
round_landed_at = time.time()
|
|
180
|
+
log.info("%s: a new review landed — reading the verdict", slug)
|
|
181
|
+
else:
|
|
182
|
+
log.debug("%s: still waiting (reviews=%d)", slug, now)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
ref = ref or _review_task_ref(alissa, owner, repo, number)
|
|
186
|
+
verdict = alissa.latest_verdict(ref) if ref else None
|
|
187
|
+
if verdict == VERDICT_APPROVE:
|
|
188
|
+
print(f"\n✔ APPROVE — {slug} converged.\n PR: {url}\n task: {ref}")
|
|
189
|
+
return 0
|
|
190
|
+
if verdict == VERDICT_REQUEST_CHANGES:
|
|
191
|
+
print(
|
|
192
|
+
f"\n✗ REQUEST_CHANGES — {slug}.\n PR: {url}\n task: {ref}\n"
|
|
193
|
+
f" Triage each finding on its PR thread, fix, then run this again "
|
|
194
|
+
f"for the next round."
|
|
195
|
+
)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
# Review landed but the envelope isn't readable yet — brief grace.
|
|
199
|
+
if round_landed_at and time.time() - round_landed_at > _ENVELOPE_GRACE_SECONDS:
|
|
200
|
+
print(
|
|
201
|
+
f"\n⚠ a review landed on {slug} but no verdict envelope was found "
|
|
202
|
+
f"on the review task {ref or '(not found)'} within "
|
|
203
|
+
f"{_ENVELOPE_GRACE_SECONDS}s.\n PR: {url}\n Read the PR review "
|
|
204
|
+
f"and the review task directly.",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
return 2
|
|
208
|
+
|
|
209
|
+
print(
|
|
210
|
+
f"\n⏱ timed out after {args.timeout}s waiting for a review on {slug} — no "
|
|
211
|
+
f"verdict yet.\n PR: {url}\n The reviewer daemon re-spawns a stalled "
|
|
212
|
+
f"reviewer at ~90 min; re-run this, or check the worker.",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return 2
|
|
216
|
+
|
|
217
|
+
except CommandError as exc:
|
|
218
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
219
|
+
return 3
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
222
|
+
return 3
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
return 130
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.0
|
|
@@ -9,6 +9,7 @@ src/main/alissa/tools/github/reviewloop/config.py
|
|
|
9
9
|
src/main/alissa/tools/github/reviewloop/ghclient.py
|
|
10
10
|
src/main/alissa/tools/github/reviewloop/loop.py
|
|
11
11
|
src/main/alissa/tools/github/reviewloop/proc.py
|
|
12
|
+
src/main/alissa/tools/github/reviewloop/prreview.py
|
|
12
13
|
src/main/alissa/tools/github/reviewloop/state.py
|
|
13
14
|
src/main/alissa/tools/github/reviewloop/version
|
|
14
15
|
src/main/alissa/tools/github/reviewloop/version.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.1.1
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.1.1 → alissa_tools_github_reviewloop-0.3.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
|