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.
Files changed (22) hide show
  1. {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
  2. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/alissa.py +86 -4
  3. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +31 -2
  4. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/loop.py +40 -9
  5. alissa_tools_github_reviewloop-0.2.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  6. {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
  7. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/version +0 -1
  8. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/MANIFEST.in +0 -0
  9. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/README.md +0 -0
  10. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/requirements.txt +0 -0
  11. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/setup.cfg +0 -0
  12. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/setup.py +0 -0
  13. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
  14. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
  15. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
  16. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
  17. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/state.py +0 -0
  18. {alissa_tools_github_reviewloop-0.1.0 → alissa_tools_github_reviewloop-0.2.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
  19. {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
  20. {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
  21. {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
  22. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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-<seq>
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
- seq = row.get("taskSeq")
44
- if seq is None:
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-{seq}",
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 completed round."""
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 and r.state in SUBMITTED_STATES
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), not from local bookkeeping.
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
- if my_reviews and my_reviews[-1].state == "APPROVED":
118
- return Decision(Action.CONVERGED, "last verdict is approve", completed)
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
- # -- actions -----------------------------------------------------------
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
- def _spawn(self, pr: PullRequest, round_: int) -> Decision:
146
- task = self.alissa.find_review_task(pr.owner, pr.repo, pr.number)
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(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-reviewloop
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera