alissa-tools-github-revloop 0.17.0__tar.gz → 0.19.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.17.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.19.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/__main__.py +36 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/alissa.py +244 -12
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/config.py +54 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/ghclient.py +232 -16
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/loop.py +141 -15
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/prreview.py +7 -1
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/state.py +166 -0
- alissa_tools_github_revloop-0.19.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.17.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.17.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -146,6 +146,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
146
146
|
"evidence; 0 queues immediately and relies on the directive alone",
|
|
147
147
|
)
|
|
148
148
|
|
|
149
|
+
over.add_argument(
|
|
150
|
+
"--review-task-miss-ttl-polls",
|
|
151
|
+
type=int,
|
|
152
|
+
metavar="N",
|
|
153
|
+
help="how many polls a PR with NO review task is taken on trust before "
|
|
154
|
+
"the task corpus is searched for one again; must be >= 1",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
scope = over.add_mutually_exclusive_group()
|
|
158
|
+
scope.add_argument(
|
|
159
|
+
"--task-list-self-scope",
|
|
160
|
+
dest="task_list_self_scope",
|
|
161
|
+
action="store_true",
|
|
162
|
+
default=None,
|
|
163
|
+
help="narrow `alissa task list` to this actor's own rows (--self), "
|
|
164
|
+
"dropping the sponsor's corpus. Only for deployments where EVERY "
|
|
165
|
+
"review task is created by this daemon's own sessions: a review task "
|
|
166
|
+
"the list cannot see is a round the daemon cannot count",
|
|
167
|
+
)
|
|
168
|
+
scope.add_argument(
|
|
169
|
+
"--no-task-list-self-scope",
|
|
170
|
+
dest="task_list_self_scope",
|
|
171
|
+
action="store_false",
|
|
172
|
+
help="list the sponsor-union corpus even if the config narrows it",
|
|
173
|
+
)
|
|
174
|
+
|
|
149
175
|
dry = over.add_mutually_exclusive_group()
|
|
150
176
|
dry.add_argument(
|
|
151
177
|
"--dry-run",
|
|
@@ -183,6 +209,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
183
209
|
"max_concurrent_sessions": args.max_concurrent_sessions,
|
|
184
210
|
"checks_wait_seconds": args.checks_wait_seconds,
|
|
185
211
|
"checks_spawn_wait_seconds": args.checks_spawn_wait_seconds,
|
|
212
|
+
"review_task_miss_ttl_polls": args.review_task_miss_ttl_polls,
|
|
213
|
+
"task_list_self_scope": args.task_list_self_scope,
|
|
186
214
|
"dry_run": args.dry_run,
|
|
187
215
|
}
|
|
188
216
|
|
|
@@ -226,6 +254,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
226
254
|
|
|
227
255
|
if args.pr:
|
|
228
256
|
owner, repo, number = parse_pr_ref(args.pr)
|
|
257
|
+
# This mode exists to tell "the search did not find it" apart from
|
|
258
|
+
# "the decision was no", so it must never be answered by the
|
|
259
|
+
# negative cache -- a suppressed pass would report "no review task"
|
|
260
|
+
# without looking, which is precisely the confusion the flag is for.
|
|
261
|
+
# Re-arming here (rather than plumbing a bypass through `evaluate`)
|
|
262
|
+
# keeps the poll path with exactly one way in, and costs the daemon
|
|
263
|
+
# one corpus fetch on a hand-run diagnostic.
|
|
264
|
+
watcher.state.forget_review_task_miss(f"{owner}/{repo}", number)
|
|
229
265
|
decision = watcher.evaluate(owner, repo, number)
|
|
230
266
|
print(f"\n{args.pr} → {decision.action.value}")
|
|
231
267
|
print(f" round: {decision.round}")
|
|
@@ -16,6 +16,94 @@ log = logging.getLogger(__name__)
|
|
|
16
16
|
# A review task is "open" while it can still receive a verdict.
|
|
17
17
|
OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
|
|
18
18
|
|
|
19
|
+
# -- narrowing the `alissa task list` call (issue #87) ------------------------
|
|
20
|
+
#
|
|
21
|
+
# `list_tasks` is the widest query this daemon issues -- no query string at all,
|
|
22
|
+
# the actor's entire non-terminal corpus, sponsor-union scoped -- and it was the
|
|
23
|
+
# single largest contributor to the Alissa deployment's #1 Database-I/O offender
|
|
24
|
+
# over 2026-08-12..16. Everything below is applied ONLY when the installed CLI
|
|
25
|
+
# advertises it (see Alissa.probe_task_list): an issue's claim about a flag is
|
|
26
|
+
# not evidence, and this daemon turns a non-zero `alissa` exit into a SKIPPED
|
|
27
|
+
# decision, so sending a flag the CLI does not have costs a review.
|
|
28
|
+
|
|
29
|
+
# Statuses a LIVE review task can hold. Deliberately OPEN_STATUSES itself and
|
|
30
|
+
# not a hand-written list: `is_review_task_for` already rejects every other
|
|
31
|
+
# status client-side, so filtering server-side on exactly this set cannot change
|
|
32
|
+
# which task the daemon resolves -- it only stops shipping the rows over the
|
|
33
|
+
# wire. Any status added to `is_open` is added here by construction.
|
|
34
|
+
TASK_LIST_STATUS_FLAG = "--status"
|
|
35
|
+
TASK_LIST_STATUS_FILTER = ",".join(sorted(OPEN_STATUSES))
|
|
36
|
+
|
|
37
|
+
# `--self` drops the SPONSOR's corpus and keeps only the calling actor's rows.
|
|
38
|
+
#
|
|
39
|
+
# NOT enabled by default, and the reason is measured rather than cautious. On
|
|
40
|
+
# the live fleet corpus (2026-08-16, 932 non-terminal rows) `--self` removes 36
|
|
41
|
+
# rows, 4% of the payload -- and 3 of the 371 `Review PR ...` tasks in it are
|
|
42
|
+
# among the rows it removes: they are owned by another actor, not by the agent
|
|
43
|
+
# actor whose sessions write the other 368. Review tasks are therefore
|
|
44
|
+
# PREDOMINANTLY actor-owned but not exclusively so, and a review task this call
|
|
45
|
+
# cannot see is a round the daemon cannot count. 4% of the wire is not worth
|
|
46
|
+
# that, so the flag is opt-in per deployment (`task_list_self_scope`).
|
|
47
|
+
TASK_LIST_SELF_FLAG = "--self"
|
|
48
|
+
|
|
49
|
+
# A lean projection of each row. The daemon keeps only taskNumber/title/status
|
|
50
|
+
# (see `_task_from_row`), so a digest view is pure saving with no semantics --
|
|
51
|
+
# which is why it is adopted whenever it exists and has no knob. It ships in the
|
|
52
|
+
# studio repo separately; until then the probe simply does not find it.
|
|
53
|
+
TASK_LIST_VIEW_FLAG = "--view"
|
|
54
|
+
TASK_LIST_DIGEST_VIEW = "digest"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class TaskListFlags:
|
|
59
|
+
"""What the installed `alissa task list` advertises in its own help.
|
|
60
|
+
|
|
61
|
+
All-False is both the "old CLI" answer and the "the probe could not run"
|
|
62
|
+
answer, and they are deliberately the same value: each means "make the call
|
|
63
|
+
the daemon has always made".
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
status: bool = False
|
|
67
|
+
self_scope: bool = False
|
|
68
|
+
digest: bool = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# How deeply an OPTION may be indented in a commander help listing. Commander
|
|
72
|
+
# puts option names in a fixed left column (two spaces) and wraps each
|
|
73
|
+
# description onto continuation lines indented to the DESCRIPTION column, which
|
|
74
|
+
# is much further right -- 22 in the CLI this daemon ships against. So a small
|
|
75
|
+
# bounded indent is what separates an option from a wrapped description, without
|
|
76
|
+
# depending on the exact description-column width, which varies with the longest
|
|
77
|
+
# option name in the listing.
|
|
78
|
+
#
|
|
79
|
+
# Anchoring merely to line START is not enough, and that was the round-1 finding
|
|
80
|
+
# on PR #88: a description that wraps such that its second line BEGINS with
|
|
81
|
+
# `--status` reads as an offer of `--status`, and 0.1.0's own help already wraps
|
|
82
|
+
# `--self`'s description onto its own line, so the shape is not hypothetical.
|
|
83
|
+
MAX_OPTION_INDENT = 3
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _advertises(helptext: str, flag: str) -> bool:
|
|
87
|
+
"""Whether `flag` appears as an OPTION in a CLI help listing.
|
|
88
|
+
|
|
89
|
+
Anchored to the option COLUMN (see MAX_OPTION_INDENT), allowing a short alias
|
|
90
|
+
in front as in `-h, --help`, so a flag merely NAMED in another option's
|
|
91
|
+
description is not read as an offer of that flag -- whether it is named
|
|
92
|
+
mid-line ("Pair with --include-shared" is in this very help text) or at the
|
|
93
|
+
start of a wrapped continuation line. The trailing guard rejects a longer
|
|
94
|
+
flag that merely starts with this one (`--self-only` is not `--self`).
|
|
95
|
+
|
|
96
|
+
The consequence of a false positive is bounded rather than silent -- the flag
|
|
97
|
+
is sent, `alissa` exits non-zero, and `list_tasks` retries plain -- but it
|
|
98
|
+
costs a whole-corpus fetch, which is the thing this module is here to stop
|
|
99
|
+
spending.
|
|
100
|
+
"""
|
|
101
|
+
return re.search(
|
|
102
|
+
rf"(?m)^[ \t]{{0,{MAX_OPTION_INDENT}}}(?:-\w,\s+)?{re.escape(flag)}(?![\w-])",
|
|
103
|
+
helptext,
|
|
104
|
+
) is not None
|
|
105
|
+
|
|
106
|
+
|
|
19
107
|
# CR6 verdict envelope outcomes.
|
|
20
108
|
VERDICT_APPROVE = "approve"
|
|
21
109
|
VERDICT_REQUEST_CHANGES = "request_changes"
|
|
@@ -213,19 +301,163 @@ class TaskDetail:
|
|
|
213
301
|
|
|
214
302
|
|
|
215
303
|
class Alissa:
|
|
216
|
-
def
|
|
217
|
-
"""
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
304
|
+
def __init__(self, *, task_list_self_scope: bool = False) -> None:
|
|
305
|
+
"""`task_list_self_scope` opts the list call into `--self`.
|
|
306
|
+
|
|
307
|
+
Default OFF, and that default is evidence, not caution -- see
|
|
308
|
+
TASK_LIST_SELF_FLAG. It is still a knob because ownership is a property
|
|
309
|
+
of a DEPLOYMENT (who creates its review tasks), not of this code, and an
|
|
310
|
+
operator who knows their review tasks are all actor-owned should be able
|
|
311
|
+
to say so.
|
|
312
|
+
"""
|
|
313
|
+
self._task_list_self_scope = bool(task_list_self_scope)
|
|
314
|
+
# The probe's answer, memoized for the process; None = not probed yet.
|
|
315
|
+
# A probe that FAILS is deliberately not memoized (see probe_task_list).
|
|
316
|
+
self._task_list_flags: "TaskListFlags | None" = None
|
|
317
|
+
# Set when a narrowed call has been disproved at RUNTIME -- the CLI
|
|
318
|
+
# advertised a flag whose call then failed or came back empty. From then
|
|
319
|
+
# on this process makes the plain call, because a list that answers
|
|
320
|
+
# wrongly is worse than a list that is large: `find_review_task` reads an
|
|
321
|
+
# empty corpus as "this PR has no review task".
|
|
322
|
+
self._task_list_narrowing_disabled = False
|
|
323
|
+
|
|
324
|
+
# -- the `alissa task list` narrowing probe -----------------------------
|
|
325
|
+
|
|
326
|
+
def probe_task_list(self) -> "TaskListFlags":
|
|
327
|
+
"""Which narrowing flags the INSTALLED `alissa task list` advertises.
|
|
328
|
+
|
|
329
|
+
Read off the CLI's own `--help`, which is local, tokenless and free.
|
|
330
|
+
The alternative -- send the flag and fall back when the call fails --
|
|
331
|
+
cannot tell an unknown flag from an auth hiccup, and this daemon turns a
|
|
332
|
+
non-zero `alissa` exit into a SKIPPED decision, so a mis-sent flag does
|
|
333
|
+
not cost a slower call, it costs a REVIEW.
|
|
334
|
+
|
|
335
|
+
Probed off the help OUTPUT rather than the exit status on purpose: this
|
|
336
|
+
CLI is commander-based and answers an unknown *subcommand* by printing
|
|
337
|
+
the parent help and exiting 0, so "it exited 0" reports every old CLI as
|
|
338
|
+
capable. (Flags are stricter than subcommands here, but the rule is the
|
|
339
|
+
same one and there is no reason to keep two.)
|
|
340
|
+
|
|
341
|
+
A probe that ANSWERS is memoized for the process -- the CLI cannot
|
|
342
|
+
change under a running daemon. A probe that FAILS is not: a transient
|
|
343
|
+
`alissa` failure then degrades one pass instead of pinning the daemon to
|
|
344
|
+
the widest call until someone restarts it.
|
|
345
|
+
"""
|
|
346
|
+
if self._task_list_flags is not None:
|
|
347
|
+
return self._task_list_flags
|
|
348
|
+
try:
|
|
349
|
+
helptext = run(["alissa", "task", "list", "--help"], timeout=20)
|
|
350
|
+
except CommandError as exc:
|
|
351
|
+
log.warning(
|
|
352
|
+
"could not probe `alissa task list --help` (%s) — this pass "
|
|
353
|
+
"lists tasks unnarrowed, as the daemon always did", exc,
|
|
354
|
+
)
|
|
355
|
+
return TaskListFlags()
|
|
356
|
+
except Exception: # pragma: no cover - defence in depth
|
|
357
|
+
log.exception("unexpected failure probing `alissa task list --help`")
|
|
358
|
+
return TaskListFlags()
|
|
359
|
+
|
|
360
|
+
flags = TaskListFlags(
|
|
361
|
+
status=_advertises(helptext, TASK_LIST_STATUS_FLAG),
|
|
362
|
+
self_scope=_advertises(helptext, TASK_LIST_SELF_FLAG),
|
|
363
|
+
digest=_advertises(helptext, TASK_LIST_VIEW_FLAG),
|
|
364
|
+
)
|
|
365
|
+
self._task_list_flags = flags
|
|
366
|
+
return flags
|
|
367
|
+
|
|
368
|
+
def task_list_argv(self, *, narrow_status: bool = True) -> list[str]:
|
|
369
|
+
"""The narrowest `alissa task list` this CLI actually supports.
|
|
370
|
+
|
|
371
|
+
Every addition is probe-gated, so an older CLI -- today's, which offers
|
|
372
|
+
none of them -- produces exactly the call the daemon has always made.
|
|
227
373
|
"""
|
|
228
|
-
|
|
374
|
+
argv = ["alissa", "task", "list", "--json"]
|
|
375
|
+
if self._task_list_narrowing_disabled:
|
|
376
|
+
return argv
|
|
377
|
+
flags = self.probe_task_list()
|
|
378
|
+
if flags.status and narrow_status:
|
|
379
|
+
argv += [TASK_LIST_STATUS_FLAG, TASK_LIST_STATUS_FILTER]
|
|
380
|
+
if flags.self_scope and self._task_list_self_scope:
|
|
381
|
+
argv.append(TASK_LIST_SELF_FLAG)
|
|
382
|
+
if flags.digest:
|
|
383
|
+
argv += [TASK_LIST_VIEW_FLAG, TASK_LIST_DIGEST_VIEW]
|
|
384
|
+
return argv
|
|
385
|
+
|
|
386
|
+
def list_tasks(self, *, narrow_status: bool = True) -> list[Task]:
|
|
387
|
+
"""This actor's live task corpus -- the expensive call.
|
|
388
|
+
|
|
389
|
+
`alissa task list` (CLI 0.1.0) exposed no server-side narrowing at all:
|
|
390
|
+
its only flags were `--json` and `--include-terminal`, and omitting the
|
|
391
|
+
latter -- already the default -- was the whole of the available
|
|
392
|
+
filtering. Newer CLIs offer more, so the call is now assembled from a
|
|
393
|
+
boot-time probe of the installed CLI's help (`task_list_argv`): a status
|
|
394
|
+
filter covering exactly the statuses a live review task can hold, a lean
|
|
395
|
+
`--view digest`, and `--self` when the deployment says its review tasks
|
|
396
|
+
are actor-owned. None of it is required; an absent flag is simply not
|
|
397
|
+
sent.
|
|
398
|
+
|
|
399
|
+
Narrowing is still the SECOND line of defence, not the first. Even a
|
|
400
|
+
perfectly narrowed call is the actor's whole review-task corpus, so the
|
|
401
|
+
daemon's job remains to call this RARELY: see loop._review_task (the
|
|
402
|
+
persisted PR -> task mapping), loop._pass_task_list (at most one fetch
|
|
403
|
+
per poll pass) and the negative cache behind them (state's
|
|
404
|
+
`review_task_misses`, which bounds the ONE case where none of those
|
|
405
|
+
help -- a PR that has no review task at all).
|
|
406
|
+
|
|
407
|
+
`narrow_status=False` is for callers that must see review tasks the
|
|
408
|
+
daemon's own `is_open` predicate would reject (prreview reads a task's
|
|
409
|
+
verdict envelope after the round is over). It suppresses only the status
|
|
410
|
+
filter; every other narrowing still applies.
|
|
411
|
+
|
|
412
|
+
A narrowed call that FAILS, or that answers with an empty corpus, is
|
|
413
|
+
retried once unnarrowed and turns the narrowing off for the rest of the
|
|
414
|
+
process. Both are how a CLI that advertises a flag its API does not
|
|
415
|
+
serve would present, and either would otherwise read as "this actor has
|
|
416
|
+
no review tasks" -- which is a skipped review, not a slower one.
|
|
417
|
+
"""
|
|
418
|
+
argv = self.task_list_argv(narrow_status=narrow_status)
|
|
419
|
+
plain = ["alissa", "task", "list", "--json"]
|
|
420
|
+
try:
|
|
421
|
+
data = run_json(argv, timeout=90) or []
|
|
422
|
+
except CommandError:
|
|
423
|
+
if argv == plain:
|
|
424
|
+
raise
|
|
425
|
+
log.warning(
|
|
426
|
+
"`%s` failed — retrying the plain task list and dropping the "
|
|
427
|
+
"narrowing for this process", " ".join(argv),
|
|
428
|
+
)
|
|
429
|
+
self._task_list_narrowing_disabled = True
|
|
430
|
+
data = run_json(plain, timeout=90) or []
|
|
431
|
+
|
|
432
|
+
tasks = self._tasks_from(data)
|
|
433
|
+
if tasks or argv == plain or self._task_list_narrowing_disabled:
|
|
434
|
+
# The third clause is the `except` branch above having already
|
|
435
|
+
# retried `plain`: without it, a retry that legitimately answers an
|
|
436
|
+
# EMPTY corpus falls into the cross-check below and calls `plain` a
|
|
437
|
+
# second time -- three subprocess calls where two happened, inside
|
|
438
|
+
# the change whose purpose is removing whole-corpus fetches -- and
|
|
439
|
+
# warns about a narrowed call that in fact errored and never
|
|
440
|
+
# answered (PR #88 round 1).
|
|
441
|
+
return tasks
|
|
442
|
+
|
|
443
|
+
# An empty answer from a narrowed call. A genuinely empty corpus is
|
|
444
|
+
# possible and costs one extra list; a filter the API does not serve
|
|
445
|
+
# would cost every review this actor owns.
|
|
446
|
+
log.warning(
|
|
447
|
+
"`%s` returned no tasks — retrying the plain task list to tell an "
|
|
448
|
+
"empty corpus from a filter this API does not serve", " ".join(argv),
|
|
449
|
+
)
|
|
450
|
+
tasks = self._tasks_from(run_json(plain, timeout=90) or [])
|
|
451
|
+
if tasks:
|
|
452
|
+
self._task_list_narrowing_disabled = True
|
|
453
|
+
log.warning(
|
|
454
|
+
"the plain task list returned %d task(s) — the narrowed call is "
|
|
455
|
+
"dropping rows, so this process stops narrowing", len(tasks),
|
|
456
|
+
)
|
|
457
|
+
return tasks
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _tasks_from(data: object) -> list[Task]:
|
|
229
461
|
tasks = []
|
|
230
462
|
for row in data if isinstance(data, list) else []:
|
|
231
463
|
task = _task_from_row(row)
|
|
@@ -132,6 +132,8 @@ CONFIG_KEYS = (
|
|
|
132
132
|
"max_concurrent_sessions",
|
|
133
133
|
"checks_wait_seconds",
|
|
134
134
|
"checks_spawn_wait_seconds",
|
|
135
|
+
"review_task_miss_ttl_polls",
|
|
136
|
+
"task_list_self_scope",
|
|
135
137
|
"dry_run",
|
|
136
138
|
)
|
|
137
139
|
|
|
@@ -223,6 +225,26 @@ DEFAULT_CHECKS_WAIT_SECONDS = 30 * 60
|
|
|
223
225
|
# still-running rollup, which is the directive-only posture.
|
|
224
226
|
DEFAULT_CHECKS_SPAWN_WAIT_SECONDS = 15 * 60
|
|
225
227
|
|
|
228
|
+
# How many polls a PR with NO review task may be taken on trust before the
|
|
229
|
+
# daemon searches the task corpus for one again (issue #87).
|
|
230
|
+
#
|
|
231
|
+
# The review-task cache can only remember an answer that EXISTS, so a PR with no
|
|
232
|
+
# review task -- a third-party PR, one whose task was validated or retitled --
|
|
233
|
+
# missed it on every pass and paid the widest read this daemon makes for the
|
|
234
|
+
# same answer every time: at a 60s poll, 1,440 full-corpus reads a day from one
|
|
235
|
+
# unmapped PR, forever.
|
|
236
|
+
#
|
|
237
|
+
# 10 is the trade, and the thing being traded is LATENCY, not correctness: a
|
|
238
|
+
# review task created while the window is open is picked up on the next search
|
|
239
|
+
# rather than the next poll, so the cost of the default is up to ten minutes
|
|
240
|
+
# before that PR's round 1 is queued -- against a 99% cut in the reads. Tune it
|
|
241
|
+
# down on a deployment where review tasks appear after their PR does and the
|
|
242
|
+
# wait is felt; tune it up on one where they are created up front.
|
|
243
|
+
#
|
|
244
|
+
# There is no "off" value: the floor is 1 (suppress one poll), because 0 would
|
|
245
|
+
# not disable a cache, it would write rows nothing ever reads.
|
|
246
|
+
DEFAULT_REVIEW_TASK_MISS_TTL_POLLS = 10
|
|
247
|
+
|
|
226
248
|
|
|
227
249
|
def default_state_path(workspace_root: Path) -> Path:
|
|
228
250
|
return Path(workspace_root) / ".revloop" / "state.db"
|
|
@@ -291,6 +313,20 @@ class Config:
|
|
|
291
313
|
# timer to configure.
|
|
292
314
|
checks_spawn_wait_seconds: int = DEFAULT_CHECKS_SPAWN_WAIT_SECONDS
|
|
293
315
|
|
|
316
|
+
# How many polls a PR with no review task is taken on trust before the
|
|
317
|
+
# corpus is searched again; see DEFAULT_REVIEW_TASK_MISS_TTL_POLLS. Floor 1
|
|
318
|
+
# -- there is no value that turns the negative cache off.
|
|
319
|
+
review_task_miss_ttl_polls: int = DEFAULT_REVIEW_TASK_MISS_TTL_POLLS
|
|
320
|
+
|
|
321
|
+
# Whether `alissa task list` may be narrowed to THIS actor's own rows
|
|
322
|
+
# (`--self`), dropping the sponsor's corpus. Off by default because on the
|
|
323
|
+
# live fleet a small minority of review tasks are NOT owned by the actor the
|
|
324
|
+
# daemon runs as, and a review task it cannot see is a round it cannot count
|
|
325
|
+
# -- see alissa.TASK_LIST_SELF_FLAG for the measurement. Turn it on only
|
|
326
|
+
# where every review task is created by this daemon's own sessions. Ignored
|
|
327
|
+
# when the installed CLI does not advertise the flag.
|
|
328
|
+
task_list_self_scope: bool = False
|
|
329
|
+
|
|
294
330
|
dry_run: bool = False
|
|
295
331
|
|
|
296
332
|
def __post_init__(self) -> None:
|
|
@@ -443,6 +479,22 @@ class Config:
|
|
|
443
479
|
f"checks_spawn_wait_seconds must be >= 0, got {spawn_wait}"
|
|
444
480
|
)
|
|
445
481
|
|
|
482
|
+
miss_ttl = int(
|
|
483
|
+
raw.get("review_task_miss_ttl_polls", cls.review_task_miss_ttl_polls)
|
|
484
|
+
)
|
|
485
|
+
if miss_ttl < 1:
|
|
486
|
+
# Refused rather than clamped: 0 reads as "turn the negative cache
|
|
487
|
+
# off", but the cache is what bounds the widest read this daemon
|
|
488
|
+
# makes, and a 0 would keep writing ledger rows nothing consults. An
|
|
489
|
+
# operator who means "search every poll" has said something the
|
|
490
|
+
# daemon cannot do, and should hear that at load rather than
|
|
491
|
+
# discover it in the I/O bill.
|
|
492
|
+
raise ValueError(
|
|
493
|
+
f"review_task_miss_ttl_polls must be >= 1 (it is a number of "
|
|
494
|
+
f"polls to suppress, and there is no value that disables the "
|
|
495
|
+
f"negative cache), got {miss_ttl}"
|
|
496
|
+
)
|
|
497
|
+
|
|
446
498
|
token_env = raw.get("reviewer_token_env")
|
|
447
499
|
if token_env is not None:
|
|
448
500
|
token_env = str(token_env).strip()
|
|
@@ -489,6 +541,8 @@ class Config:
|
|
|
489
541
|
max_concurrent_sessions=max_sessions,
|
|
490
542
|
checks_wait_seconds=checks_wait,
|
|
491
543
|
checks_spawn_wait_seconds=spawn_wait,
|
|
544
|
+
review_task_miss_ttl_polls=miss_ttl,
|
|
545
|
+
task_list_self_scope=bool(raw.get("task_list_self_scope", False)),
|
|
492
546
|
dry_run=bool(raw.get("dry_run", False)),
|
|
493
547
|
)
|
|
494
548
|
|
|
@@ -112,6 +112,20 @@ _STATUS_CONCLUSIONS = {
|
|
|
112
112
|
# rather than green -- a partial read cannot support an approve.
|
|
113
113
|
CHECK_RUN_PAGE_LIMIT = 5
|
|
114
114
|
|
|
115
|
+
# What a fallback-answered rollup says about itself, in the log line and in the
|
|
116
|
+
# `summary` that reaches a verdict body. The Actions API sees only contexts
|
|
117
|
+
# GitHub Actions produced -- a check run posted by a third-party check app is
|
|
118
|
+
# invisible to it, so a fallback rollup could call green a commit whose
|
|
119
|
+
# non-Actions check failed. Every repo this fleet reviews runs Actions-only CI,
|
|
120
|
+
# which is why the fallback is taken at all; the note is how an operator reading
|
|
121
|
+
# "approved on green" can tell that this is the narrower read.
|
|
122
|
+
ACTIONS_FALLBACK_NOTE = "via the Actions API — check-runs forbidden for this credential"
|
|
123
|
+
|
|
124
|
+
# The workflow-run states that mean "this run has finished". Read exactly like a
|
|
125
|
+
# check run's `status`: anything else is still going, whatever conclusion the
|
|
126
|
+
# payload carries.
|
|
127
|
+
_RUN_COMPLETED = "completed"
|
|
128
|
+
|
|
115
129
|
# The hidden marker the daemon stamps into every native verdict review it
|
|
116
130
|
# submits, carrying the round it closes. Two jobs, both load-bearing:
|
|
117
131
|
#
|
|
@@ -251,17 +265,31 @@ class CheckRollup:
|
|
|
251
265
|
# from GitHub and reaches loop._abandon_verdict, which proves absence from
|
|
252
266
|
# the PR's commit list rather than trusting the message.
|
|
253
267
|
unreadable: str = ""
|
|
268
|
+
# True when the contexts came from the Actions API instead of the check-runs
|
|
269
|
+
# rollup, because the credential cannot read checks. That read is NARROWER
|
|
270
|
+
# than the one it stands in for (see ACTIONS_FALLBACK_NOTE), so the answer
|
|
271
|
+
# carries which path produced it everywhere the rollup is reported.
|
|
272
|
+
via_actions_fallback: bool = False
|
|
254
273
|
|
|
255
274
|
@property
|
|
256
275
|
def summary(self) -> str:
|
|
257
|
-
"""One log-line description of the rollup.
|
|
276
|
+
"""One log-line description of the rollup.
|
|
277
|
+
|
|
278
|
+
The fallback marker rides on EVERY state, not just green: an operator
|
|
279
|
+
reading "approved on green" has to be able to see which read path
|
|
280
|
+
answered, and so does one reading a request_changes that named a job.
|
|
281
|
+
"""
|
|
258
282
|
if self.state == CHECKS_RED:
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
283
|
+
base = f"red — failing: {check_names(self.failing)}"
|
|
284
|
+
elif self.state == CHECKS_PENDING:
|
|
285
|
+
base = f"pending — still running: {check_names(self.running)}"
|
|
286
|
+
elif self.state == CHECKS_UNKNOWN:
|
|
287
|
+
base = f"unreadable — {self.unreadable or 'no reason recorded'}"
|
|
288
|
+
else:
|
|
289
|
+
base = f"green — {self.total} context(s), none failing or running"
|
|
290
|
+
if self.via_actions_fallback:
|
|
291
|
+
return f"{base} [{ACTIONS_FALLBACK_NOTE}]"
|
|
292
|
+
return base
|
|
265
293
|
|
|
266
294
|
|
|
267
295
|
def check_names(contexts: "tuple[CheckContext, ...]") -> str:
|
|
@@ -269,7 +297,37 @@ def check_names(contexts: "tuple[CheckContext, ...]") -> str:
|
|
|
269
297
|
return ", ".join(c.name for c in contexts) or "none"
|
|
270
298
|
|
|
271
299
|
|
|
272
|
-
def
|
|
300
|
+
def _completed_conclusion(payload: dict) -> str:
|
|
301
|
+
"""A check run's / workflow job's conclusion, EMPTY unless it finished.
|
|
302
|
+
|
|
303
|
+
The one discriminator both read paths share: a payload that is not
|
|
304
|
+
`completed` carries no conclusion here whatever it says, so "queued",
|
|
305
|
+
"in_progress", "waiting" and whatever GitHub adds next all read as running
|
|
306
|
+
-- exactly the states a gate must not mistake for a verdict.
|
|
307
|
+
"""
|
|
308
|
+
completed = str(payload.get("status") or "").lower() == _RUN_COMPLETED
|
|
309
|
+
return str(payload.get("conclusion") or "").lower() if completed else ""
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _run_order(run_: dict) -> "tuple[int, int]":
|
|
313
|
+
"""How recent a workflow run is, among runs of the same workflow.
|
|
314
|
+
|
|
315
|
+
`run_number` is the workflow's own monotonic counter and `id` breaks its
|
|
316
|
+
ties. A payload missing either sorts LAST (-1), so a malformed entry can
|
|
317
|
+
never displace a run that identifies itself.
|
|
318
|
+
"""
|
|
319
|
+
def as_int(value: object) -> int:
|
|
320
|
+
try:
|
|
321
|
+
return int(str(value))
|
|
322
|
+
except (TypeError, ValueError):
|
|
323
|
+
return -1
|
|
324
|
+
|
|
325
|
+
return (as_int(run_.get("run_number")), as_int(run_.get("id")))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def rollup_of(
|
|
329
|
+
contexts: "list[CheckContext]", via_actions_fallback: bool = False
|
|
330
|
+
) -> CheckRollup:
|
|
273
331
|
"""Reduce read contexts to a rollup state.
|
|
274
332
|
|
|
275
333
|
Precedence is failure over running, deliberately: a commit with one job
|
|
@@ -289,7 +347,11 @@ def rollup_of(contexts: "list[CheckContext]") -> CheckRollup:
|
|
|
289
347
|
else:
|
|
290
348
|
state = CHECKS_GREEN
|
|
291
349
|
return CheckRollup(
|
|
292
|
-
state=state,
|
|
350
|
+
state=state,
|
|
351
|
+
failing=failing,
|
|
352
|
+
running=running,
|
|
353
|
+
total=len(contexts),
|
|
354
|
+
via_actions_fallback=via_actions_fallback,
|
|
293
355
|
)
|
|
294
356
|
|
|
295
357
|
|
|
@@ -354,6 +416,26 @@ class ReviewerTokenUnset(RuntimeError):
|
|
|
354
416
|
"""`reviewer_token_env` names a variable that is absent or empty."""
|
|
355
417
|
|
|
356
418
|
|
|
419
|
+
def _is_authorization_forbidden(exc: CommandError) -> bool:
|
|
420
|
+
"""Is this a 403 about PERMISSION rather than about throttling?
|
|
421
|
+
|
|
422
|
+
GitHub answers a secondary rate limit with 403 too, and the two want
|
|
423
|
+
opposite responses: throttling is waited out, a missing permission never
|
|
424
|
+
resolves itself. `_api(forbidden_is_rate_limit=False)` already raises
|
|
425
|
+
RateLimited for anything carrying an explicit throttling marker, so today
|
|
426
|
+
every CommandError reaching a caller with a 403 in it is an authorization
|
|
427
|
+
fact. The markers are re-checked anyway rather than inherited from that
|
|
428
|
+
contract, because what this answer decides is whether to answer the gate
|
|
429
|
+
from a NARROWER source -- and doing that for a condition that clears itself
|
|
430
|
+
in seconds is the wrong trade in the one component whose job is to fail
|
|
431
|
+
closed.
|
|
432
|
+
"""
|
|
433
|
+
blob = (exc.stderr or "").lower()
|
|
434
|
+
if any(marker in blob for marker in RATE_LIMIT_MARKERS):
|
|
435
|
+
return False
|
|
436
|
+
return "403" in blob
|
|
437
|
+
|
|
438
|
+
|
|
357
439
|
class TruncatedListing(RuntimeError):
|
|
358
440
|
"""A paged listing exceeded its page bound, so absence cannot be inferred."""
|
|
359
441
|
|
|
@@ -659,13 +741,37 @@ class GitHub:
|
|
|
659
741
|
is dropped rather than read as "something is running" -- otherwise every
|
|
660
742
|
approve on every Actions-only repo would hold until the wait bound.
|
|
661
743
|
|
|
744
|
+
A check-runs read that answers an authorization 403 does NOT stop here.
|
|
745
|
+
A fine-grained PAT cannot hold GitHub's `Checks` permission at all, so
|
|
746
|
+
on such a deployment that 403 is permanent and CHECKS_UNKNOWN would mean
|
|
747
|
+
no round can ever approve, on any repo, forever. The same credential's
|
|
748
|
+
`Actions: Read` answers for the same commit, so the rollup is read
|
|
749
|
+
through `_actions_contexts` instead and the answer is LABELLED as the
|
|
750
|
+
narrower read it is. Everything else about the failure handling is
|
|
751
|
+
unchanged: a 403 from the Actions API too, a TruncatedListing from
|
|
752
|
+
either listing, or any other error is still CHECKS_UNKNOWN, and
|
|
753
|
+
RateLimited still propagates.
|
|
754
|
+
|
|
662
755
|
Never raises except RateLimited (which run_forever's backoff owns): an
|
|
663
756
|
unreadable rollup is a CHECKS_UNKNOWN answer the caller can hold on, not
|
|
664
757
|
a reason to abort a poll pass that has other PRs to decide.
|
|
665
758
|
"""
|
|
666
759
|
contexts: list[CheckContext] = []
|
|
760
|
+
fallback = False
|
|
667
761
|
try:
|
|
668
|
-
|
|
762
|
+
try:
|
|
763
|
+
contexts.extend(self._check_runs(owner, repo, sha))
|
|
764
|
+
except CommandError as exc:
|
|
765
|
+
if not _is_authorization_forbidden(exc):
|
|
766
|
+
raise
|
|
767
|
+
log.warning(
|
|
768
|
+
"%s/%s %s: check-runs is forbidden for this credential — "
|
|
769
|
+
"reading CI %s instead (%s)",
|
|
770
|
+
owner, repo, sha[:8], ACTIONS_FALLBACK_NOTE,
|
|
771
|
+
str(exc)[:200],
|
|
772
|
+
)
|
|
773
|
+
contexts.extend(self._actions_contexts(owner, repo, sha))
|
|
774
|
+
fallback = True
|
|
669
775
|
contexts.extend(self._commit_statuses(owner, repo, sha))
|
|
670
776
|
except RateLimited:
|
|
671
777
|
raise
|
|
@@ -675,9 +781,11 @@ class GitHub:
|
|
|
675
781
|
return CheckRollup(
|
|
676
782
|
CHECKS_UNKNOWN, unreadable=f"{type(exc).__name__}: {exc}"[:300]
|
|
677
783
|
)
|
|
678
|
-
return rollup_of(contexts)
|
|
784
|
+
return rollup_of(contexts, via_actions_fallback=fallback)
|
|
679
785
|
|
|
680
|
-
def _rollup_listing(
|
|
786
|
+
def _rollup_listing(
|
|
787
|
+
self, path: str, key: str, what: str, params: "tuple[str, ...]" = ()
|
|
788
|
+
) -> list[dict]:
|
|
681
789
|
"""Page ONE rollup listing to completion, or refuse to answer.
|
|
682
790
|
|
|
683
791
|
Completeness is decided by two signals, and the pair is the point --
|
|
@@ -701,11 +809,17 @@ class GitHub:
|
|
|
701
809
|
30 successes and called a red head green -- and the direction has to be
|
|
702
810
|
"cannot answer", never "nothing failing in what I got".
|
|
703
811
|
|
|
812
|
+
`params` are extra `-f key=value` query fields for endpoints that need
|
|
813
|
+
one (`actions/runs` is per-commit only via `head_sha=`), carried through
|
|
814
|
+
every page so the filter cannot silently drop off the second one.
|
|
815
|
+
|
|
704
816
|
`forbidden_is_rate_limit=False` for the same reason `submit_review`
|
|
705
817
|
passes it: a 403 here is an authorization fact about the deployment (a
|
|
706
818
|
credential without `checks: read`) with its own handling one layer up,
|
|
707
819
|
and collapsing it into RateLimited would abort the whole poll pass and
|
|
708
820
|
double run_forever's backoff instead of degrading this one verdict.
|
|
821
|
+
That handling is now `check_rollup`'s Actions fallback, which is why the
|
|
822
|
+
CommandError has to arrive intact rather than as RateLimited.
|
|
709
823
|
"""
|
|
710
824
|
out: list[dict] = []
|
|
711
825
|
total: int | None = None
|
|
@@ -719,6 +833,7 @@ class GitHub:
|
|
|
719
833
|
f"per_page={PER_PAGE}",
|
|
720
834
|
"-f",
|
|
721
835
|
f"page={page}",
|
|
836
|
+
*[arg for param in params for arg in ("-f", param)],
|
|
722
837
|
forbidden_is_rate_limit=False,
|
|
723
838
|
)
|
|
724
839
|
or {}
|
|
@@ -754,18 +869,119 @@ class GitHub:
|
|
|
754
869
|
)
|
|
755
870
|
out: list[CheckContext] = []
|
|
756
871
|
for run_ in runs:
|
|
757
|
-
completed = str(run_.get("status") or "").lower() == "completed"
|
|
758
872
|
out.append(
|
|
759
873
|
CheckContext(
|
|
760
874
|
name=str(run_.get("name") or "unnamed check"),
|
|
761
|
-
conclusion=(
|
|
762
|
-
str(run_.get("conclusion") or "").lower() if completed else ""
|
|
763
|
-
),
|
|
875
|
+
conclusion=_completed_conclusion(run_),
|
|
764
876
|
url=str(run_.get("html_url") or run_.get("details_url") or ""),
|
|
765
877
|
)
|
|
766
878
|
)
|
|
767
879
|
return out
|
|
768
880
|
|
|
881
|
+
def _actions_contexts(self, owner: str, repo: str, sha: str) -> list[CheckContext]:
|
|
882
|
+
"""The commit's CI as GitHub ACTIONS sees it -- the fallback read.
|
|
883
|
+
|
|
884
|
+
Taken only when the check-runs rollup answered an authorization 403.
|
|
885
|
+
GitHub does not offer the `Checks` permission on fine-grained PATs at
|
|
886
|
+
all (community discussion 129512), so on a fine-grained-PAT deployment
|
|
887
|
+
that read is not "sometimes unavailable", it is a permanent dead end:
|
|
888
|
+
`check_rollup` degrades to CHECKS_UNKNOWN forever and no round can ever
|
|
889
|
+
approve. The same credential's `Actions: Read` answers 200 for the same
|
|
890
|
+
commit's CI, one layer lower down -- workflow runs, and each run's jobs.
|
|
891
|
+
|
|
892
|
+
The jobs, not the runs, are the contexts: a job maps one-to-one onto the
|
|
893
|
+
check run GitHub would have published for it (same name, same status,
|
|
894
|
+
same conclusion), so `rollup_of` judges the fallback answer under
|
|
895
|
+
exactly the rules it judges the real one under, including `skipped` and
|
|
896
|
+
`neutral` passing for a path-filtered matrix.
|
|
897
|
+
|
|
898
|
+
What this read CANNOT see is any check run posted by a third-party check
|
|
899
|
+
app; see ACTIONS_FALLBACK_NOTE for why that is accepted here and how the
|
|
900
|
+
answer says so.
|
|
901
|
+
|
|
902
|
+
COST: this path costs one call for the run listing plus one per kept
|
|
903
|
+
workflow, where the read it replaces cost one. It is taken only where
|
|
904
|
+
the alternative is a permanent CHECKS_UNKNOWN, and only on the rollup
|
|
905
|
+
reads the caller already decided to make -- the gate's existing
|
|
906
|
+
per-round caching is what bounds how often that is. On this fleet's
|
|
907
|
+
repos (one or two workflows per commit) it is two or three calls.
|
|
908
|
+
"""
|
|
909
|
+
runs = self._rollup_listing(
|
|
910
|
+
f"repos/{owner}/{repo}/actions/runs",
|
|
911
|
+
"workflow_runs",
|
|
912
|
+
"workflow runs",
|
|
913
|
+
params=(f"head_sha={sha}",),
|
|
914
|
+
)
|
|
915
|
+
out: list[CheckContext] = []
|
|
916
|
+
for run_ in self._latest_run_per_workflow(runs):
|
|
917
|
+
out.extend(self._run_contexts(owner, repo, run_))
|
|
918
|
+
return out
|
|
919
|
+
|
|
920
|
+
@staticmethod
|
|
921
|
+
def _latest_run_per_workflow(runs: list[dict]) -> list[dict]:
|
|
922
|
+
"""One run per workflow: the most recent.
|
|
923
|
+
|
|
924
|
+
A sha carries more than one run of the same workflow whenever it was
|
|
925
|
+
triggered more than once -- a re-run shares its run id, but a distinct
|
|
926
|
+
trigger event (a `push` and a `pull_request` on the same commit, a
|
|
927
|
+
workflow re-dispatched by hand) creates a distinct run. Reading all of
|
|
928
|
+
them would judge the commit on a superseded attempt: an earlier failed
|
|
929
|
+
run whose re-trigger passed would hold the head red forever.
|
|
930
|
+
|
|
931
|
+
Recency is `run_number` then `id`, both monotonic per workflow and both
|
|
932
|
+
integers -- deliberately not a timestamp string, which is the field a
|
|
933
|
+
re-run rewrites. A run with no `workflow_id` is not grouped with
|
|
934
|
+
anything (its position in the listing is its key), because collapsing
|
|
935
|
+
unidentified runs together would silently drop contexts.
|
|
936
|
+
"""
|
|
937
|
+
latest: dict[object, dict] = {}
|
|
938
|
+
for index, run_ in enumerate(runs):
|
|
939
|
+
workflow_id = run_.get("workflow_id")
|
|
940
|
+
key: object = (
|
|
941
|
+
("workflow", workflow_id)
|
|
942
|
+
if workflow_id is not None
|
|
943
|
+
else ("unidentified", index)
|
|
944
|
+
)
|
|
945
|
+
current = latest.get(key)
|
|
946
|
+
if current is None or _run_order(run_) >= _run_order(current):
|
|
947
|
+
latest[key] = run_
|
|
948
|
+
return list(latest.values())
|
|
949
|
+
|
|
950
|
+
def _run_contexts(self, owner: str, repo: str, run_: dict) -> list[CheckContext]:
|
|
951
|
+
"""One workflow run's jobs as contexts, or the run itself if it has none.
|
|
952
|
+
|
|
953
|
+
A run exposes no jobs while it is still queued, and the empty list must
|
|
954
|
+
never read as "nothing failing here". So a run with no readable jobs
|
|
955
|
+
contributes ITSELF as one context, under the same completed/conclusion
|
|
956
|
+
rule: not completed reads as running (the case the gate exists for), and
|
|
957
|
+
a completed run with no jobs is judged on its own conclusion rather than
|
|
958
|
+
vanishing from the rollup.
|
|
959
|
+
"""
|
|
960
|
+
run_id = run_.get("id")
|
|
961
|
+
run_name = str(run_.get("name") or run_.get("display_title") or "workflow run")
|
|
962
|
+
run_url = str(run_.get("html_url") or "")
|
|
963
|
+
jobs: list[dict] = []
|
|
964
|
+
if run_id is not None:
|
|
965
|
+
jobs = self._rollup_listing(
|
|
966
|
+
f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs", "jobs", "jobs"
|
|
967
|
+
)
|
|
968
|
+
if not jobs:
|
|
969
|
+
return [
|
|
970
|
+
CheckContext(
|
|
971
|
+
name=run_name,
|
|
972
|
+
conclusion=_completed_conclusion(run_),
|
|
973
|
+
url=run_url,
|
|
974
|
+
)
|
|
975
|
+
]
|
|
976
|
+
return [
|
|
977
|
+
CheckContext(
|
|
978
|
+
name=str(job.get("name") or "unnamed job"),
|
|
979
|
+
conclusion=_completed_conclusion(job),
|
|
980
|
+
url=str(job.get("html_url") or run_url),
|
|
981
|
+
)
|
|
982
|
+
for job in jobs
|
|
983
|
+
]
|
|
984
|
+
|
|
769
985
|
def _commit_statuses(self, owner: str, repo: str, sha: str) -> list[CheckContext]:
|
|
770
986
|
"""The commit's legacy statuses, one context each.
|
|
771
987
|
|
|
@@ -586,6 +586,25 @@ _CHECKS_BEFORE_VERDICT = (
|
|
|
586
586
|
"never conclude within that bound, do NOT approve either — request_changes, "
|
|
587
587
|
"saying plainly that the checks at that sha never settled; the next round can "
|
|
588
588
|
"approve the same code on a green head. "
|
|
589
|
+
"(2b) IF THE CHECK-RUNS READ IS FORBIDDEN — a `403 Resource not accessible "
|
|
590
|
+
"by personal access token` means this deployment's credential cannot read "
|
|
591
|
+
"check runs at all, NOT that the commit has no checks: never approve on it. "
|
|
592
|
+
"Read the same commit's CI through the Actions API instead, which the same "
|
|
593
|
+
"credential can read: `gh api "
|
|
594
|
+
"'repos/<org>/<repo>/actions/runs?head_sha=<sha>' --jq "
|
|
595
|
+
"'.workflow_runs[]|[.id,.name,.status,.conclusion,.html_url]|@tsv'`, keep "
|
|
596
|
+
"only the MOST RECENT run per workflow (one sha can carry several runs of "
|
|
597
|
+
"the same workflow, and a superseded attempt is not the answer), then for "
|
|
598
|
+
"each kept run `gh api repos/<org>/<repo>/actions/runs/<run_id>/jobs --jq "
|
|
599
|
+
"'.jobs[]|[.name,.status,.conclusion,.html_url]|@tsv'`. Judge those jobs "
|
|
600
|
+
"under exactly the rules above — concluded and none failed to approve, wait "
|
|
601
|
+
"and re-read within the same bound while any is still running, "
|
|
602
|
+
"request_changes naming the job and linking its run if one failed. A run "
|
|
603
|
+
"that is not `completed` and exposes no jobs yet is still running, never "
|
|
604
|
+
"green. Say in your verdict that the rollup was read through the Actions "
|
|
605
|
+
"API: it sees only Actions-produced contexts, so an operator has to be able "
|
|
606
|
+
"to tell that read from the full one. If the Actions read is forbidden too, "
|
|
607
|
+
"the rollup is unreadable — do not approve. "
|
|
589
608
|
)
|
|
590
609
|
|
|
591
610
|
# The floor under the wait the directive asks a session to observe. The bound
|
|
@@ -621,9 +640,24 @@ MIN_SESSION_CHECKS_WAIT_SECONDS = 5 * 60
|
|
|
621
640
|
# contexts x item, leaving the character cap as the backstop for a pathological
|
|
622
641
|
# item rather than the thing that decides how many jobs a reviewer hears about.
|
|
623
642
|
# 200 fits a GitHub Actions run URL (~105) plus a generous name and conclusion.
|
|
643
|
+
# The marker an item cut to fit carries. Defined here, next to the caps, because
|
|
644
|
+
# the derived budget below has to count it: it is part of what a full list of
|
|
645
|
+
# capped items actually measures (PR #85 round-3 nit).
|
|
646
|
+
DIRECTIVE_ITEM_TRUNCATED = "…"
|
|
647
|
+
|
|
624
648
|
MAX_DIRECTIVE_ITEM_CHARS = 200
|
|
625
649
|
MAX_DIRECTIVE_CONTEXTS = 10
|
|
626
|
-
|
|
650
|
+
# The widest list the COUNT cap can pass, exactly: every item at its cap, every
|
|
651
|
+
# one of them carrying the cut marker, joined by "; ". The naive
|
|
652
|
+
# contexts x item was 28 characters short of that, so ten items at the item cap
|
|
653
|
+
# tripped the character backstop and nine were kept -- the character budget
|
|
654
|
+
# deciding how many jobs a reviewer hears about, which is the inversion the
|
|
655
|
+
# round-2 major was about, surviving at an input no real rollup produces. Stated
|
|
656
|
+
# as arithmetic so it stays true if either cap is retuned.
|
|
657
|
+
MAX_DIRECTIVE_DATA_CHARS = (
|
|
658
|
+
MAX_DIRECTIVE_CONTEXTS * (MAX_DIRECTIVE_ITEM_CHARS + len(DIRECTIVE_ITEM_TRUNCATED))
|
|
659
|
+
+ (MAX_DIRECTIVE_CONTEXTS - 1) * len("; ")
|
|
660
|
+
)
|
|
627
661
|
|
|
628
662
|
# The fence around interpolated data. A lead-in alone says where the data starts
|
|
629
663
|
# and nothing says where it stops -- and in all three clauses the span is
|
|
@@ -642,7 +676,24 @@ DATA_CLOSE = "⟦end data⟧"
|
|
|
642
676
|
# (PR #85 round-2 minor, with a working repro). Newlines and control characters
|
|
643
677
|
# go for the same reason -- a fresh line reads as fresh prose -- and the fence's
|
|
644
678
|
# own brackets go so the END marker cannot be forged.
|
|
645
|
-
|
|
679
|
+
#
|
|
680
|
+
# The class is deliberately NOT ASCII-only, because the input is not: the first
|
|
681
|
+
# version stripped C0 and left U+0085, U+2028 and U+2029, every one of which
|
|
682
|
+
# renders as a line break, so the mitigation applied to one ENCODING of "start a
|
|
683
|
+
# fresh line" rather than to the effect (PR #85 round-3 minor). C1 goes with
|
|
684
|
+
# them, and so do the bidi controls -- U+202A-E and U+2066-9 reorder the rendered
|
|
685
|
+
# run, which is the same class of "what is displayed is not what the string
|
|
686
|
+
# says".
|
|
687
|
+
# Written as escapes, never as the characters themselves: every one of them
|
|
688
|
+
# is invisible or reorders its neighbours in an editor, which is precisely
|
|
689
|
+
# why they are stripped.
|
|
690
|
+
_DIRECTIVE_STRIP_RE = re.compile(
|
|
691
|
+
"[`\\x00-\\x1f\\x7f-\\x9f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069"
|
|
692
|
+
# The fence's BRACKETS only -- putting the whole markers in a character
|
|
693
|
+
# class would strip their letters out of every check name too.
|
|
694
|
+
+ re.escape("⟦⟧")
|
|
695
|
+
+ "]"
|
|
696
|
+
)
|
|
646
697
|
|
|
647
698
|
# Says out loud that what follows is data, and exactly where it ends. A session
|
|
648
699
|
# reading its directive has no other way to tell the daemon's instructions from a
|
|
@@ -711,12 +762,11 @@ CHECKS_AT_SPAWN_UNREADABLE = (
|
|
|
711
762
|
# quotes mean something. Inside ⟦data⟧ the fence is the boundary.
|
|
712
763
|
CHECKS_AT_SPAWN_FAILING = "{name} ({conclusion}){url}"
|
|
713
764
|
|
|
714
|
-
# What the list becomes once the count cap has bitten
|
|
715
|
-
# item
|
|
765
|
+
# What the list becomes once the count cap has bitten. Visible on purpose, like
|
|
766
|
+
# the per-item marker beside the caps above: every truncation in this module has
|
|
716
767
|
# to be readable in the directive, or a reviewer session cannot tell "these are
|
|
717
768
|
# the failing checks" from "these are some of them".
|
|
718
769
|
DIRECTIVE_DATA_TRUNCATED = " …(truncated: {dropped} more)"
|
|
719
|
-
DIRECTIVE_ITEM_TRUNCATED = "…"
|
|
720
770
|
|
|
721
771
|
|
|
722
772
|
def directive_text(value: str) -> str:
|
|
@@ -1350,7 +1400,9 @@ class ReviewWatcher:
|
|
|
1350
1400
|
self.github = github or GitHub(
|
|
1351
1401
|
config.reviewer_login, token_env=config.reviewer_token_env
|
|
1352
1402
|
)
|
|
1353
|
-
self.alissa = alissa or Alissa(
|
|
1403
|
+
self.alissa = alissa or Alissa(
|
|
1404
|
+
task_list_self_scope=config.task_list_self_scope
|
|
1405
|
+
)
|
|
1354
1406
|
self.state = state or State(config.state_db)
|
|
1355
1407
|
# (repo, number, comment id) of every re-entry directive already
|
|
1356
1408
|
# refused in this process -- see _log_ignored_ack.
|
|
@@ -1476,9 +1528,29 @@ class ReviewWatcher:
|
|
|
1476
1528
|
unreadable task is NOT a disproof, so the row survives a transient
|
|
1477
1529
|
CLI failure and the pass just degrades to the old behaviour.
|
|
1478
1530
|
|
|
1531
|
+
...and a FOURTH outcome that is none of those (issue #87): no cached ref
|
|
1532
|
+
and a recent search that already found nothing. The three above bound
|
|
1533
|
+
the cost of a PR whose review task EXISTS; a PR that has none missed
|
|
1534
|
+
every one of them on every pass and paid the corpus fetch for the same
|
|
1535
|
+
answer forever -- 1,440 whole-corpus reads a day, per unmapped PR, at a
|
|
1536
|
+
60s poll. So a completed search that finds nothing is recorded as such
|
|
1537
|
+
and taken on trust for `review_task_miss_ttl_polls` further polls.
|
|
1538
|
+
|
|
1539
|
+
That negative answer is consulted ONLY when there is no cached ref at
|
|
1540
|
+
all, which is what keeps it from ever competing with the three outcomes
|
|
1541
|
+
above: a cached ref that reads and matches never reaches it, a cached
|
|
1542
|
+
ref that is DISPROVED must re-search on the strength of that fresh
|
|
1543
|
+
disproof (and only records a miss if that search also comes up empty),
|
|
1544
|
+
and a cached ref that could not be READ must fall back to the search,
|
|
1545
|
+
because an unreadable task is not a wrong one.
|
|
1546
|
+
|
|
1479
1547
|
Fail-open is the whole contract: every degradation here lands on "do
|
|
1480
1548
|
what the daemon did before the cache existed", and none of them can
|
|
1481
|
-
answer "no review task" unless a successful search actually said so
|
|
1549
|
+
answer "no review task" unless a successful search actually said so --
|
|
1550
|
+
including the negative cache, which is written only from a search that
|
|
1551
|
+
RAN and returned nothing, and which suppresses a pass only when the
|
|
1552
|
+
ledger accepted the write that spends it (see
|
|
1553
|
+
State.consume_review_task_miss).
|
|
1482
1554
|
|
|
1483
1555
|
TWO consequences of resolving from cache, both accepted rather than
|
|
1484
1556
|
overlooked (PR #68 round 1):
|
|
@@ -1517,6 +1589,24 @@ class ReviewWatcher:
|
|
|
1517
1589
|
"an unreadable task is not a wrong one)",
|
|
1518
1590
|
pr.slug, cached,
|
|
1519
1591
|
)
|
|
1592
|
+
elif self._pass_tasks is None and self.state.consume_review_task_miss(
|
|
1593
|
+
pr.full_name, pr.number
|
|
1594
|
+
):
|
|
1595
|
+
# The negative answer is worth having only while the corpus has NOT
|
|
1596
|
+
# been fetched this pass. Once the memo is populated -- some earlier
|
|
1597
|
+
# PR of this pass re-armed and paid for it -- answering from the
|
|
1598
|
+
# ledger saves nothing at all (the search is a list comprehension
|
|
1599
|
+
# over rows already in memory) and still spends the window, so a
|
|
1600
|
+
# review task created since this PR's last search would stay
|
|
1601
|
+
# invisible for up to `review_task_miss_ttl_polls` more polls with
|
|
1602
|
+
# the evidence for it sitting right there. Order-dependent within a
|
|
1603
|
+
# pass, and therefore a partial win -- but never a read added, and
|
|
1604
|
+
# sometimes a whole window of latency removed (PR #88 round 1).
|
|
1605
|
+
log.debug(
|
|
1606
|
+
"%s: a recent search found no review task and the answer has "
|
|
1607
|
+
"polls left — skipping the corpus fetch this pass", pr.slug,
|
|
1608
|
+
)
|
|
1609
|
+
return ResolvedTask(task=None)
|
|
1520
1610
|
|
|
1521
1611
|
task = self.alissa.find_review_task(
|
|
1522
1612
|
pr.owner, pr.repo, pr.number, tasks=self._pass_task_list()
|
|
@@ -1526,6 +1616,29 @@ class ReviewWatcher:
|
|
|
1526
1616
|
# no open review task for this PR, whatever the cache said.
|
|
1527
1617
|
if cached is not None:
|
|
1528
1618
|
self.state.forget_review_task(pr.full_name, pr.number)
|
|
1619
|
+
# ...and it is the ONLY thing this daemon may record a negative
|
|
1620
|
+
# answer from. Recorded here rather than at the call sites because
|
|
1621
|
+
# this is the one place that knows the search RAN: a search that
|
|
1622
|
+
# raised never reaches this line at all (it propagates out of
|
|
1623
|
+
# `_pass_task_list` and the pass turns it into one PR's SKIPPED),
|
|
1624
|
+
# so a transient CLI failure can never buy itself a window of
|
|
1625
|
+
# silence.
|
|
1626
|
+
#
|
|
1627
|
+
# KNOWN GAP, not reachable on any shipping CLI (PR #88 round 1): a
|
|
1628
|
+
# search "ran" is only as good as the corpus it ran over. The two
|
|
1629
|
+
# runtime disproofs in `Alissa.list_tasks` catch a narrowed call that
|
|
1630
|
+
# FAILS and one that answers EMPTY; neither catches one that answers
|
|
1631
|
+
# NON-EMPTY but INCOMPLETE -- a `--status` the API accepts and serves
|
|
1632
|
+
# partially, say. The search then legitimately finds nothing and this
|
|
1633
|
+
# line makes that wrong answer STICKY for a window, where before the
|
|
1634
|
+
# negative cache it was wrong but self-correcting on the next pass.
|
|
1635
|
+
# No CLI generation offers `--status` or `--view digest` yet, so
|
|
1636
|
+
# nothing can reach it today; the first one that does must prove
|
|
1637
|
+
# itself before this row may be trusted. See the operator's-gate
|
|
1638
|
+
# checklist on PR #88.
|
|
1639
|
+
self.state.record_review_task_miss(
|
|
1640
|
+
pr.full_name, pr.number, self.config.review_task_miss_ttl_polls
|
|
1641
|
+
)
|
|
1529
1642
|
return ResolvedTask(task=None)
|
|
1530
1643
|
|
|
1531
1644
|
self.state.record_review_task(pr.full_name, pr.number, task.ref)
|
|
@@ -3863,6 +3976,19 @@ class ReviewWatcher:
|
|
|
3863
3976
|
f"not an Alissa Code Workspace yet (`alissa code workspace init`)"
|
|
3864
3977
|
)
|
|
3865
3978
|
|
|
3979
|
+
# The task-list narrowing probe (issue #87), run at BOOT so the answer
|
|
3980
|
+
# is memoized before the first pass and, more usefully, so the call the
|
|
3981
|
+
# daemon will actually make is in the startup log next to the config
|
|
3982
|
+
# that shaped it. Nothing here can fail the daemon: an unprobeable CLI
|
|
3983
|
+
# degrades to the unnarrowed call and says so.
|
|
3984
|
+
log.info("task list: %s", " ".join(self.alissa.task_list_argv()))
|
|
3985
|
+
if self.config.task_list_self_scope and not self.alissa.probe_task_list().self_scope:
|
|
3986
|
+
warnings.append(
|
|
3987
|
+
"task_list_self_scope is set but the installed `alissa` CLI "
|
|
3988
|
+
"does not advertise `task list --self` — the task list is not "
|
|
3989
|
+
"actor-scoped; upgrade the CLI or drop the key"
|
|
3990
|
+
)
|
|
3991
|
+
|
|
3866
3992
|
if not self.config.dry_run and not self.alissa.worker_running():
|
|
3867
3993
|
warnings.append(
|
|
3868
3994
|
"`alissa worker` does not appear to be running — queued reviewer "
|
|
@@ -4072,14 +4198,14 @@ class ReviewWatcher:
|
|
|
4072
4198
|
# effect AND every correctness write (`_spawn` skips record_spawn, the
|
|
4073
4199
|
# reaper logs instead of killing, the drift/cap-out/deferral paths
|
|
4074
4200
|
# return before both their comment and their record). The ledger writes
|
|
4075
|
-
# it may still take are the snapshot and the review-task cache
|
|
4076
|
-
# (`_review_task` runs in dry-run and
|
|
4077
|
-
# mappings) --
|
|
4078
|
-
#
|
|
4079
|
-
# has to remember. Writing the cache in dry-run is
|
|
4080
|
-
# dry-run pass that learns a mapping hands it to the next
|
|
4081
|
-
# pass, and suppressing it would make the two disagree about
|
|
4082
|
-
# contents for no correctness reason. The cost on a read-only volume is
|
|
4201
|
+
# it may still take are the snapshot and the review-task cache, both
|
|
4202
|
+
# halves (`_review_task` runs in dry-run and records, forgets and spends
|
|
4203
|
+
# both mappings and misses) -- all classified by this module as
|
|
4204
|
+
# best-effort telemetry, all absorbed by _write_telemetry, and none a
|
|
4205
|
+
# decision the daemon has to remember. Writing the cache in dry-run is
|
|
4206
|
+
# deliberate: a dry-run pass that learns a mapping hands it to the next
|
|
4207
|
+
# production pass, and suppressing it would make the two disagree about
|
|
4208
|
+
# ledger contents for no correctness reason. The cost on a read-only volume is
|
|
4083
4209
|
# a reconnect attempt on the first failure of the streak plus
|
|
4084
4210
|
# streak-limited warnings, which is the same best-effort behaviour the
|
|
4085
4211
|
# snapshot has always had there.
|
|
@@ -74,11 +74,17 @@ def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str
|
|
|
74
74
|
|
|
75
75
|
Deliberately not `Alissa.find_review_task` (which filters to open tasks): we
|
|
76
76
|
still want the ref just after the task is validated, to read its verdict.
|
|
77
|
+
`narrow_status=False` says the same thing to the LIST call for the same
|
|
78
|
+
reason -- the daemon's server-side status filter is exactly its `is_open`
|
|
79
|
+
set, and narrowing by it here would re-impose the filter this function
|
|
80
|
+
exists to avoid.
|
|
77
81
|
"""
|
|
78
82
|
pattern = re.compile(
|
|
79
83
|
rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
|
|
80
84
|
)
|
|
81
|
-
matches = [
|
|
85
|
+
matches = [
|
|
86
|
+
t for t in alissa.list_tasks(narrow_status=False) if pattern.match(t.title)
|
|
87
|
+
]
|
|
82
88
|
return matches[0].ref if matches else None
|
|
83
89
|
|
|
84
90
|
|
|
@@ -171,6 +171,33 @@ CREATE TABLE IF NOT EXISTS review_tasks (
|
|
|
171
171
|
PRIMARY KEY (repo, number)
|
|
172
172
|
);
|
|
173
173
|
|
|
174
|
+
-- The NEGATIVE half of the mapping above: PRs a completed search found NO open
|
|
175
|
+
-- review task for (issue #87). The table above can only remember an answer that
|
|
176
|
+
-- exists, so a PR without one missed the cache on every single pass and paid the
|
|
177
|
+
-- full corpus fetch for it -- 1,440 whole-corpus reads a day from one unmapped
|
|
178
|
+
-- PR, forever, because nothing about "there is no review task" ever changes on
|
|
179
|
+
-- its own. That is the widest read this daemon makes, on the path that repeats
|
|
180
|
+
-- most often.
|
|
181
|
+
--
|
|
182
|
+
-- `polls_left` is a COUNTDOWN, not a deadline: each pass that consults the row
|
|
183
|
+
-- burns one and the row is deleted at zero, so the suppression is measured in
|
|
184
|
+
-- polls of THIS PR (the unit the knob is written in) rather than in wall-clock
|
|
185
|
+
-- or in global passes -- a PR nobody is requesting a review on burns nothing.
|
|
186
|
+
-- It also makes the row self-cleaning, which matters because a negative answer
|
|
187
|
+
-- must never outlive its own re-check.
|
|
188
|
+
--
|
|
189
|
+
-- Best-effort like `review_tasks`, and fail-open in the same direction: a row
|
|
190
|
+
-- that cannot be written or decremented simply does not suppress anything, and
|
|
191
|
+
-- the pass pays the fetch it always paid. The one thing this table may never do
|
|
192
|
+
-- is answer "no review task" for longer than it was asked to.
|
|
193
|
+
CREATE TABLE IF NOT EXISTS review_task_misses (
|
|
194
|
+
repo TEXT NOT NULL,
|
|
195
|
+
number INTEGER NOT NULL,
|
|
196
|
+
polls_left INTEGER NOT NULL,
|
|
197
|
+
first_at INTEGER NOT NULL,
|
|
198
|
+
PRIMARY KEY (repo, number)
|
|
199
|
+
);
|
|
200
|
+
|
|
174
201
|
-- One row per (PR, round, head) whose reviewer the pre-spawn CI gate has held
|
|
175
202
|
-- back, stamped when the wait BEGAN (issue #84). That stamp is the only thing
|
|
176
203
|
-- the gate needs to remember: everything else about the decision -- what is
|
|
@@ -1019,8 +1046,147 @@ class State:
|
|
|
1019
1046
|
"(repo, number, task_ref, resolved_at) VALUES (?,?,?,?)",
|
|
1020
1047
|
(repo, number, task_ref, int(time.time())),
|
|
1021
1048
|
)
|
|
1049
|
+
# A mapping DISPROVES an outstanding negative answer, so the two tables
|
|
1050
|
+
# can never both speak for one PR. Dropped in the same transaction as
|
|
1051
|
+
# the mapping rather than by a separate call: a negative row that
|
|
1052
|
+
# survived a recorded mapping would keep the suppression running against
|
|
1053
|
+
# a PR the daemon can now resolve by ref -- which is not a wasted fetch
|
|
1054
|
+
# (the cached path skips the corpus anyway) but a stale claim in the
|
|
1055
|
+
# ledger, and it is the "mapping recorded mid-TTL re-arms" contract.
|
|
1056
|
+
self._db.execute(
|
|
1057
|
+
"DELETE FROM review_task_misses WHERE repo=? AND number=?",
|
|
1058
|
+
(repo, number),
|
|
1059
|
+
)
|
|
1060
|
+
self._db.commit()
|
|
1061
|
+
|
|
1062
|
+
# -- the NEGATIVE half: PRs with no review task at all (issue #87) ------
|
|
1063
|
+
|
|
1064
|
+
def record_review_task_miss(self, repo: str, number: int, polls: int) -> bool:
|
|
1065
|
+
"""Remember that a COMPLETED search found no review task for this PR.
|
|
1066
|
+
|
|
1067
|
+
`polls` is how many later passes may take that answer on trust before
|
|
1068
|
+
the search is re-armed. REPLACE, not IGNORE: every fresh disproof
|
|
1069
|
+
restarts the countdown, which is what makes a run of misses cost one
|
|
1070
|
+
fetch per window rather than one per pass.
|
|
1071
|
+
|
|
1072
|
+
Only ever called on a search that RAN and found nothing -- never on a
|
|
1073
|
+
failed one. A miss recorded because the CLI hiccuped would suppress the
|
|
1074
|
+
retry that was supposed to correct it, which is the one way this table
|
|
1075
|
+
could cost a review instead of a read.
|
|
1076
|
+
|
|
1077
|
+
Best-effort, like every write in this class's optimization half: a
|
|
1078
|
+
`polls < 1` request records nothing (there is no window to remember),
|
|
1079
|
+
and a database error records nothing either. Both mean the next pass
|
|
1080
|
+
searches, which is what the daemon did before this table existed.
|
|
1081
|
+
"""
|
|
1082
|
+
if polls < 1:
|
|
1083
|
+
return False
|
|
1084
|
+
return self._write_telemetry(
|
|
1085
|
+
lambda: self._replace_review_task_miss(repo, number, polls),
|
|
1086
|
+
"review-task miss write",
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
def _replace_review_task_miss(self, repo: str, number: int, polls: int) -> None:
|
|
1090
|
+
self._db.execute(
|
|
1091
|
+
"INSERT OR REPLACE INTO review_task_misses "
|
|
1092
|
+
"(repo, number, polls_left, first_at) VALUES (?,?,?,?)",
|
|
1093
|
+
(repo, number, int(polls), int(time.time())),
|
|
1094
|
+
)
|
|
1022
1095
|
self._db.commit()
|
|
1023
1096
|
|
|
1097
|
+
def consume_review_task_miss(self, repo: str, number: int) -> bool:
|
|
1098
|
+
"""Spend one poll of this PR's negative answer. True = skip the search.
|
|
1099
|
+
|
|
1100
|
+
True is the ONLY answer that suppresses a corpus fetch, and it is
|
|
1101
|
+
returned only when a live row was read AND its countdown was
|
|
1102
|
+
successfully decremented. That coupling is the read-only-ledger
|
|
1103
|
+
contract: on a volume that flips read-only after a row was written, the
|
|
1104
|
+
row is still readable and would otherwise say "skip" forever, because
|
|
1105
|
+
nothing could ever burn it down. Requiring the write means such a ledger
|
|
1106
|
+
answers False every pass -- one fetch per pass, exactly today's
|
|
1107
|
+
behaviour -- rather than silently answering "no review task" until
|
|
1108
|
+
someone restarts the daemon.
|
|
1109
|
+
|
|
1110
|
+
False therefore means all of: no row, an exhausted row, an unreadable
|
|
1111
|
+
database, and a decrement that would not persist. The caller may
|
|
1112
|
+
conclude nothing from it beyond "search for it", which is what it did
|
|
1113
|
+
unconditionally before this table existed.
|
|
1114
|
+
"""
|
|
1115
|
+
try:
|
|
1116
|
+
row = self._db.execute(
|
|
1117
|
+
"SELECT polls_left FROM review_task_misses WHERE repo=? AND number=?",
|
|
1118
|
+
(repo, number),
|
|
1119
|
+
).fetchone()
|
|
1120
|
+
except sqlite3.DatabaseError as exc:
|
|
1121
|
+
log.warning(
|
|
1122
|
+
"state: review-task miss cache unreadable (%s: %s) — this pass "
|
|
1123
|
+
"resolves %s#%d by searching, as it did before the cache",
|
|
1124
|
+
type(exc).__name__, exc, repo, number,
|
|
1125
|
+
)
|
|
1126
|
+
return False
|
|
1127
|
+
if row is None or int(row["polls_left"]) < 1:
|
|
1128
|
+
# An exhausted row cannot occur through this method (the decrement
|
|
1129
|
+
# deletes at zero) but can through a hand-edited ledger; treat it as
|
|
1130
|
+
# the re-armed state it describes and clear it.
|
|
1131
|
+
if row is not None:
|
|
1132
|
+
self.forget_review_task_miss(repo, number)
|
|
1133
|
+
return False
|
|
1134
|
+
return self._write_telemetry(
|
|
1135
|
+
lambda: self._burn_review_task_miss(repo, number),
|
|
1136
|
+
"review-task miss decrement",
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
def _burn_review_task_miss(self, repo: str, number: int) -> None:
|
|
1140
|
+
"""One poll off the countdown, and the row goes when it runs out.
|
|
1141
|
+
|
|
1142
|
+
Both statements in one transaction so a row can never be left claiming a
|
|
1143
|
+
window it has already spent.
|
|
1144
|
+
"""
|
|
1145
|
+
self._db.execute(
|
|
1146
|
+
"UPDATE review_task_misses SET polls_left = polls_left - 1 "
|
|
1147
|
+
"WHERE repo=? AND number=?",
|
|
1148
|
+
(repo, number),
|
|
1149
|
+
)
|
|
1150
|
+
self._db.execute(
|
|
1151
|
+
"DELETE FROM review_task_misses WHERE repo=? AND number=? AND polls_left < 1",
|
|
1152
|
+
(repo, number),
|
|
1153
|
+
)
|
|
1154
|
+
self._db.commit()
|
|
1155
|
+
|
|
1156
|
+
def forget_review_task_miss(self, repo: str, number: int) -> bool:
|
|
1157
|
+
"""Re-arm the search for this PR now. Best-effort.
|
|
1158
|
+
|
|
1159
|
+
The manual counterpart to the countdown: `--pr` runs it so a one-shot
|
|
1160
|
+
diagnostic always reports what a search would find, never what a
|
|
1161
|
+
suppressed one was told to assume.
|
|
1162
|
+
"""
|
|
1163
|
+
return self._write_telemetry(
|
|
1164
|
+
lambda: self._delete_review_task_miss(repo, number),
|
|
1165
|
+
"review-task miss invalidation",
|
|
1166
|
+
)
|
|
1167
|
+
|
|
1168
|
+
def _delete_review_task_miss(self, repo: str, number: int) -> None:
|
|
1169
|
+
self._db.execute(
|
|
1170
|
+
"DELETE FROM review_task_misses WHERE repo=? AND number=?", (repo, number)
|
|
1171
|
+
)
|
|
1172
|
+
self._db.commit()
|
|
1173
|
+
|
|
1174
|
+
def review_task_miss(self, repo: str, number: int) -> "int | None":
|
|
1175
|
+
"""Polls left on this PR's negative answer, or None when it has none.
|
|
1176
|
+
|
|
1177
|
+
A read for tests and for an operator poking at the ledger; the decide
|
|
1178
|
+
path uses `consume_review_task_miss`, which is the only caller allowed
|
|
1179
|
+
to act on the answer (it burns a poll for the pass it grants).
|
|
1180
|
+
"""
|
|
1181
|
+
try:
|
|
1182
|
+
row = self._db.execute(
|
|
1183
|
+
"SELECT polls_left FROM review_task_misses WHERE repo=? AND number=?",
|
|
1184
|
+
(repo, number),
|
|
1185
|
+
).fetchone()
|
|
1186
|
+
except sqlite3.DatabaseError:
|
|
1187
|
+
return None
|
|
1188
|
+
return None if row is None else int(row["polls_left"])
|
|
1189
|
+
|
|
1024
1190
|
def forget_review_task(self, repo: str, number: int) -> bool:
|
|
1025
1191
|
"""Drop a mapping that has been DISPROVED. Best-effort.
|
|
1026
1192
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.19.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.17.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
|
|
File without changes
|