alissa-tools-github-revloop 0.14.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.
Files changed (30) hide show
  1. {alissa_tools_github_revloop-0.14.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
  3. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/config.py +30 -1
  4. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/ghclient.py +45 -16
  5. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/loop.py +466 -23
  6. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/state.py +87 -2
  7. alissa_tools_github_revloop-0.16.0/src/main/alissa/tools/github/revloop/version +1 -0
  8. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/sources.py +4 -0
  9. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  10. alissa_tools_github_revloop-0.14.0/src/main/alissa/tools/github/revloop/version +0 -1
  11. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/MANIFEST.in +0 -0
  12. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  18. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  19. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  20. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  21. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  22. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  23. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  24. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  25. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  26. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  27. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  28. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  29. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  30. {alissa_tools_github_revloop-0.14.0 → alissa_tools_github_revloop-0.16.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 = tuple(raw.get("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
- data = (
238
- self._api(
239
- "-X",
240
- "GET",
241
- f"repos/{owner}/{repo}/issues/{number}/comments",
242
- "-f",
243
- "per_page=100",
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
- or []
246
- )
247
- return [
248
- IssueComment(
249
- id=int(c.get("id") or 0),
250
- author=(c.get("user") or {}).get("login", ""),
251
- body=c.get("body") or "",
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
- for c in data
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`. Per the alissa-code-review skill the loop does not "
110
- "run past the cap and never silently merges; this needs an operator decision "
111
- "(merge with a recorded waiver, direct specific fixes and re-enter with a "
112
- "fresh cap, or park it).\n\n"
113
- "Last verdict: `{last_state}` at `{sha}`."
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
- # CR9: never queue round cap+1.
271
- if completed >= self.config.round_cap:
272
- if self.state.escalated(pr.full_name, number, pr.head_sha):
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._escalate(pr, my_reviews[-1].state if my_reviews else "none", completed)
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 _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
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, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
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=self.config.round_cap, session=name
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
- ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
704
- return f"- {ts} `{session}` round {round_} of {self.config.round_cap} — {context}"
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(self, pr: PullRequest, last_state: str, rounds: int) -> None:
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, last_state=last_state.lower(), sha=pr.head_sha[:8]
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), and to remember that a
8
- cap-out was already escalated. The ledger tolerates sessions dying or being
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(
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.14.0
3
+ Version: 0.16.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera