alissa-tools-github-revloop 0.15.0__tar.gz → 0.16.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_revloop-0.15.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/config.py +30 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/ghclient.py +45 -16
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/loop.py +466 -23
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/state.py +87 -2
- alissa_tools_github_revloop-0.16.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/sources.py +4 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.15.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.15.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -80,6 +80,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
80
80
|
metavar="OWNER/REPO",
|
|
81
81
|
help="only watch this repo; repeatable. Replaces the config list entirely.",
|
|
82
82
|
)
|
|
83
|
+
over.add_argument(
|
|
84
|
+
"--operator",
|
|
85
|
+
dest="operators",
|
|
86
|
+
action="append",
|
|
87
|
+
metavar="LOGIN",
|
|
88
|
+
help="GitHub login allowed to ack a review-loop re-entry "
|
|
89
|
+
"(`alissa-review: re-enter +N` on a capped PR); repeatable. Replaces "
|
|
90
|
+
"the config list entirely. Empty = no ack is ever honoured.",
|
|
91
|
+
)
|
|
83
92
|
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
84
93
|
over.add_argument("--round-cap", type=int, metavar="N", help="CR9 round cap")
|
|
85
94
|
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
@@ -114,6 +123,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
114
123
|
through. `repos` becomes a tuple so it matches the config-file form."""
|
|
115
124
|
return {
|
|
116
125
|
"repos": tuple(args.repos) if args.repos else None,
|
|
126
|
+
"operators": tuple(args.operators) if args.operators else None,
|
|
117
127
|
"poll_interval": args.poll_interval,
|
|
118
128
|
"round_cap": args.round_cap,
|
|
119
129
|
"hub_template": args.hub_template,
|
|
@@ -41,6 +41,7 @@ CONFIG_KEYS = (
|
|
|
41
41
|
"poll_interval",
|
|
42
42
|
"round_cap",
|
|
43
43
|
"repos",
|
|
44
|
+
"operators",
|
|
44
45
|
"agent_profile",
|
|
45
46
|
"reviewer_login",
|
|
46
47
|
"state_path",
|
|
@@ -68,6 +69,14 @@ class Config:
|
|
|
68
69
|
# Empty tuple means "every repo that requests a review from me".
|
|
69
70
|
repos: tuple[str, ...] = ()
|
|
70
71
|
|
|
72
|
+
# GitHub logins whose re-entry ack may raise a capped PR's effective cap
|
|
73
|
+
# (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
|
|
74
|
+
# honoured: the lever fails closed, because anyone who can comment on a PR
|
|
75
|
+
# could otherwise buy it more rounds. The reviewer identity itself is never
|
|
76
|
+
# an operator however it is configured (the daemon's own escalation quotes
|
|
77
|
+
# the grammar, and self-granting would defeat CR9's cap outright).
|
|
78
|
+
operators: tuple[str, ...] = ()
|
|
79
|
+
|
|
71
80
|
agent_profile: str = "claude"
|
|
72
81
|
reviewer_login: str | None = None # None -> resolve once via `gh api user`
|
|
73
82
|
|
|
@@ -148,7 +157,10 @@ class Config:
|
|
|
148
157
|
f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
|
|
149
158
|
)
|
|
150
159
|
|
|
151
|
-
repos =
|
|
160
|
+
repos = _string_list(raw.get("repos", ()), "repos", "owner/repo entries")
|
|
161
|
+
operators = _string_list(
|
|
162
|
+
raw.get("operators", ()), "operators", "GitHub logins"
|
|
163
|
+
)
|
|
152
164
|
if hub_mode == HUB_ADD and not repos:
|
|
153
165
|
# Anyone who can request a review could otherwise cause an arbitrary
|
|
154
166
|
# repo to be cloned onto this machine and opened as an agent's cwd.
|
|
@@ -175,6 +187,7 @@ class Config:
|
|
|
175
187
|
poll_interval=interval,
|
|
176
188
|
round_cap=cap,
|
|
177
189
|
repos=repos,
|
|
190
|
+
operators=operators,
|
|
178
191
|
agent_profile=raw.get("agent_profile", "claude"),
|
|
179
192
|
reviewer_login=raw.get("reviewer_login"),
|
|
180
193
|
state_path=Path(state_path).expanduser() if state_path else None,
|
|
@@ -184,6 +197,22 @@ class Config:
|
|
|
184
197
|
)
|
|
185
198
|
|
|
186
199
|
|
|
200
|
+
def _string_list(value: Any, key: str, what: str) -> tuple[str, ...]:
|
|
201
|
+
"""A config list key as a tuple of non-empty, stripped strings.
|
|
202
|
+
|
|
203
|
+
The guard is the point: JSON makes `"repos": "org/repo"` an easy typo, and
|
|
204
|
+
Python would iterate it into single CHARACTERS -- an allowlist of 30-odd
|
|
205
|
+
one-character names, which `watches()` then matches against nothing and the
|
|
206
|
+
daemon quietly reviews no PR at all. Both list keys go through here so
|
|
207
|
+
neither can grow the footgun back.
|
|
208
|
+
"""
|
|
209
|
+
if isinstance(value, str):
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"{key} must be a list of {what}, not a string (got {value!r})"
|
|
212
|
+
)
|
|
213
|
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
214
|
+
|
|
215
|
+
|
|
187
216
|
def load_config_file(path: Path) -> dict[str, Any]:
|
|
188
217
|
data = json.loads(Path(path).expanduser().read_text())
|
|
189
218
|
if not isinstance(data, dict):
|
|
@@ -17,6 +17,19 @@ log = logging.getLogger(__name__)
|
|
|
17
17
|
# was never submitted, so it does not close a round.
|
|
18
18
|
SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
19
19
|
|
|
20
|
+
# GitHub's page size ceiling, and how many pages of issue comments we will walk.
|
|
21
|
+
# Both readers of the comment list need the WHOLE thread, and a truncated read
|
|
22
|
+
# fails silently in each: the activity-comment finder would stop seeing its own
|
|
23
|
+
# comment and fork a second one, and the re-entry ack scan would drop an
|
|
24
|
+
# operator's ack on the floor -- on exactly the long-lived, capped-out PRs where
|
|
25
|
+
# the thread is longest and the ack matters most. Bounded anyway so a
|
|
26
|
+
# pathological thread cannot stall a poll pass; 20 pages is 2000 comments, and
|
|
27
|
+
# the truncation is logged rather than assumed away. Paged explicitly rather
|
|
28
|
+
# than with `gh api --paginate`: this targets gh 2.4.0, whose --paginate
|
|
29
|
+
# concatenates one JSON document per page and would not parse.
|
|
30
|
+
PER_PAGE = 100
|
|
31
|
+
COMMENT_PAGE_LIMIT = 20
|
|
32
|
+
|
|
20
33
|
|
|
21
34
|
@dataclass(frozen=True)
|
|
22
35
|
class PullRequest:
|
|
@@ -234,24 +247,40 @@ class GitHub:
|
|
|
234
247
|
)
|
|
235
248
|
|
|
236
249
|
def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
"""Every issue comment on the PR, oldest first -- see COMMENT_PAGE_LIMIT
|
|
251
|
+
for why this pages instead of reading the first 100 and hoping."""
|
|
252
|
+
out: list[IssueComment] = []
|
|
253
|
+
for page in range(1, COMMENT_PAGE_LIMIT + 1):
|
|
254
|
+
data = (
|
|
255
|
+
self._api(
|
|
256
|
+
"-X",
|
|
257
|
+
"GET",
|
|
258
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
259
|
+
"-f",
|
|
260
|
+
f"per_page={PER_PAGE}",
|
|
261
|
+
"-f",
|
|
262
|
+
f"page={page}",
|
|
263
|
+
)
|
|
264
|
+
or []
|
|
244
265
|
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
266
|
+
out.extend(
|
|
267
|
+
IssueComment(
|
|
268
|
+
id=int(c.get("id") or 0),
|
|
269
|
+
author=(c.get("user") or {}).get("login", ""),
|
|
270
|
+
body=c.get("body") or "",
|
|
271
|
+
)
|
|
272
|
+
for c in data
|
|
252
273
|
)
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
if len(data) < PER_PAGE:
|
|
275
|
+
break
|
|
276
|
+
else:
|
|
277
|
+
log.warning(
|
|
278
|
+
"%s/%s#%d has more than %d comments — only the first %d were "
|
|
279
|
+
"read; an operator re-entry ack past that point cannot be seen",
|
|
280
|
+
owner, repo, number, COMMENT_PAGE_LIMIT * PER_PAGE,
|
|
281
|
+
COMMENT_PAGE_LIMIT * PER_PAGE,
|
|
282
|
+
)
|
|
283
|
+
return out
|
|
255
284
|
|
|
256
285
|
def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
|
257
286
|
self._api(
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import logging
|
|
14
14
|
import re
|
|
15
15
|
import secrets
|
|
16
|
+
import sqlite3
|
|
16
17
|
import time
|
|
17
18
|
from collections import Counter
|
|
18
19
|
from dataclasses import dataclass
|
|
@@ -21,7 +22,7 @@ from pathlib import Path
|
|
|
21
22
|
|
|
22
23
|
from .alissa import VERDICT_APPROVE, Alissa, Task
|
|
23
24
|
from .config import HUB_ADD, ON_MISSING_SKIP, Config
|
|
24
|
-
from .ghclient import GitHub, PullRequest, RateLimited, Review
|
|
25
|
+
from .ghclient import GitHub, IssueComment, PullRequest, RateLimited, Review
|
|
25
26
|
from .proc import CommandError
|
|
26
27
|
from .state import State
|
|
27
28
|
|
|
@@ -79,12 +80,27 @@ _RELEASE_SLOT = (
|
|
|
79
80
|
"`alissa tmux kill {session}`. Do nothing after it."
|
|
80
81
|
)
|
|
81
82
|
|
|
83
|
+
# The cap the reviewer must write down. The review-task description template in
|
|
84
|
+
# the alissa-code-review skill documents a default of 3, which this daemon's
|
|
85
|
+
# config has not used since the cap moved to 10 -- so round 1 (the round that
|
|
86
|
+
# CREATES the review task) has to carry the effective number, or every task
|
|
87
|
+
# description keeps repeating the stale one. Both directives say it: a round-k
|
|
88
|
+
# reviewer reading a task that records the wrong cap is the case that surfaced
|
|
89
|
+
# the drift in the first place.
|
|
90
|
+
_RECORD_THE_CAP = (
|
|
91
|
+
"This loop's EFFECTIVE round cap is {cap} (the daemon's config, plus any "
|
|
92
|
+
"operator re-entry grants) — record THAT number in the review task "
|
|
93
|
+
"description, and correct it if the description carries a different cap "
|
|
94
|
+
"from a stale template default. "
|
|
95
|
+
)
|
|
96
|
+
|
|
82
97
|
ROUND_1_DIRECTIVE = (
|
|
83
98
|
"You are a PR REVIEWER, not an implementer. {assignment} "
|
|
84
99
|
"Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
|
|
85
100
|
"hydrate the task and the PR it names, review per the rubric, post "
|
|
86
101
|
"severity-tagged comments via gh pr review, record the verdict evidence, "
|
|
87
102
|
"move the task to pending_validation. "
|
|
103
|
+
+ _RECORD_THE_CAP
|
|
88
104
|
+ _CLOSE_THE_ROUND +
|
|
89
105
|
"NEVER push commits, merge, or change PR state. "
|
|
90
106
|
"Do NOT create further ali-* sessions. "
|
|
@@ -98,19 +114,88 @@ ROUND_K_DIRECTIVE = (
|
|
|
98
114
|
"including its round-k section: verify the triage of every prior finding, "
|
|
99
115
|
"verify the fixes, sweep the new diff with the full rubric, record a "
|
|
100
116
|
"round-{round} verdict envelope, move the task to pending_validation. "
|
|
117
|
+
+ _RECORD_THE_CAP
|
|
101
118
|
+ _CLOSE_THE_ROUND +
|
|
102
119
|
"NEVER push commits, merge, or change PR state. "
|
|
103
120
|
"Do NOT create further ali-* sessions. "
|
|
104
121
|
+ _RELEASE_SLOT
|
|
105
122
|
)
|
|
106
123
|
|
|
124
|
+
# The operator re-entry ack (issue #42). A capped PR is unreviewable until an
|
|
125
|
+
# operator says otherwise, and the cap-out message used to OFFER "re-enter with
|
|
126
|
+
# a fresh cap" without any mechanism existing: the only lever was raising
|
|
127
|
+
# round_cap for every PR and restarting the daemon. The grammar below is that
|
|
128
|
+
# mechanism, and it is deliberately dull -- one line, a literal prefix, an
|
|
129
|
+
# explicit +N -- so it is trivially auditable in the PR's comment history and
|
|
130
|
+
# impossible to fire by accident.
|
|
131
|
+
REENTRY_GRAMMAR = "alissa-review: re-enter +N"
|
|
132
|
+
|
|
133
|
+
# The ceiling on a single ack. A constant, not a config key: the point of the
|
|
134
|
+
# ack is a SMALL bounded re-entry (the live case wanted exactly one round), and
|
|
135
|
+
# an operator who needs more says so again in another comment -- which leaves
|
|
136
|
+
# two auditable rows instead of one giant grant. Pinned by a test.
|
|
137
|
+
MAX_REENTRY_ROUNDS = 5
|
|
138
|
+
|
|
139
|
+
# "Is this line trying to be a directive?" -- the loose sieve. A line that
|
|
140
|
+
# reaches for the prefix but misses the grammar is reported as malformed
|
|
141
|
+
# (a silent typo would look exactly like a cap that refuses to lift).
|
|
142
|
+
_ACK_LEAD_RE = re.compile(r"^`?\s*alissa-review\s*:", re.IGNORECASE)
|
|
143
|
+
|
|
144
|
+
# The grammar itself: the WHOLE line, optionally wrapped in backticks, is the
|
|
145
|
+
# directive. Anchored on both ends so a mention inside prose ("just post
|
|
146
|
+
# `alissa-review: re-enter +1`") is not a directive, and quoted lines (`>`) are
|
|
147
|
+
# dropped before matching so replying to the escalation cannot re-grant it.
|
|
148
|
+
_ACK_RE = re.compile(
|
|
149
|
+
r"^`?\s*alissa-review:\s+re-enter\s+\+(\d{1,3})\s*`?$", re.IGNORECASE
|
|
150
|
+
)
|
|
151
|
+
|
|
107
152
|
ESCALATION_COMMENT = (
|
|
108
153
|
"**Review loop cap-out (CR9)** — {rounds} rounds ran on this PR without "
|
|
109
|
-
"converging on `approve
|
|
110
|
-
"
|
|
111
|
-
"
|
|
112
|
-
"
|
|
113
|
-
"
|
|
154
|
+
"converging on `approve` (effective cap {cap}).{grant_note} Per the "
|
|
155
|
+
"alissa-code-review skill the loop does not run past the cap and never "
|
|
156
|
+
"silently merges, so it stops here: this needs an operator decision.\n\n"
|
|
157
|
+
"{verdict_line}\n"
|
|
158
|
+
"{verification}"
|
|
159
|
+
"\n**Operator re-entry — grant N more rounds.** Comment with a line that "
|
|
160
|
+
"reads exactly:\n\n"
|
|
161
|
+
"```\n" + REENTRY_GRAMMAR + "\n```\n\n"
|
|
162
|
+
"…with `N` from 1 to {max_rounds}. It is honoured only from an allowlisted "
|
|
163
|
+
"operator account (the daemon's `operators` config), counted once per "
|
|
164
|
+
"comment — a further grant needs a further comment — logged, and appended "
|
|
165
|
+
"to the review-loop activity comment. Anything else is ignored: a quoted "
|
|
166
|
+
"line, another wording, a bigger N, any other author.\n\n"
|
|
167
|
+
"Without an ack no further round runs. The other options are unchanged: "
|
|
168
|
+
"merge with a recorded waiver, or park it."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# The last-verdict line. Two shapes, because one sha is not enough once the
|
|
172
|
+
# head can have moved: the page has to say what was judged AND what is sitting
|
|
173
|
+
# there now, or an operator reading "last verdict at <current head>" concludes
|
|
174
|
+
# the verdict already covers the pushed fixes -- the exact opposite of what the
|
|
175
|
+
# verification hint below is telling them.
|
|
176
|
+
VERDICT_LINE = "Last verdict: `{last_state}` at `{sha}`."
|
|
177
|
+
VERDICT_LINE_MOVED = (
|
|
178
|
+
"Last verdict: `{last_state}` on `{reviewed}` — the head is now `{sha}`."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Only when the head has moved past the head the last verdict was written
|
|
182
|
+
# against -- the PR #277 shape, where the fixes are already pushed and sitting
|
|
183
|
+
# unreviewed. That case wants exactly one round, and saying so at the moment
|
|
184
|
+
# the operator reads the page is the whole point of the lever being
|
|
185
|
+
# discoverable.
|
|
186
|
+
VERIFICATION_HINT = (
|
|
187
|
+
"\n**The head has moved since that verdict** (`{sha}`, reviewed at "
|
|
188
|
+
"`{reviewed}`) — fixes are already pushed and unreviewed, so one round is "
|
|
189
|
+
"usually all this needs: ack `alissa-review: re-enter +1` and the next "
|
|
190
|
+
"reviewer verifies the fix against its own final findings and flips to "
|
|
191
|
+
"approve (or re-requests, consuming the grant).\n"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Named in the fresh escalation that fires once a grant has been spent without
|
|
195
|
+
# an approve, so the page says which decision was already tried.
|
|
196
|
+
GRANT_CONSUMED_NOTE = (
|
|
197
|
+
" The re-entry granted by @{author} (`+{rounds}`, comment {comment_id}) "
|
|
198
|
+
"has been consumed without an approve."
|
|
114
199
|
)
|
|
115
200
|
|
|
116
201
|
STALLED_COMMENT = (
|
|
@@ -172,6 +257,106 @@ def stalled_kind(session: str) -> str:
|
|
|
172
257
|
return f"{ESCALATION_STALLED}:{session}"
|
|
173
258
|
|
|
174
259
|
|
|
260
|
+
def capout_kind(head_sha: str, granted: int) -> str:
|
|
261
|
+
"""The ping-ledger kind that dedupes ONE cap-out page.
|
|
262
|
+
|
|
263
|
+
`escalations` is keyed by head alone, which was the whole story while the
|
|
264
|
+
cap was global: same head, same decision, one page. A re-entry grant breaks
|
|
265
|
+
that -- rounds run on the operator's own authority and end without an
|
|
266
|
+
approve on the very SAME head, and that is a new decision, not a repeat of
|
|
267
|
+
the page the operator already answered. Folding the PR's granted total into
|
|
268
|
+
the kind says exactly that: one page per (head, grant total), so a consumed
|
|
269
|
+
grant pages once more and then goes quiet until the next ack. Deliberately
|
|
270
|
+
not a timestamp comparison against the escalation row -- ordering by
|
|
271
|
+
wall-clock seconds ties when two events land in the same second, and the
|
|
272
|
+
tie fails in the loud direction (a page every poll).
|
|
273
|
+
"""
|
|
274
|
+
return f"capout:{head_sha}:{granted}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _now() -> str:
|
|
278
|
+
"""The activity comment's timestamp format (UTC, seconds)."""
|
|
279
|
+
return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def grant_activity_kind(comment_id: int) -> str:
|
|
283
|
+
"""The ping-ledger kind that dedupes ONE grant's activity line.
|
|
284
|
+
|
|
285
|
+
The grant itself is recorded the moment it is honoured (the effective cap
|
|
286
|
+
must not depend on a comment API call succeeding), but the activity line is
|
|
287
|
+
a separate best-effort append -- so the ledger row lands only AFTER the
|
|
288
|
+
append does, and a failed append is retried on the next poll instead of
|
|
289
|
+
losing the audit line the grant is supposed to leave.
|
|
290
|
+
"""
|
|
291
|
+
return f"activity-grant:{comment_id}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass(frozen=True)
|
|
295
|
+
class Ack:
|
|
296
|
+
"""One comment read against the re-entry grammar.
|
|
297
|
+
|
|
298
|
+
Three outcomes, deliberately distinguishable: not a directive at all
|
|
299
|
+
(`rounds` and `problem` both None -- an ordinary comment, no log), a
|
|
300
|
+
directive that cannot be honoured (`problem` set -- ignored WITH a log
|
|
301
|
+
line, because a silent typo is indistinguishable from a cap that refuses
|
|
302
|
+
to lift), or a good ack (`rounds` set).
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
rounds: int | None = None
|
|
306
|
+
problem: str | None = None
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def is_directive(self) -> bool:
|
|
310
|
+
return self.rounds is not None or self.problem is not None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_reentry_ack(body: str) -> Ack:
|
|
314
|
+
"""Parse a comment body for the operator re-entry directive.
|
|
315
|
+
|
|
316
|
+
The grammar is one whole line, `alissa-review: re-enter +N`, optionally
|
|
317
|
+
wrapped in backticks. Everything else about the comment is ignored, so an
|
|
318
|
+
operator may explain themselves above or below the line -- but the line
|
|
319
|
+
itself has to be exact, and a quoted (`>`) line never counts, which is what
|
|
320
|
+
stops a reply that quotes the escalation from re-granting it.
|
|
321
|
+
|
|
322
|
+
Two or more directives disagreeing in one comment is a refusal, not a
|
|
323
|
+
guess: "acks are counted, never inferred". Repeating the SAME `+N` is fine
|
|
324
|
+
(it is still one grant, and one comment is one grant however many times it
|
|
325
|
+
says so).
|
|
326
|
+
"""
|
|
327
|
+
lead = False
|
|
328
|
+
found: set[int] = set()
|
|
329
|
+
for raw in (body or "").splitlines():
|
|
330
|
+
line = raw.strip()
|
|
331
|
+
if line.startswith(">"): # a quoted escalation is not a directive
|
|
332
|
+
continue
|
|
333
|
+
if not _ACK_LEAD_RE.match(line):
|
|
334
|
+
continue
|
|
335
|
+
lead = True
|
|
336
|
+
match = _ACK_RE.match(line)
|
|
337
|
+
if match:
|
|
338
|
+
found.add(int(match.group(1)))
|
|
339
|
+
|
|
340
|
+
if not found:
|
|
341
|
+
if lead:
|
|
342
|
+
return Ack(
|
|
343
|
+
problem=f"malformed re-entry directive (expected `{REENTRY_GRAMMAR}`)"
|
|
344
|
+
)
|
|
345
|
+
return Ack()
|
|
346
|
+
if len(found) > 1:
|
|
347
|
+
return Ack(
|
|
348
|
+
problem="contradictory re-entry directives in one comment "
|
|
349
|
+
f"(+{', +'.join(str(n) for n in sorted(found))})"
|
|
350
|
+
)
|
|
351
|
+
rounds = found.pop()
|
|
352
|
+
if not 1 <= rounds <= MAX_REENTRY_ROUNDS:
|
|
353
|
+
return Ack(
|
|
354
|
+
problem=f"+{rounds} is outside the re-entry ceiling "
|
|
355
|
+
f"(+1 to +{MAX_REENTRY_ROUNDS})"
|
|
356
|
+
)
|
|
357
|
+
return Ack(rounds=rounds)
|
|
358
|
+
|
|
359
|
+
|
|
175
360
|
class Action(str, Enum):
|
|
176
361
|
SPAWNED = "spawned"
|
|
177
362
|
IN_FLIGHT = "in-flight"
|
|
@@ -227,6 +412,9 @@ class ReviewWatcher:
|
|
|
227
412
|
self.github = github or GitHub(config.reviewer_login)
|
|
228
413
|
self.alissa = alissa or Alissa()
|
|
229
414
|
self.state = state or State(config.state_db)
|
|
415
|
+
# (repo, number, comment id) of every re-entry directive already
|
|
416
|
+
# refused in this process -- see _log_ignored_ack.
|
|
417
|
+
self._ignored_acks: set[tuple[str, int, int]] = set()
|
|
230
418
|
|
|
231
419
|
# -- per-PR decision ---------------------------------------------------
|
|
232
420
|
|
|
@@ -267,11 +455,26 @@ class ReviewWatcher:
|
|
|
267
455
|
if converged is not None:
|
|
268
456
|
return Decision(Action.CONVERGED, converged, completed)
|
|
269
457
|
|
|
270
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
458
|
+
# The effective cap is the configured one plus every re-entry an
|
|
459
|
+
# operator has explicitly acked on THIS PR (issue #42). The sum is a
|
|
460
|
+
# local read, so it costs nothing on the common path; the GitHub scan
|
|
461
|
+
# that can DISCOVER a new ack runs only when the loop would otherwise
|
|
462
|
+
# be capped -- exactly the state an ack exists to unstick, and the only
|
|
463
|
+
# one where an extra comments fetch per poll is worth paying for.
|
|
464
|
+
granted = self.state.granted_rounds(pr.full_name, number)
|
|
465
|
+
if completed >= self.config.round_cap + granted:
|
|
466
|
+
granted = self._collect_acks(pr, granted)
|
|
467
|
+
self._announce_grants(pr)
|
|
468
|
+
cap = self.config.round_cap + granted
|
|
469
|
+
|
|
470
|
+
# CR9: never queue round cap+1 -- where "cap" is now the effective one.
|
|
471
|
+
# No ack, no rounds: with no grant this is byte-for-byte the old
|
|
472
|
+
# behaviour.
|
|
473
|
+
if completed >= cap:
|
|
474
|
+
if not self._escalation_owed(pr, granted):
|
|
273
475
|
return Decision(Action.CAPPED, "already escalated", completed)
|
|
274
|
-
self.
|
|
476
|
+
grant = self.state.newest_grant(pr.full_name, number)
|
|
477
|
+
self._escalate(pr, my_reviews, completed, cap, grant)
|
|
275
478
|
return Decision(Action.ESCALATED, f"{completed} rounds, no approve", completed)
|
|
276
479
|
|
|
277
480
|
round_ = completed + 1
|
|
@@ -280,7 +483,7 @@ class ReviewWatcher:
|
|
|
280
483
|
if age is not None and age < STALE_ROUND_SECONDS:
|
|
281
484
|
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
282
485
|
if age is not None:
|
|
283
|
-
deferred = self._defer_stale_round(pr, round_, age)
|
|
486
|
+
deferred = self._defer_stale_round(pr, round_, age, cap)
|
|
284
487
|
if deferred is not None:
|
|
285
488
|
return deferred
|
|
286
489
|
log.warning(
|
|
@@ -292,9 +495,188 @@ class ReviewWatcher:
|
|
|
292
495
|
age / 60,
|
|
293
496
|
)
|
|
294
497
|
|
|
295
|
-
return self._spawn(pr, round_, task, reenqueued=age is not None)
|
|
498
|
+
return self._spawn(pr, round_, task, cap, reenqueued=age is not None)
|
|
499
|
+
|
|
500
|
+
# -- operator re-entry -------------------------------------------------
|
|
501
|
+
|
|
502
|
+
def _is_operator(self, author: str) -> bool:
|
|
503
|
+
"""Whether an ack from this author may be honoured.
|
|
504
|
+
|
|
505
|
+
Allowlist membership, case-insensitively (GitHub logins are), minus the
|
|
506
|
+
reviewer identity itself: the daemon's own escalation comment carries
|
|
507
|
+
the grammar, so an identity that could ack its own page would be able
|
|
508
|
+
to lift CR9's cap without a human ever touching it.
|
|
509
|
+
|
|
510
|
+
The PR author is deliberately NOT excluded the same way. Refusing them
|
|
511
|
+
outright would block the ordinary case -- an operator who opened the PR
|
|
512
|
+
by hand -- and the protection that matters is structural rather than
|
|
513
|
+
per-identity: the allowlist is opt-in and empty by default, so putting
|
|
514
|
+
an AGENT identity on it (an implementer that can comment on its own PR
|
|
515
|
+
and has read the escalation) is an operator's deliberate choice, not
|
|
516
|
+
something the daemon does for them. The reviewer identity is different
|
|
517
|
+
in kind because it is not opt-in at all.
|
|
518
|
+
"""
|
|
519
|
+
if not author or author.lower() == (self.github.login or "").lower():
|
|
520
|
+
return False
|
|
521
|
+
return author.lower() in {o.lower() for o in self.config.operators}
|
|
522
|
+
|
|
523
|
+
def _collect_acks(self, pr: PullRequest, granted: int) -> int:
|
|
524
|
+
"""Scan the capped PR's comments for operator acks; record new grants.
|
|
525
|
+
|
|
526
|
+
Returns the PR's total granted rounds. Every honoured ack is recorded
|
|
527
|
+
under its comment id, so the same comment can never grant twice however
|
|
528
|
+
many polls read it, and the log/announce side effects fire once.
|
|
529
|
+
|
|
530
|
+
With no operator allowlist -- the default, and what the container bakes
|
|
531
|
+
on purpose -- NO ack can ever be honoured, so the scan does not run at
|
|
532
|
+
all: a capped PR keeps its review request pending and therefore stays
|
|
533
|
+
in the search result set indefinitely, which would make this a full
|
|
534
|
+
comment-thread fetch (up to COMMENT_PAGE_LIMIT paged requests, on
|
|
535
|
+
exactly the long threads paging exists for) every poll, forever, to
|
|
536
|
+
discard everything it found. The fail-closed default stays free.
|
|
537
|
+
|
|
538
|
+
Comment reads are best-effort: an unreadable comment list leaves the PR
|
|
539
|
+
capped for this pass (the conservative direction -- it can only ever
|
|
540
|
+
withhold rounds, never invent them) and the scan retries next poll.
|
|
541
|
+
"""
|
|
542
|
+
if not self.config.operators:
|
|
543
|
+
return granted
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
comments = self.github.issue_comments(pr.owner, pr.repo, pr.number)
|
|
547
|
+
except RateLimited:
|
|
548
|
+
# Not swallowed: run_forever's backoff is the whole response to a
|
|
549
|
+
# rate limit, and eating it here would keep the pass hammering.
|
|
550
|
+
raise
|
|
551
|
+
except Exception as exc:
|
|
552
|
+
log.warning(
|
|
553
|
+
"%s is at its cap but its comments are unreadable (%s) — no "
|
|
554
|
+
"re-entry ack can be honoured this pass; retrying next poll",
|
|
555
|
+
pr.slug,
|
|
556
|
+
exc,
|
|
557
|
+
)
|
|
558
|
+
return granted
|
|
559
|
+
|
|
560
|
+
for comment in comments:
|
|
561
|
+
# Our own comments are never directives -- the escalation itself
|
|
562
|
+
# quotes the grammar.
|
|
563
|
+
if comment.author == self.github.login:
|
|
564
|
+
continue
|
|
565
|
+
ack = parse_reentry_ack(comment.body)
|
|
566
|
+
if not ack.is_directive:
|
|
567
|
+
continue
|
|
568
|
+
if not self._is_operator(comment.author):
|
|
569
|
+
self._log_ignored_ack(
|
|
570
|
+
pr, comment, f"{comment.author} is not an allowlisted operator"
|
|
571
|
+
)
|
|
572
|
+
continue
|
|
573
|
+
if ack.rounds is None:
|
|
574
|
+
self._log_ignored_ack(pr, comment, ack.problem or "malformed directive")
|
|
575
|
+
continue
|
|
576
|
+
if self.config.dry_run:
|
|
577
|
+
log.info(
|
|
578
|
+
"[dry-run] would honour re-entry ack +%d from %s on %s "
|
|
579
|
+
"(comment %d)",
|
|
580
|
+
ack.rounds, comment.author, pr.slug, comment.id,
|
|
581
|
+
)
|
|
582
|
+
granted += ack.rounds
|
|
583
|
+
continue
|
|
584
|
+
if not self.state.record_grant(
|
|
585
|
+
pr.full_name, pr.number, comment.id, comment.author, ack.rounds
|
|
586
|
+
):
|
|
587
|
+
continue # already counted in `granted`
|
|
588
|
+
granted += ack.rounds
|
|
589
|
+
log.warning(
|
|
590
|
+
"RE-ENTRY GRANT %s — operator %s acked +%d round(s) in comment "
|
|
591
|
+
"%d; effective cap %d → %d",
|
|
592
|
+
pr.slug,
|
|
593
|
+
comment.author,
|
|
594
|
+
ack.rounds,
|
|
595
|
+
comment.id,
|
|
596
|
+
self.config.round_cap + granted - ack.rounds,
|
|
597
|
+
self.config.round_cap + granted,
|
|
598
|
+
)
|
|
599
|
+
return granted
|
|
296
600
|
|
|
297
|
-
def
|
|
601
|
+
def _log_ignored_ack(
|
|
602
|
+
self, pr: PullRequest, comment: IssueComment, why: str
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Report an ack that will not be honoured — once per comment.
|
|
605
|
+
|
|
606
|
+
A capped PR is re-scanned every poll, so an unconditional warning would
|
|
607
|
+
repeat a line a minute for as long as the PR stays capped. The comment
|
|
608
|
+
id is the identity of the thing being refused, and an in-memory set is
|
|
609
|
+
the right scope for it: this is a log line, not a delivery guarantee,
|
|
610
|
+
so a daemon restart re-stating the refusal once is fine (and a ledger
|
|
611
|
+
row per typo is not).
|
|
612
|
+
"""
|
|
613
|
+
key = (pr.full_name, pr.number, comment.id)
|
|
614
|
+
if key in self._ignored_acks:
|
|
615
|
+
return
|
|
616
|
+
self._ignored_acks.add(key)
|
|
617
|
+
log.warning(
|
|
618
|
+
"ignoring re-entry directive on %s (comment %d by %s): %s — the PR "
|
|
619
|
+
"stays capped",
|
|
620
|
+
pr.slug,
|
|
621
|
+
comment.id,
|
|
622
|
+
comment.author,
|
|
623
|
+
why,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def _announce_grants(self, pr: PullRequest) -> None:
|
|
627
|
+
"""Append the activity line of every grant that has not had one yet.
|
|
628
|
+
|
|
629
|
+
Separate from recording the grant on purpose: the cap change is
|
|
630
|
+
authoritative state and must not hinge on a comment API call, while the
|
|
631
|
+
audit line must survive one failing -- so it is retried here on later
|
|
632
|
+
polls until it lands (see grant_activity_kind).
|
|
633
|
+
|
|
634
|
+
Walked OLDEST first (read_grants is newest-first, like its siblings)
|
|
635
|
+
with a running total, so each line reports its OWN transition rather
|
|
636
|
+
than the PR's total: two acks read in one pass -- or, more commonly, a
|
|
637
|
+
retried append landing beside a newer grant, which is the whole reason
|
|
638
|
+
this retries -- would otherwise both claim the same before/after cap
|
|
639
|
+
and go out in reverse order into an append-only log.
|
|
640
|
+
"""
|
|
641
|
+
running = self.config.round_cap
|
|
642
|
+
for row in reversed(self.state.read_grants(pr.full_name, pr.number)):
|
|
643
|
+
before, running = running, running + row["rounds"]
|
|
644
|
+
kind = grant_activity_kind(row["comment_id"])
|
|
645
|
+
if self.state.pinged(pr.full_name, pr.number, kind):
|
|
646
|
+
continue
|
|
647
|
+
line = (
|
|
648
|
+
f"- {_now()} — operator `{row['author']}` — re-entry ack "
|
|
649
|
+
f"(comment {row['comment_id']}) — +{row['rounds']} round(s) — "
|
|
650
|
+
f"effective cap {before} → {running}"
|
|
651
|
+
)
|
|
652
|
+
if self._append_activity(pr, line):
|
|
653
|
+
self.state.record_ping(pr.full_name, pr.number, kind)
|
|
654
|
+
|
|
655
|
+
def _escalation_owed(self, pr: PullRequest, granted: int) -> bool:
|
|
656
|
+
"""Whether a cap-out page is owed, or already delivered.
|
|
657
|
+
|
|
658
|
+
Two ways a page is owed: this head has never been paged (a fresh
|
|
659
|
+
cap-out, or the implementer pushed since -- the decision is about the
|
|
660
|
+
new state), or rounds granted by an operator ack have since been
|
|
661
|
+
consumed without an approve, which is a new decision on an unmoved head
|
|
662
|
+
(capout_kind). Everything else is the same page the operator already
|
|
663
|
+
has, and CR9's escalation stays once-only.
|
|
664
|
+
|
|
665
|
+
A PR with no grant takes the first branch alone, so the pre-#42
|
|
666
|
+
behaviour -- and any state.db written by it -- is untouched: no
|
|
667
|
+
already-escalated PR re-pages just because the daemon was upgraded.
|
|
668
|
+
"""
|
|
669
|
+
if not self.state.escalated(pr.full_name, pr.number, pr.head_sha):
|
|
670
|
+
return True
|
|
671
|
+
if granted == 0:
|
|
672
|
+
return False
|
|
673
|
+
return not self.state.pinged(
|
|
674
|
+
pr.full_name, pr.number, capout_kind(pr.head_sha, granted)
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
def _defer_stale_round(
|
|
678
|
+
self, pr: PullRequest, round_: int, age: float, cap: int
|
|
679
|
+
) -> Decision | None:
|
|
298
680
|
"""The liveness signal under the stale timer: a deferral, or None to
|
|
299
681
|
respawn.
|
|
300
682
|
|
|
@@ -358,7 +740,7 @@ class ReviewWatcher:
|
|
|
358
740
|
appended = self._append_activity(
|
|
359
741
|
pr,
|
|
360
742
|
self._activity_line(
|
|
361
|
-
session, round_, f"deferred — session `{session}` still {life}"
|
|
743
|
+
session, round_, f"deferred — session `{session}` still {life}", cap
|
|
362
744
|
),
|
|
363
745
|
)
|
|
364
746
|
if appended:
|
|
@@ -562,7 +944,13 @@ class ReviewWatcher:
|
|
|
562
944
|
# -- actions -----------------------------------------------------------
|
|
563
945
|
|
|
564
946
|
def _spawn(
|
|
565
|
-
self,
|
|
947
|
+
self,
|
|
948
|
+
pr: PullRequest,
|
|
949
|
+
round_: int,
|
|
950
|
+
task: Task | None,
|
|
951
|
+
cap: int,
|
|
952
|
+
*,
|
|
953
|
+
reenqueued: bool = False,
|
|
566
954
|
) -> Decision:
|
|
567
955
|
if task is None:
|
|
568
956
|
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
@@ -585,7 +973,7 @@ class ReviewWatcher:
|
|
|
585
973
|
name = session_name(pr, round_)
|
|
586
974
|
template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
|
|
587
975
|
directive = template.format(
|
|
588
|
-
assignment=assignment, round=round_, cap=
|
|
976
|
+
assignment=assignment, round=round_, cap=cap, session=name
|
|
589
977
|
)
|
|
590
978
|
|
|
591
979
|
hub, problem = self._ensure_hub(pr)
|
|
@@ -616,7 +1004,7 @@ class ReviewWatcher:
|
|
|
616
1004
|
context = (
|
|
617
1005
|
"re-enqueued — previous session presumed dead" if reenqueued else "spawned"
|
|
618
1006
|
)
|
|
619
|
-
self._append_activity(pr, self._activity_line(name, round_, context))
|
|
1007
|
+
self._append_activity(pr, self._activity_line(name, round_, context, cap))
|
|
620
1008
|
|
|
621
1009
|
return Decision(
|
|
622
1010
|
Action.SPAWNED,
|
|
@@ -699,9 +1087,11 @@ class ReviewWatcher:
|
|
|
699
1087
|
|
|
700
1088
|
return warnings
|
|
701
1089
|
|
|
702
|
-
def _activity_line(self, session: str, round_: int, context: str) -> str:
|
|
703
|
-
|
|
704
|
-
|
|
1090
|
+
def _activity_line(self, session: str, round_: int, context: str, cap: int) -> str:
|
|
1091
|
+
"""One mechanical line. `cap` is the PR's EFFECTIVE cap (config plus
|
|
1092
|
+
granted re-entries) -- printing the config value would render a granted
|
|
1093
|
+
round 11 as "round 11 of 10"."""
|
|
1094
|
+
return f"- {_now()} — `{session}` — round {round_} of {cap} — {context}"
|
|
705
1095
|
|
|
706
1096
|
def _append_activity(self, pr: PullRequest, line: str) -> bool:
|
|
707
1097
|
"""Append one line to THE activity comment on the PR; True if it landed.
|
|
@@ -784,11 +1174,57 @@ class ReviewWatcher:
|
|
|
784
1174
|
return
|
|
785
1175
|
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
786
1176
|
|
|
787
|
-
def _escalate(
|
|
1177
|
+
def _escalate(
|
|
1178
|
+
self,
|
|
1179
|
+
pr: PullRequest,
|
|
1180
|
+
my_reviews: list[Review],
|
|
1181
|
+
rounds: int,
|
|
1182
|
+
cap: int,
|
|
1183
|
+
grant: sqlite3.Row | None = None,
|
|
1184
|
+
) -> None:
|
|
1185
|
+
"""Page the operator, and teach the lever that gets past the page.
|
|
1186
|
+
|
|
1187
|
+
The message carries the re-entry grammar because the cap-out is exactly
|
|
1188
|
+
the moment an operator needs it, and -- when the head has moved past
|
|
1189
|
+
the head the last verdict was written against -- the recommendation to
|
|
1190
|
+
grant a single verification round. A `grant` names the re-entry that
|
|
1191
|
+
was already spent, so a second page cannot read as a repeat of the
|
|
1192
|
+
first.
|
|
1193
|
+
"""
|
|
1194
|
+
last = my_reviews[-1] if my_reviews else None
|
|
1195
|
+
last_state = (last.state if last else "none").lower()
|
|
1196
|
+
reviewed = (last.commit_id if last else "") or ""
|
|
1197
|
+
moved = bool(reviewed) and reviewed != pr.head_sha
|
|
1198
|
+
verification = ""
|
|
1199
|
+
if moved:
|
|
1200
|
+
verification = VERIFICATION_HINT.format(
|
|
1201
|
+
sha=pr.head_sha[:8], reviewed=reviewed[:8]
|
|
1202
|
+
)
|
|
1203
|
+
template = VERDICT_LINE_MOVED if moved else VERDICT_LINE
|
|
1204
|
+
verdict_line = template.format(
|
|
1205
|
+
last_state=last_state, sha=pr.head_sha[:8], reviewed=reviewed[:8]
|
|
1206
|
+
)
|
|
1207
|
+
grant_note = ""
|
|
1208
|
+
if grant is not None:
|
|
1209
|
+
grant_note = GRANT_CONSUMED_NOTE.format(
|
|
1210
|
+
author=grant["author"],
|
|
1211
|
+
rounds=grant["rounds"],
|
|
1212
|
+
comment_id=grant["comment_id"],
|
|
1213
|
+
)
|
|
788
1214
|
body = ESCALATION_COMMENT.format(
|
|
789
|
-
rounds=rounds,
|
|
1215
|
+
rounds=rounds,
|
|
1216
|
+
cap=cap,
|
|
1217
|
+
grant_note=grant_note,
|
|
1218
|
+
verdict_line=verdict_line,
|
|
1219
|
+
verification=verification,
|
|
1220
|
+
max_rounds=MAX_REENTRY_ROUNDS,
|
|
1221
|
+
)
|
|
1222
|
+
log.error(
|
|
1223
|
+
"CAP-OUT %s after %d rounds (effective cap %d) — escalating to operator",
|
|
1224
|
+
pr.slug,
|
|
1225
|
+
rounds,
|
|
1226
|
+
cap,
|
|
790
1227
|
)
|
|
791
|
-
log.error("CAP-OUT %s after %d rounds — escalating to operator", pr.slug, rounds)
|
|
792
1228
|
|
|
793
1229
|
if self.config.dry_run:
|
|
794
1230
|
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
@@ -798,7 +1234,14 @@ class ReviewWatcher:
|
|
|
798
1234
|
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
799
1235
|
except CommandError as exc:
|
|
800
1236
|
log.error("could not post escalation comment on %s: %s", pr.slug, exc)
|
|
1237
|
+
# Recorded even when the comment failed: a cap-out is terminal state,
|
|
1238
|
+
# and re-paging it every poll because GitHub was briefly unavailable
|
|
1239
|
+
# would be worse than the one missed comment (the log line above and
|
|
1240
|
+
# the escalations row both survive it).
|
|
801
1241
|
self.state.record_escalation(pr.full_name, pr.number, pr.head_sha)
|
|
1242
|
+
self.state.record_ping(
|
|
1243
|
+
pr.full_name, pr.number, capout_kind(pr.head_sha, cap - self.config.round_cap)
|
|
1244
|
+
)
|
|
802
1245
|
|
|
803
1246
|
# -- polling -----------------------------------------------------------
|
|
804
1247
|
|
|
@@ -4,8 +4,9 @@ Deliberately thin: GitHub is the source of truth for how many rounds have run
|
|
|
4
4
|
(one submitted review per round). This table exists only to stop the daemon
|
|
5
5
|
double-spawning a reviewer while a round is still in flight, to map a live
|
|
6
6
|
session name back to the round it was spawned for (so the reap sweep can tell
|
|
7
|
-
a finished round's session from an in-flight one),
|
|
8
|
-
|
|
7
|
+
a finished round's session from an in-flight one), to remember that a cap-out
|
|
8
|
+
was already escalated, and to count the operator re-entry acks that raise a
|
|
9
|
+
single PR's effective cap. The ledger tolerates sessions dying or being
|
|
9
10
|
killed behind its back: a reap record is bookkeeping, never a precondition.
|
|
10
11
|
|
|
11
12
|
The `poll_snapshots` table is a different animal from the ledger above: it
|
|
@@ -67,6 +68,16 @@ CREATE TABLE IF NOT EXISTS reaps (
|
|
|
67
68
|
reaped_at INTEGER NOT NULL
|
|
68
69
|
);
|
|
69
70
|
|
|
71
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
72
|
+
repo TEXT NOT NULL,
|
|
73
|
+
number INTEGER NOT NULL,
|
|
74
|
+
comment_id INTEGER NOT NULL,
|
|
75
|
+
author TEXT NOT NULL,
|
|
76
|
+
rounds INTEGER NOT NULL,
|
|
77
|
+
granted_at INTEGER NOT NULL,
|
|
78
|
+
PRIMARY KEY (repo, number, comment_id)
|
|
79
|
+
);
|
|
80
|
+
|
|
70
81
|
CREATE TABLE IF NOT EXISTS pings (
|
|
71
82
|
repo TEXT NOT NULL,
|
|
72
83
|
number INTEGER NOT NULL,
|
|
@@ -269,6 +280,10 @@ class State:
|
|
|
269
280
|
self._db.commit()
|
|
270
281
|
|
|
271
282
|
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
283
|
+
"""Whether this head has been paged at all. Not the whole dedupe story
|
|
284
|
+
once re-entry grants exist -- a grant consumed without an approve is a
|
|
285
|
+
new decision on the SAME head, deduped through the ping ledger; see
|
|
286
|
+
loop._escalation_owed."""
|
|
272
287
|
row = self._db.execute(
|
|
273
288
|
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
274
289
|
(repo, number, head_sha),
|
|
@@ -283,6 +298,76 @@ class State:
|
|
|
283
298
|
)
|
|
284
299
|
self._db.commit()
|
|
285
300
|
|
|
301
|
+
# -- operator re-entry grants ------------------------------------------
|
|
302
|
+
#
|
|
303
|
+
# One row per HONOURED operator ack comment (issue #42): a capped PR is
|
|
304
|
+
# re-entered only by an explicit, allowlisted, bounded ack, and this table
|
|
305
|
+
# is what makes the grant *counted* rather than inferred. The comment id is
|
|
306
|
+
# the grant's identity, so the same ack is honoured exactly once however
|
|
307
|
+
# many polls read it, and a second grant genuinely requires a second
|
|
308
|
+
# comment. Rows are never pruned: the effective cap of a PR is
|
|
309
|
+
# `round_cap + sum(rounds)` over its grants, so dropping one would silently
|
|
310
|
+
# lower the cap and re-escalate a PR that was already re-entered.
|
|
311
|
+
|
|
312
|
+
def record_grant(
|
|
313
|
+
self, repo: str, number: int, comment_id: int, author: str, rounds: int
|
|
314
|
+
) -> bool:
|
|
315
|
+
"""Record one honoured ack. True when it is NEW (first sighting).
|
|
316
|
+
|
|
317
|
+
INSERT OR IGNORE keyed on the comment id: re-reading the same ack on
|
|
318
|
+
every poll must not re-grant its rounds, and the caller uses the return
|
|
319
|
+
value to log/announce the grant exactly once.
|
|
320
|
+
"""
|
|
321
|
+
cur = self._db.execute(
|
|
322
|
+
"INSERT OR IGNORE INTO grants "
|
|
323
|
+
"(repo, number, comment_id, author, rounds, granted_at) "
|
|
324
|
+
"VALUES (?,?,?,?,?,?)",
|
|
325
|
+
(repo, number, int(comment_id), author, int(rounds), int(time.time())),
|
|
326
|
+
)
|
|
327
|
+
self._db.commit()
|
|
328
|
+
return cur.rowcount > 0
|
|
329
|
+
|
|
330
|
+
def granted_rounds(self, repo: str, number: int) -> int:
|
|
331
|
+
"""Extra rounds this PR has been granted, summed over its acks."""
|
|
332
|
+
row = self._db.execute(
|
|
333
|
+
"SELECT COALESCE(SUM(rounds), 0) AS total FROM grants "
|
|
334
|
+
"WHERE repo=? AND number=?",
|
|
335
|
+
(repo, number),
|
|
336
|
+
).fetchone()
|
|
337
|
+
return int(row["total"]) if row is not None else 0
|
|
338
|
+
|
|
339
|
+
def newest_grant(self, repo: str, number: int) -> sqlite3.Row | None:
|
|
340
|
+
"""The most recently recorded ack for this PR, or None.
|
|
341
|
+
|
|
342
|
+
The escalation names it ("the re-entry granted by @x was consumed"),
|
|
343
|
+
and it is the newest ack by `granted_at`, so a second grant supersedes
|
|
344
|
+
the first in the page's wording. Nothing compares that timestamp with
|
|
345
|
+
the escalation row -- the once-only dedupe is the ping-ledger key; see
|
|
346
|
+
loop.capout_kind for why not a wall-clock comparison.
|
|
347
|
+
"""
|
|
348
|
+
return self._db.execute(
|
|
349
|
+
"SELECT * FROM grants WHERE repo=? AND number=? "
|
|
350
|
+
"ORDER BY granted_at DESC, rowid DESC LIMIT 1",
|
|
351
|
+
(repo, number),
|
|
352
|
+
).fetchone()
|
|
353
|
+
|
|
354
|
+
def read_grants(self, repo: str, number: int) -> list[dict]:
|
|
355
|
+
"""One PR's grant rows, newest first (like every reader here).
|
|
356
|
+
|
|
357
|
+
Deliberately narrow: no unfiltered form and no `limit` until something
|
|
358
|
+
needs them. The console does not read this table yet -- showing "this
|
|
359
|
+
PR was re-entered by @x" in the operator inbox wants rendering as well
|
|
360
|
+
as data, so it lands as its own change rather than as unused
|
|
361
|
+
parameters here.
|
|
362
|
+
"""
|
|
363
|
+
return self._read_rows(
|
|
364
|
+
"SELECT repo, number, comment_id, author, rounds, granted_at "
|
|
365
|
+
"FROM grants WHERE repo=? AND number=? "
|
|
366
|
+
"ORDER BY granted_at DESC, rowid DESC",
|
|
367
|
+
None,
|
|
368
|
+
(repo, number),
|
|
369
|
+
)
|
|
370
|
+
|
|
286
371
|
# -- poll snapshots (the console sidecar's exhaust buffer) -------------
|
|
287
372
|
|
|
288
373
|
def record_snapshot(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.0
|
|
@@ -414,6 +414,10 @@ class Sources:
|
|
|
414
414
|
return {
|
|
415
415
|
"workspace_root": str(c.workspace_root),
|
|
416
416
|
"repos": list(c.repos),
|
|
417
|
+
# Who may re-open a capped PR with a re-entry ack. Part of the
|
|
418
|
+
# operator's own picture: the escalation inbox below is where that
|
|
419
|
+
# lever gets used.
|
|
420
|
+
"operators": list(c.operators),
|
|
417
421
|
"hub_template": c.hub_template,
|
|
418
422
|
"poll_interval": c.poll_interval,
|
|
419
423
|
"round_cap": c.round_cap,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.15.0
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|