alissa-tools-github-revloop 0.16.3__tar.gz → 0.16.5__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.16.3/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.5}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/config.py +43 -14
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/ghclient.py +93 -21
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/loop.py +281 -11
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/proc.py +11 -1
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/state.py +23 -2
- alissa_tools_github_revloop-0.16.5/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/page.py +2 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.16.3/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/README.md +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/setup.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.16.3 → alissa_tools_github_revloop-0.16.5}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -23,6 +23,10 @@ from typing import Any, Mapping
|
|
|
23
23
|
# A POSIX-ish environment variable name -- what `reviewer_token_env` must be.
|
|
24
24
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
25
25
|
|
|
26
|
+
# The per-character half of the same rule, used to name the offending
|
|
27
|
+
# characters in an error without echoing the value (see _describe).
|
|
28
|
+
_ENV_CHAR_RE = re.compile(r"[A-Za-z0-9_]")
|
|
29
|
+
|
|
26
30
|
# ...which, on its own, accepts every GitHub credential format: `ghp_`, `gho_`,
|
|
27
31
|
# `ghu_`, `ghs_`, `ghr_` and `github_pat_` tokens are `[A-Za-z0-9_]` throughout
|
|
28
32
|
# and therefore valid identifiers. So the shape has to be rejected explicitly,
|
|
@@ -30,9 +34,13 @@ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
30
34
|
# its variable's NAME belongs -- sails through.
|
|
31
35
|
_TOKEN_SHAPE_RE = re.compile(r"^(gh[pousr]_|github_pat_)", re.IGNORECASE)
|
|
32
36
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
37
|
+
# Generous on purpose. A tighter ceiling (32 was tried) rejects names an
|
|
38
|
+
# operator can plausibly write -- `REVLOOP_REVIEWER_GITHUB_TOKEN_ENV` is 33 --
|
|
39
|
+
# and this bound is NOT what catches credentials: MIN_SECRET_RUN_LENGTH is,
|
|
40
|
+
# and it catches a 40-character hex token on its shape whatever the ceiling
|
|
41
|
+
# says. So the ceiling exists only to refuse the obviously absurd, and it is
|
|
42
|
+
# deliberately kept OUT of `_is_credential_shaped` (see there).
|
|
43
|
+
MAX_ENV_NAME_LENGTH = 40
|
|
36
44
|
|
|
37
45
|
# Length is not the whole discriminator, because a secret can be short enough
|
|
38
46
|
# to fit under the ceiling. The shape is: a long, undifferentiated run of
|
|
@@ -47,10 +55,14 @@ def _is_credential_shaped(value: str) -> bool:
|
|
|
47
55
|
A heuristic, and deliberately one that errs toward accusing: the cost of a
|
|
48
56
|
false positive is renaming a variable, and the cost of a false negative is
|
|
49
57
|
a live credential sitting in a config file on a shared volume.
|
|
58
|
+
|
|
59
|
+
Length is deliberately NOT one of the clauses. It was, and it made a merely
|
|
60
|
+
LONG name get classified as a credential -- rejected, redacted, and told to
|
|
61
|
+
rotate itself. Over-length is a separate refusal with its own message; only
|
|
62
|
+
an actual credential SHAPE drives redaction.
|
|
50
63
|
"""
|
|
51
64
|
return bool(
|
|
52
65
|
_TOKEN_SHAPE_RE.match(value)
|
|
53
|
-
or len(value) > MAX_ENV_NAME_LENGTH
|
|
54
66
|
or (
|
|
55
67
|
len(value) >= MIN_SECRET_RUN_LENGTH
|
|
56
68
|
and "_" not in value
|
|
@@ -60,18 +72,30 @@ def _is_credential_shaped(value: str) -> bool:
|
|
|
60
72
|
|
|
61
73
|
|
|
62
74
|
def _describe(value: str) -> str:
|
|
63
|
-
"""
|
|
75
|
+
"""Say what is wrong with a rejected value without reprinting a secret.
|
|
64
76
|
|
|
65
77
|
`__main__` prints this to stderr as `config error: …`, straight into the
|
|
66
|
-
container log, so a pasted
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
container log, so a pasted credential must never be echoed there. But the
|
|
79
|
+
other way to fail this check is an ordinary typo
|
|
80
|
+
(`REVLOOP-REVIEWER-GH-TOKEN`), and there the value IS the diagnostic.
|
|
81
|
+
|
|
82
|
+
Redacting everything loses the typo case; echoing whatever the GitHub-shape
|
|
83
|
+
heuristic does not recognise hands back the container-log disclosure for
|
|
84
|
+
every non-GitHub secret (`sk-proj-…`, `xoxb-…`, `glpat-…` all carry
|
|
85
|
+
punctuation and match no clause above). So neither branch prints the value:
|
|
86
|
+
what an operator needs is WHICH CHARACTERS were rejected, and those can be
|
|
87
|
+
named with their positions and nothing else.
|
|
71
88
|
"""
|
|
72
89
|
if _is_credential_shaped(value):
|
|
73
|
-
return f
|
|
74
|
-
|
|
90
|
+
return f"a {len(value)}-character value that looks like a credential"
|
|
91
|
+
offenders = sorted({c for c in value if not _ENV_CHAR_RE.match(c)})
|
|
92
|
+
if offenders:
|
|
93
|
+
where = ", ".join(
|
|
94
|
+
f"{c!r} at {', '.join(str(i) for i, ch in enumerate(value) if ch == c)}"
|
|
95
|
+
for c in offenders
|
|
96
|
+
)
|
|
97
|
+
return f"a {len(value)}-character value containing {where}"
|
|
98
|
+
return f"a {len(value)}-character value"
|
|
75
99
|
|
|
76
100
|
|
|
77
101
|
# What to do when a PR has a pending review request but no matching Alissa
|
|
@@ -322,7 +346,11 @@ class Config:
|
|
|
322
346
|
# rather than exposed. So the check is two-sided: it must LOOK like
|
|
323
347
|
# a name, and it must not look like a credential. The value itself
|
|
324
348
|
# is never echoed back (see _redact).
|
|
325
|
-
if
|
|
349
|
+
if (
|
|
350
|
+
not _ENV_NAME_RE.match(token_env)
|
|
351
|
+
or _is_credential_shaped(token_env)
|
|
352
|
+
or len(token_env) > MAX_ENV_NAME_LENGTH
|
|
353
|
+
):
|
|
326
354
|
rotate = (
|
|
327
355
|
" If that is a credential, rotate it: it is now in a config file."
|
|
328
356
|
if _is_credential_shaped(token_env)
|
|
@@ -330,7 +358,8 @@ class Config:
|
|
|
330
358
|
)
|
|
331
359
|
raise ValueError(
|
|
332
360
|
f"reviewer_token_env must be an environment variable NAME "
|
|
333
|
-
f"(e.g. 'REVLOOP_REVIEWER_GH_TOKEN')
|
|
361
|
+
f"(e.g. 'REVLOOP_REVIEWER_GH_TOKEN') of at most "
|
|
362
|
+
f"{MAX_ENV_NAME_LENGTH} characters, not a value or a "
|
|
334
363
|
f"token — got {_describe(token_env)}.{rotate}"
|
|
335
364
|
)
|
|
336
365
|
|
|
@@ -15,6 +15,7 @@ without it the client inherits, exactly as it always did.
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import json
|
|
18
19
|
import logging
|
|
19
20
|
import os
|
|
20
21
|
import re
|
|
@@ -41,11 +42,15 @@ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
|
41
42
|
PER_PAGE = 100
|
|
42
43
|
COMMENT_PAGE_LIMIT = 20
|
|
43
44
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
|
|
45
|
+
# GitHub's OWN cap on the pull-request commits endpoint: it "lists a maximum of
|
|
46
|
+
# 250 commits" and refers callers with more to the repository commits endpoint.
|
|
47
|
+
# That, not a page count of ours, is where absence stops being provable -- a
|
|
48
|
+
# 250-entry answer looks complete (the last page is short) while saying nothing
|
|
49
|
+
# about commit 251. And the direction is hostile: the endpoint returns commits
|
|
50
|
+
# OLDEST first, so on a longer PR the 250 returned are the oldest and a recent
|
|
51
|
+
# `judged` head is absent from every read. Unguarded, the "is the pinned commit
|
|
52
|
+
# gone?" probe would answer yes on every large PR and abandon real verdicts.
|
|
53
|
+
PR_COMMIT_CAP = 250
|
|
49
54
|
|
|
50
55
|
# Environment variables `gh` reads a token from. Both are cleared before an
|
|
51
56
|
# explicitly-routed call, so an inherited implementer credential cannot win by
|
|
@@ -92,6 +97,12 @@ class PullRequest:
|
|
|
92
97
|
# "open" or "closed"; merged PRs report state "closed" AND merged True.
|
|
93
98
|
state: str = "open"
|
|
94
99
|
merged: bool = False
|
|
100
|
+
# Logins GitHub currently holds a pending review request against. Read off
|
|
101
|
+
# the same PR payload as everything else here, so knowing whether this
|
|
102
|
+
# daemon's own request is still dangling costs no extra call. USERS only:
|
|
103
|
+
# `requested_teams` is a separate field and is deliberately not carried,
|
|
104
|
+
# because nothing in the loop may ever withdraw a team's request.
|
|
105
|
+
requested_reviewers: tuple[str, ...] = ()
|
|
95
106
|
|
|
96
107
|
@property
|
|
97
108
|
def full_name(self) -> str:
|
|
@@ -327,9 +338,23 @@ class GitHub:
|
|
|
327
338
|
)
|
|
328
339
|
return actual
|
|
329
340
|
|
|
330
|
-
def _api(
|
|
341
|
+
def _api(
|
|
342
|
+
self,
|
|
343
|
+
*args: str,
|
|
344
|
+
timeout: int = 60,
|
|
345
|
+
forbidden_is_rate_limit: bool = True,
|
|
346
|
+
body: "dict | None" = None,
|
|
347
|
+
):
|
|
331
348
|
"""One `gh api` call, with GitHub's throttling mapped to RateLimited.
|
|
332
349
|
|
|
350
|
+
`body` is for requests whose payload must have a particular JSON SHAPE.
|
|
351
|
+
`gh`'s `-f` fields cannot express one portably: `-f 'k[]=v'` is encoded
|
|
352
|
+
as the array `{"k": ["v"]}` only by modern `gh`, and as a string field
|
|
353
|
+
NAMED `k[]` by the 2.4.0 this client targets -- silently, so the
|
|
354
|
+
request goes out well-formed and simply missing the key the endpoint
|
|
355
|
+
wants. Passing the body on stdin through `--input -` (which 2.4.0 does
|
|
356
|
+
support) takes every `gh` version out of the encoding decision.
|
|
357
|
+
|
|
333
358
|
`forbidden_is_rate_limit` is about one ambiguity: GitHub answers a
|
|
334
359
|
secondary rate limit with 403, so a bare "403" in stderr is read as
|
|
335
360
|
throttling by default, and `run_forever` backs off. That default is
|
|
@@ -344,8 +369,13 @@ class GitHub:
|
|
|
344
369
|
that pass False: an explicit throttling marker still raises
|
|
345
370
|
RateLimited, and everything else stays a CommandError they can handle.
|
|
346
371
|
"""
|
|
372
|
+
argv = ["gh", "api", *args]
|
|
373
|
+
stdin = None
|
|
374
|
+
if body is not None:
|
|
375
|
+
argv += ["--input", "-"]
|
|
376
|
+
stdin = json.dumps(body)
|
|
347
377
|
try:
|
|
348
|
-
return run_json(
|
|
378
|
+
return run_json(argv, timeout=timeout, env=self._env(), stdin=stdin)
|
|
349
379
|
except CommandError as exc:
|
|
350
380
|
blob = exc.stderr.lower()
|
|
351
381
|
throttled = any(marker in blob for marker in RATE_LIMIT_MARKERS)
|
|
@@ -398,6 +428,11 @@ class GitHub:
|
|
|
398
428
|
url=data.get("html_url", ""),
|
|
399
429
|
state=data.get("state") or "open",
|
|
400
430
|
merged=bool(data.get("merged")),
|
|
431
|
+
requested_reviewers=tuple(
|
|
432
|
+
str((u or {}).get("login") or "")
|
|
433
|
+
for u in (data.get("requested_reviewers") or [])
|
|
434
|
+
if (u or {}).get("login")
|
|
435
|
+
),
|
|
401
436
|
)
|
|
402
437
|
|
|
403
438
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
@@ -432,14 +467,15 @@ class GitHub:
|
|
|
432
467
|
this" from "this can never succeed", so it is worth a call, but only
|
|
433
468
|
there; nothing on the common path reads it.
|
|
434
469
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
470
|
+
Raises `TruncatedListing` rather than returning a possibly-incomplete
|
|
471
|
+
list, because the caller uses ABSENCE as evidence and a short answer
|
|
472
|
+
cannot support that. The bound is GitHub's own 250 (PR_COMMIT_CAP), not
|
|
473
|
+
a page count of ours: at 250 the last page is short and the listing
|
|
474
|
+
looks complete while saying nothing about the commits past it.
|
|
440
475
|
"""
|
|
441
476
|
out: list[str] = []
|
|
442
|
-
|
|
477
|
+
page = 1
|
|
478
|
+
while True:
|
|
443
479
|
data = (
|
|
444
480
|
self._api(
|
|
445
481
|
"-X",
|
|
@@ -453,16 +489,20 @@ class GitHub:
|
|
|
453
489
|
or []
|
|
454
490
|
)
|
|
455
491
|
out.extend(str(c.get("sha") or "") for c in data)
|
|
492
|
+
if len(out) >= PR_COMMIT_CAP:
|
|
493
|
+
log.warning(
|
|
494
|
+
"%s/%s#%d reached GitHub's %d-commit listing cap — the "
|
|
495
|
+
"commit list is not complete, so a missing SHA cannot be "
|
|
496
|
+
"read as absent",
|
|
497
|
+
owner, repo, number, PR_COMMIT_CAP,
|
|
498
|
+
)
|
|
499
|
+
raise TruncatedListing(
|
|
500
|
+
f"{owner}/{repo}#{number} reached GitHub's {PR_COMMIT_CAP}-commit "
|
|
501
|
+
f"listing cap; absence cannot be proven from it"
|
|
502
|
+
)
|
|
456
503
|
if len(data) < PER_PAGE:
|
|
457
504
|
return out
|
|
458
|
-
|
|
459
|
-
"%s/%s#%d has more than %d commits — the list is truncated, so a "
|
|
460
|
-
"missing SHA cannot be read as absent",
|
|
461
|
-
owner, repo, number, COMMIT_PAGE_LIMIT * PER_PAGE,
|
|
462
|
-
)
|
|
463
|
-
raise TruncatedListing(
|
|
464
|
-
f"{owner}/{repo}#{number} has more than {COMMIT_PAGE_LIMIT * PER_PAGE} commits"
|
|
465
|
-
)
|
|
505
|
+
page += 1
|
|
466
506
|
|
|
467
507
|
def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
468
508
|
"""My substantive submitted reviews, oldest first -- one per round.
|
|
@@ -538,6 +578,38 @@ class GitHub:
|
|
|
538
578
|
data = self._api(*argv, forbidden_is_rate_limit=False) or {}
|
|
539
579
|
return str(data.get("html_url") or "")
|
|
540
580
|
|
|
581
|
+
def remove_review_request(
|
|
582
|
+
self, owner: str, repo: str, number: int, login: str
|
|
583
|
+
) -> None:
|
|
584
|
+
"""Withdraw the pending review request held against ONE login.
|
|
585
|
+
|
|
586
|
+
The payload names exactly one reviewer, never the whole list: GitHub's
|
|
587
|
+
DELETE removes only what it is given, so a human reviewer or a second
|
|
588
|
+
bot sitting in the same `requested_reviewers` array is untouched. The
|
|
589
|
+
caller is the round close-out (loop._clear_own_review_request), and the
|
|
590
|
+
only login it ever passes is the daemon's own.
|
|
591
|
+
|
|
592
|
+
The body goes out through `--input` rather than `-f 'reviewers[]=…'`,
|
|
593
|
+
because that field syntax means different things on different `gh`
|
|
594
|
+
versions and this client targets 2.4.0, where it produces a string
|
|
595
|
+
field literally named `reviewers[]` -- no `reviewers` key, a 422, and a
|
|
596
|
+
feature that never worked. See `_api`.
|
|
597
|
+
|
|
598
|
+
`forbidden_is_rate_limit=False` for the same reason `submit_review`
|
|
599
|
+
passes it: a 403 here is this identity not being allowed to edit the
|
|
600
|
+
PR's reviewers, which is a fact about the deployment that its caller
|
|
601
|
+
logs and degrades on -- not a throttle worth backing the whole poll
|
|
602
|
+
pass off for. Note the permission is `pull_requests: write`, strictly
|
|
603
|
+
more than reviewing needs.
|
|
604
|
+
"""
|
|
605
|
+
self._api(
|
|
606
|
+
"-X",
|
|
607
|
+
"DELETE",
|
|
608
|
+
f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
|
|
609
|
+
body={"reviewers": [login]},
|
|
610
|
+
forbidden_is_rate_limit=False,
|
|
611
|
+
)
|
|
612
|
+
|
|
541
613
|
def comment(self, owner: str, repo: str, number: int, body: str) -> None:
|
|
542
614
|
run_json(
|
|
543
615
|
[
|
|
@@ -399,6 +399,70 @@ def capout_kind(head_sha: str, granted: int) -> str:
|
|
|
399
399
|
return f"capout:{head_sha}:{granted}"
|
|
400
400
|
|
|
401
401
|
|
|
402
|
+
def identity_drift_kind(requested: str, author: str) -> str:
|
|
403
|
+
"""The ping-ledger kind that dedupes ONE request/author identity-drift warning.
|
|
404
|
+
|
|
405
|
+
Scope is once per PR per ordered login pair -- the ledger keys on
|
|
406
|
+
`(repo, number, kind)`, so folding the pair into the kind narrows the
|
|
407
|
+
dedupe WITHIN a PR and cannot lift it above one. What the pair buys is that
|
|
408
|
+
a drift which later becomes a DIFFERENT drift announces itself again
|
|
409
|
+
instead of staying quiet because this PR once warned about something else.
|
|
410
|
+
|
|
411
|
+
Durable rather than in-memory because it is a configuration alarm, not a
|
|
412
|
+
per-poll observation: a restart re-stating it on every open PR would be a
|
|
413
|
+
wall of noise. Per-PR is bounded in practice by the withdrawal itself --
|
|
414
|
+
once the request is gone the PR leaves the poll set, so a drifted
|
|
415
|
+
deployment emits one block per PR it closes a round on, not one per poll.
|
|
416
|
+
"""
|
|
417
|
+
return f"reqdrift:{requested}->{author}"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def drift_probe_kind(head_sha: str) -> str:
|
|
421
|
+
"""The ping-ledger kind that bounds the drift check's EXTRA read.
|
|
422
|
+
|
|
423
|
+
The check needs the unfiltered review list, which `my_reviews` cannot
|
|
424
|
+
supply -- a review by another identity is exactly what it filters out. That
|
|
425
|
+
is one more API call, and on a PR whose withdrawal fails permanently (a
|
|
426
|
+
reviewer identity without `pull_requests: write`) the close-out path runs
|
|
427
|
+
every poll, so "one extra call on the rare path" would quietly become one
|
|
428
|
+
per poll forever.
|
|
429
|
+
|
|
430
|
+
Keyed on the head so the probe re-runs when the code moves -- a new head
|
|
431
|
+
means a new round and a new chance for its verdict to land under the wrong
|
|
432
|
+
login -- and recorded only once the review list has actually been READ, so
|
|
433
|
+
an unreadable list retries instead of being settled by a failure.
|
|
434
|
+
"""
|
|
435
|
+
return f"driftprobe:{head_sha}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def withdraw_failed_kind(exc: BaseException) -> str:
|
|
439
|
+
"""The ping-ledger kind that dedupes a failing review-request withdrawal.
|
|
440
|
+
|
|
441
|
+
Retrying a failed DELETE is right -- a 403 can be a permission grant away
|
|
442
|
+
from working. Re-LOGGING it every poll is not: the common failure is
|
|
443
|
+
permanent and per-PR, so at a 30s poll interval one such PR would emit
|
|
444
|
+
~2,880 warning lines a day that never converge on anything.
|
|
445
|
+
|
|
446
|
+
Keyed on the exception class, not its message: a message carries the PR
|
|
447
|
+
and the moment, which would defeat the dedupe, while a 403 that later
|
|
448
|
+
becomes a 422 is a genuinely different condition and says so once.
|
|
449
|
+
"""
|
|
450
|
+
return f"withdraw-failed:{type(exc).__name__}"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def withdrawn_kind(head_sha: str) -> str:
|
|
454
|
+
"""The ping-ledger kind that marks a review request already withdrawn at
|
|
455
|
+
this head -- so a request that COMES BACK is distinguishable from the first
|
|
456
|
+
one.
|
|
457
|
+
|
|
458
|
+
Withdrawing is the daemon undoing a state change a human or an automation
|
|
459
|
+
made on GitHub. Doing it once at a head is routine close-out; doing it
|
|
460
|
+
again means somebody deliberately asked for another look and the daemon
|
|
461
|
+
took it away, which is worth a louder line than the first.
|
|
462
|
+
"""
|
|
463
|
+
return f"withdrawn:{head_sha}"
|
|
464
|
+
|
|
465
|
+
|
|
402
466
|
def _now() -> str:
|
|
403
467
|
"""The activity comment's timestamp format (UTC, seconds)."""
|
|
404
468
|
return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
@@ -508,6 +572,11 @@ class Action(str, Enum):
|
|
|
508
572
|
# window, or the post failed and is being retried. Either way the round is
|
|
509
573
|
# NOT closed and nothing downstream of it runs.
|
|
510
574
|
AWAITING_POST = "awaiting-post"
|
|
575
|
+
# The round's verdict can never be posted -- the head it judged is gone
|
|
576
|
+
# from the PR. Distinct from AWAITING_POST on purpose: that one retries,
|
|
577
|
+
# this one has given up, and the poll snapshot and console aggregate the
|
|
578
|
+
# action rather than the reason.
|
|
579
|
+
ABANDONED = "abandoned"
|
|
511
580
|
|
|
512
581
|
|
|
513
582
|
@dataclass(frozen=True)
|
|
@@ -617,16 +686,21 @@ class ReviewWatcher:
|
|
|
617
686
|
# purpose: an approve envelope with no native APPROVE behind it would
|
|
618
687
|
# otherwise close the loop with no verdict of record on GitHub and the
|
|
619
688
|
# review request still dangling -- the studio #298 failure.
|
|
620
|
-
# ...
|
|
621
|
-
# round judged is gone from the PR. There is then no
|
|
622
|
-
# record that verdict against, and holding the round
|
|
623
|
-
# the PR outside the loop forever; the round is
|
|
624
|
-
# fresh one is owed against the new head
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
689
|
+
# ...discounting rounds whose post was ABANDONED, which happens only
|
|
690
|
+
# when the head the round judged is gone from the PR. There is then no
|
|
691
|
+
# commit left to record that verdict against, and holding the round
|
|
692
|
+
# open would strand the PR outside the loop forever; the round is
|
|
693
|
+
# released instead and a fresh one is owed against the new head (see
|
|
694
|
+
# _abandon_verdict).
|
|
695
|
+
#
|
|
696
|
+
# The discount is a SUBTRACTION, not just a check on the newest round.
|
|
697
|
+
# An abandoned round's envelope stays on the task forever while its
|
|
698
|
+
# review record never exists, so it leaves a permanent hole between the
|
|
699
|
+
# two counts -- and an uncorrected hole reads, on the NEXT round, as
|
|
700
|
+
# "that round has no native verdict", producing a duplicate post over a
|
|
701
|
+
# session that closed its own round correctly.
|
|
702
|
+
owed = completed - self.state.abandoned_rounds(pr.full_name, number)
|
|
703
|
+
if task is not None and owed > native:
|
|
630
704
|
# Terminal for this pass either way. On a landed post the review
|
|
631
705
|
# request it consumed drops the PR out of the search, so
|
|
632
706
|
# convergence and the next round belong to the next pass, decided
|
|
@@ -636,6 +710,13 @@ class ReviewWatcher:
|
|
|
636
710
|
|
|
637
711
|
converged = self._convergence_reason(my_reviews, task, pr.head_sha)
|
|
638
712
|
if converged is not None:
|
|
713
|
+
# THE terminal branch: a verdict of record exists at the current
|
|
714
|
+
# head, so no round k+1 can be owed from here -- every path that
|
|
715
|
+
# could open one (head moved, non-approve verdict, cap re-entry
|
|
716
|
+
# ack) is either upstream of this return or unreachable past it.
|
|
717
|
+
# That is what makes it the only safe place to withdraw a dangling
|
|
718
|
+
# self review request; see _clear_own_review_request.
|
|
719
|
+
self._clear_own_review_request(pr, my_reviews, converged)
|
|
639
720
|
return Decision(Action.CONVERGED, converged, completed)
|
|
640
721
|
|
|
641
722
|
# The effective cap is the configured one plus every re-entry an
|
|
@@ -737,6 +818,7 @@ class ReviewWatcher:
|
|
|
737
818
|
pr.full_name, pr.number, round_, self._judged_head(pr, round_)
|
|
738
819
|
)
|
|
739
820
|
attempts = int(row["attempts"])
|
|
821
|
+
# `left` decides; `reason` only ever reaches a caller when left > 0.
|
|
740
822
|
if attempts == 0:
|
|
741
823
|
# The grace window: measured from the first observation, because it
|
|
742
824
|
# is about the round's own session getting its chance, not about
|
|
@@ -907,7 +989,7 @@ class ReviewWatcher:
|
|
|
907
989
|
f"A fresh round is owed against `{pr.head_sha[:8]}`.",
|
|
908
990
|
)
|
|
909
991
|
return Decision(
|
|
910
|
-
Action.
|
|
992
|
+
Action.ABANDONED,
|
|
911
993
|
f"round {round_}'s native verdict was abandoned — {why}",
|
|
912
994
|
round_,
|
|
913
995
|
task_ref=task.ref,
|
|
@@ -1279,6 +1361,193 @@ class ReviewWatcher:
|
|
|
1279
1361
|
|
|
1280
1362
|
return None
|
|
1281
1363
|
|
|
1364
|
+
# -- round close-out ---------------------------------------------------
|
|
1365
|
+
|
|
1366
|
+
def _clear_own_review_request(
|
|
1367
|
+
self, pr: PullRequest, my_reviews: list[Review], why: str
|
|
1368
|
+
) -> None:
|
|
1369
|
+
"""Withdraw this daemon's own dangling review request on a closed round.
|
|
1370
|
+
|
|
1371
|
+
Normally there is nothing to do: GitHub consumes a review request the
|
|
1372
|
+
moment the requested identity submits a review, so a converged PR has
|
|
1373
|
+
no request left and drops straight out of `review_requests`. The
|
|
1374
|
+
request survives when the review that closed the round was NOT posted
|
|
1375
|
+
by the requested identity -- studio #298, where a ready-flip
|
|
1376
|
+
auto-requested `alissa-app` while the round ran under another login.
|
|
1377
|
+
Nothing then ever consumes it, so the PR stays in the search set and
|
|
1378
|
+
every poll pays a full re-verification (PR fetch, reviews, task,
|
|
1379
|
+
envelope count) to reach this same no-op. Removing the request is what
|
|
1380
|
+
ends that: the search is the evaluation set, so the PR leaves it.
|
|
1381
|
+
|
|
1382
|
+
Four properties this must not violate:
|
|
1383
|
+
|
|
1384
|
+
* Only ever OUR login. Human reviewers and any second bot in the same
|
|
1385
|
+
`requested_reviewers` array are somebody else's pending work, and a
|
|
1386
|
+
daemon that withdrew them would silently cancel real reviews.
|
|
1387
|
+
* Only from the terminal branch. The caller is the one return where a
|
|
1388
|
+
verdict of record stands at the current head and no further round can
|
|
1389
|
+
be owed; a request withdrawn while a round could still open would
|
|
1390
|
+
delete the very trigger that surfaces the PR.
|
|
1391
|
+
* Only on a HEAD-BOUND verdict. `_convergence_reason` cannot check a
|
|
1392
|
+
review that carries no `commit_id` and lets it converge anyway; that
|
|
1393
|
+
convergence is not about the current head, so withdrawing on it would
|
|
1394
|
+
take a PR out of the daemon's sight on a verdict that might be about
|
|
1395
|
+
old code. Such a review is not producible today -- `submit_review`
|
|
1396
|
+
always pins a commit and GitHub populates it -- but the terminal
|
|
1397
|
+
argument must hold on its own terms rather than by inheritance, so
|
|
1398
|
+
the check is repeated here.
|
|
1399
|
+
* Never blocking. The removal is a side effect of a decision already
|
|
1400
|
+
made, so any failure -- including a throttled one -- is logged and
|
|
1401
|
+
dropped rather than raised: the pass keeps walking and the next poll
|
|
1402
|
+
retries, which is strictly better than converting a no-op into an
|
|
1403
|
+
aborted pass. (Deliberately unlike the read paths, which let
|
|
1404
|
+
RateLimited reach run_forever's backoff.)
|
|
1405
|
+
|
|
1406
|
+
`my_reviews` is non-empty at every call site -- `_convergence_reason`
|
|
1407
|
+
returns None on an empty one -- so its newest entry IS the verdict of
|
|
1408
|
+
record, read positionally rather than defended against.
|
|
1409
|
+
"""
|
|
1410
|
+
mine = self.github.login
|
|
1411
|
+
if mine not in pr.requested_reviewers:
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
verdict = my_reviews[-1]
|
|
1415
|
+
if not verdict.commit_id:
|
|
1416
|
+
log.debug(
|
|
1417
|
+
"%s: not withdrawing the review request — the converged review "
|
|
1418
|
+
"%s carries no commit_id, so convergence is not head-bound",
|
|
1419
|
+
pr.slug, verdict.url or "with no url",
|
|
1420
|
+
)
|
|
1421
|
+
return
|
|
1422
|
+
|
|
1423
|
+
# Above the dry-run guard: the drift check reads, records a ledger row
|
|
1424
|
+
# and logs, but takes nothing on GitHub -- it OBSERVES the pass rather
|
|
1425
|
+
# than acting in it, the same reasoning that writes poll snapshots in
|
|
1426
|
+
# dry-run. Dry-run is also the mode an operator reaches for to diagnose
|
|
1427
|
+
# exactly this, so staying silent about it there is the worst place.
|
|
1428
|
+
self._warn_identity_drift(pr)
|
|
1429
|
+
|
|
1430
|
+
if self.config.dry_run:
|
|
1431
|
+
log.info(
|
|
1432
|
+
"[dry-run] would withdraw the dangling review request for %s on "
|
|
1433
|
+
"%s — the round is closed (%s)",
|
|
1434
|
+
mine, pr.slug, why,
|
|
1435
|
+
)
|
|
1436
|
+
return
|
|
1437
|
+
|
|
1438
|
+
try:
|
|
1439
|
+
self.github.remove_review_request(pr.owner, pr.repo, pr.number, mine)
|
|
1440
|
+
except Exception as exc:
|
|
1441
|
+
kind = withdraw_failed_kind(exc)
|
|
1442
|
+
if self.state.pinged(pr.full_name, pr.number, kind):
|
|
1443
|
+
# Still retried, just not re-announced; see withdraw_failed_kind.
|
|
1444
|
+
log.debug(
|
|
1445
|
+
"%s: withdrawal still failing for %s (%s)", pr.slug, mine, exc
|
|
1446
|
+
)
|
|
1447
|
+
return
|
|
1448
|
+
self.state.record_ping(pr.full_name, pr.number, kind)
|
|
1449
|
+
log.warning(
|
|
1450
|
+
"%s: could not withdraw the dangling review request for %s (%s) "
|
|
1451
|
+
"— the closed round will be re-evaluated next poll and the "
|
|
1452
|
+
"removal retried. Withdrawing needs `pull_requests: write`, "
|
|
1453
|
+
"which is more than reviewing needs; further failures of this "
|
|
1454
|
+
"kind log at debug",
|
|
1455
|
+
pr.slug, mine, exc,
|
|
1456
|
+
)
|
|
1457
|
+
return
|
|
1458
|
+
|
|
1459
|
+
others = [login for login in pr.requested_reviewers if login != mine]
|
|
1460
|
+
left = ", ".join(others) if others else "no other reviewers"
|
|
1461
|
+
kind = withdrawn_kind(pr.head_sha)
|
|
1462
|
+
if self.state.pinged(pr.full_name, pr.number, kind):
|
|
1463
|
+
# The request came back at a head this daemon already closed out.
|
|
1464
|
+
# Someone asked for another look at code that still carries its
|
|
1465
|
+
# approve -- and the daemon has just taken their request away, so
|
|
1466
|
+
# say so where they will find it.
|
|
1467
|
+
log.warning(
|
|
1468
|
+
"%s round close-out: the review request for %s came back at "
|
|
1469
|
+
"head %s and was withdrawn AGAIN — the approve at that head "
|
|
1470
|
+
"still stands (%s), so no round is owed; only a new commit "
|
|
1471
|
+
"opens one. Verdict of record %s. Left in place: %s",
|
|
1472
|
+
pr.slug, mine, pr.head_sha[:8], why, verdict.url, left,
|
|
1473
|
+
)
|
|
1474
|
+
return
|
|
1475
|
+
self.state.record_ping(pr.full_name, pr.number, kind)
|
|
1476
|
+
log.info(
|
|
1477
|
+
"%s round close-out: withdrew the dangling review request for %s — "
|
|
1478
|
+
"%s; verdict of record %s at head %s. Left in place: %s",
|
|
1479
|
+
pr.slug, mine, why, verdict.url, pr.head_sha[:8], left,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
def _warn_identity_drift(self, pr: PullRequest) -> None:
|
|
1483
|
+
"""Page once when the round's write-up landed under a login GitHub does
|
|
1484
|
+
not hold the request against.
|
|
1485
|
+
|
|
1486
|
+
This is the #298 shape: the request names one identity, the newest
|
|
1487
|
+
review on the PR carries another, and GitHub only ever consumes a
|
|
1488
|
+
request when the REQUESTED login submits. Withdrawing the request
|
|
1489
|
+
cleans up this PR; the config that produced it will produce the next
|
|
1490
|
+
one too, so it is worth one loud line naming both identities.
|
|
1491
|
+
|
|
1492
|
+
Diagnostic only -- it never gates the removal, and an unreadable review
|
|
1493
|
+
list just means no warning, not a stalled close-out.
|
|
1494
|
+
|
|
1495
|
+
The extra read it needs is bounded to once per (PR, head); see
|
|
1496
|
+
drift_probe_kind for why that bound exists at all.
|
|
1497
|
+
|
|
1498
|
+
In `--dry-run` this touches the ping ledger in NEITHER direction: it
|
|
1499
|
+
always probes, always warns, and records nothing. Both halves matter.
|
|
1500
|
+
Writing would let a diagnostic pass durably silence the daemon it was
|
|
1501
|
+
run to diagnose -- `state_db` has no dry-run branch, so the rows land in
|
|
1502
|
+
the same `state.db` production reads, and the alarm is once-per-PR. And
|
|
1503
|
+
reading would silence the DIAGNOSTIC instead: a production pass that
|
|
1504
|
+
already probed this head would make the operator's dry-run print
|
|
1505
|
+
nothing. The cost is one review read per dry-run poll, which is the
|
|
1506
|
+
right trade -- dry-run withdraws nothing, so the PR stays in the poll
|
|
1507
|
+
set regardless, and the probe bound exists to protect the production
|
|
1508
|
+
failure loop, not a run an operator is watching.
|
|
1509
|
+
"""
|
|
1510
|
+
record = not self.config.dry_run
|
|
1511
|
+
|
|
1512
|
+
probe = drift_probe_kind(pr.head_sha)
|
|
1513
|
+
if record and self.state.pinged(pr.full_name, pr.number, probe):
|
|
1514
|
+
return
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
reviews = self.github.reviews(pr.owner, pr.repo, pr.number)
|
|
1518
|
+
except Exception as exc:
|
|
1519
|
+
# Deliberately not recorded: a read that failed settles nothing.
|
|
1520
|
+
log.debug("%s: could not read reviews for the drift check: %s", pr.slug, exc)
|
|
1521
|
+
return
|
|
1522
|
+
if record:
|
|
1523
|
+
self.state.record_ping(pr.full_name, pr.number, probe)
|
|
1524
|
+
|
|
1525
|
+
substantive = [r for r in reviews if r.is_substantive]
|
|
1526
|
+
if not substantive:
|
|
1527
|
+
return
|
|
1528
|
+
newest = max(substantive, key=lambda r: r.submitted_at)
|
|
1529
|
+
if newest.author == self.github.login:
|
|
1530
|
+
return
|
|
1531
|
+
|
|
1532
|
+
kind = identity_drift_kind(self.github.login, newest.author)
|
|
1533
|
+
if record:
|
|
1534
|
+
if self.state.pinged(pr.full_name, pr.number, kind):
|
|
1535
|
+
return
|
|
1536
|
+
self.state.record_ping(pr.full_name, pr.number, kind)
|
|
1537
|
+
log.warning(
|
|
1538
|
+
"IDENTITY DRIFT on %s: the review request is held against %r but "
|
|
1539
|
+
"the round's newest review was submitted by %r (%s). GitHub only "
|
|
1540
|
+
"consumes a review request when the REQUESTED login submits, so "
|
|
1541
|
+
"native review consumption cannot work for this deployment: every "
|
|
1542
|
+
"closed round will leave a dangling request for the daemon to "
|
|
1543
|
+
"withdraw. Point reviewer_login/reviewer_token_env at one identity "
|
|
1544
|
+
"and have that identity post the verdict of record.",
|
|
1545
|
+
pr.slug,
|
|
1546
|
+
self.github.login,
|
|
1547
|
+
newest.author,
|
|
1548
|
+
newest.url or "no url",
|
|
1549
|
+
)
|
|
1550
|
+
|
|
1282
1551
|
# -- reap sweep --------------------------------------------------------
|
|
1283
1552
|
|
|
1284
1553
|
def sweep_sessions(self) -> int:
|
|
@@ -2154,6 +2423,7 @@ class ReviewWatcher:
|
|
|
2154
2423
|
reaped=reaped,
|
|
2155
2424
|
posted=counts[Action.POSTED],
|
|
2156
2425
|
awaiting_post=counts[Action.AWAITING_POST],
|
|
2426
|
+
abandoned=counts[Action.ABANDONED],
|
|
2157
2427
|
stages=stages,
|
|
2158
2428
|
)
|
|
2159
2429
|
|
|
@@ -36,12 +36,20 @@ def run(
|
|
|
36
36
|
check: bool = True,
|
|
37
37
|
cwd: "str | os.PathLike[str] | None" = None,
|
|
38
38
|
env: "Mapping[str, str] | None" = None,
|
|
39
|
+
stdin: "str | None" = None,
|
|
39
40
|
) -> str:
|
|
40
41
|
"""Run a command, return stdout. Never uses shell=True.
|
|
41
42
|
|
|
42
43
|
`env`, when given, REPLACES the child's environment rather than adding to
|
|
43
44
|
it -- that is what makes it an identity guarantee: a credential the caller
|
|
44
45
|
did not put in the mapping cannot reach the child by inheritance.
|
|
46
|
+
|
|
47
|
+
`stdin` feeds the child's standard input. It exists for `gh api --input -`:
|
|
48
|
+
`gh`'s `-f key=value` fields are encoded differently across `gh` versions
|
|
49
|
+
(`key[]=v` is a JSON array only on modern builds, a string field named
|
|
50
|
+
`key[]` on the 2.4.0 this client targets), so a request whose body must
|
|
51
|
+
have a particular JSON SHAPE is built by the caller and piped in, where no
|
|
52
|
+
version gets a say in it.
|
|
45
53
|
"""
|
|
46
54
|
log.debug("exec: %s", " ".join(argv))
|
|
47
55
|
try:
|
|
@@ -52,6 +60,7 @@ def run(
|
|
|
52
60
|
timeout=timeout,
|
|
53
61
|
cwd=str(cwd) if cwd is not None else None,
|
|
54
62
|
env=dict(env) if env is not None else None,
|
|
63
|
+
input=stdin,
|
|
55
64
|
)
|
|
56
65
|
except subprocess.TimeoutExpired as exc:
|
|
57
66
|
raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
|
|
@@ -66,9 +75,10 @@ def run_json(
|
|
|
66
75
|
*,
|
|
67
76
|
timeout: int = 60,
|
|
68
77
|
env: "Mapping[str, str] | None" = None,
|
|
78
|
+
stdin: "str | None" = None,
|
|
69
79
|
):
|
|
70
80
|
"""Run a command whose stdout is JSON."""
|
|
71
|
-
out = run(argv, timeout=timeout, env=env).strip()
|
|
81
|
+
out = run(argv, timeout=timeout, env=env, stdin=stdin).strip()
|
|
72
82
|
if not out:
|
|
73
83
|
return None
|
|
74
84
|
try:
|
|
@@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
128
128
|
reaped INTEGER NOT NULL,
|
|
129
129
|
posted INTEGER NOT NULL DEFAULT 0,
|
|
130
130
|
awaiting_post INTEGER NOT NULL DEFAULT 0,
|
|
131
|
+
abandoned INTEGER NOT NULL DEFAULT 0,
|
|
131
132
|
stages_json TEXT NOT NULL
|
|
132
133
|
);
|
|
133
134
|
"""
|
|
@@ -140,6 +141,7 @@ CREATE TABLE IF NOT EXISTS poll_snapshots (
|
|
|
140
141
|
_SNAPSHOT_ADDED_COLUMNS = (
|
|
141
142
|
("posted", "INTEGER NOT NULL DEFAULT 0"),
|
|
142
143
|
("awaiting_post", "INTEGER NOT NULL DEFAULT 0"),
|
|
144
|
+
("abandoned", "INTEGER NOT NULL DEFAULT 0"),
|
|
143
145
|
)
|
|
144
146
|
|
|
145
147
|
|
|
@@ -509,6 +511,23 @@ class State:
|
|
|
509
511
|
row = self.get_verdict_post(repo, number, round_)
|
|
510
512
|
return bool(row is not None and row["abandoned_at"])
|
|
511
513
|
|
|
514
|
+
def abandoned_rounds(self, repo: str, number: int) -> int:
|
|
515
|
+
"""How many of this PR's rounds will never have a native verdict.
|
|
516
|
+
|
|
517
|
+
Each one leaves a permanent hole between the envelope count and the
|
|
518
|
+
review count -- the envelope is on the task forever, the review record
|
|
519
|
+
never exists -- so every later comparison of the two has to subtract
|
|
520
|
+
it. Without that the daemon reads the hole as "the newest round has no
|
|
521
|
+
native verdict" and posts a redundant one on the round AFTER each
|
|
522
|
+
abandonment.
|
|
523
|
+
"""
|
|
524
|
+
row = self._db.execute(
|
|
525
|
+
"SELECT COUNT(*) AS n FROM verdict_posts "
|
|
526
|
+
"WHERE repo=? AND number=? AND abandoned_at IS NOT NULL",
|
|
527
|
+
(repo, number),
|
|
528
|
+
).fetchone()
|
|
529
|
+
return int(row["n"]) if row else 0
|
|
530
|
+
|
|
512
531
|
def record_verdict_post(
|
|
513
532
|
self, repo: str, number: int, round_: int, review_url: str
|
|
514
533
|
) -> None:
|
|
@@ -551,6 +570,7 @@ class State:
|
|
|
551
570
|
reaped: int = 0,
|
|
552
571
|
posted: int = 0,
|
|
553
572
|
awaiting_post: int = 0,
|
|
573
|
+
abandoned: int = 0,
|
|
554
574
|
stages: list[dict],
|
|
555
575
|
) -> None:
|
|
556
576
|
"""Append one poll-pass observation, then prune to the newest
|
|
@@ -566,8 +586,8 @@ class State:
|
|
|
566
586
|
"INSERT INTO poll_snapshots "
|
|
567
587
|
"(ts, duration_ms, candidates, spawned, stale_reenqueued, "
|
|
568
588
|
"in_flight, deferred, converged, capped, escalated, skipped, "
|
|
569
|
-
"reaped, posted, awaiting_post, stages_json) "
|
|
570
|
-
"VALUES (
|
|
589
|
+
"reaped, posted, awaiting_post, abandoned, stages_json) "
|
|
590
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
571
591
|
(
|
|
572
592
|
int(time.time()),
|
|
573
593
|
duration_ms,
|
|
@@ -583,6 +603,7 @@ class State:
|
|
|
583
603
|
reaped,
|
|
584
604
|
posted,
|
|
585
605
|
awaiting_post,
|
|
606
|
+
abandoned,
|
|
586
607
|
json.dumps(stages, separators=(",", ":")),
|
|
587
608
|
),
|
|
588
609
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.16.5
|
|
@@ -208,6 +208,8 @@ section.panel {
|
|
|
208
208
|
border-color: var(--status-pending); background: var(--status-pending-bg); }
|
|
209
209
|
.pill.converged, .pill.posted { color: var(--status-in-progress);
|
|
210
210
|
border-color: var(--status-in-progress); background: var(--status-in-progress-bg); }
|
|
211
|
+
.pill.abandoned { color: var(--status-cancelled);
|
|
212
|
+
border-color: var(--status-cancelled); background: var(--status-cancelled-bg); }
|
|
211
213
|
.pill.awaiting-post { color: var(--status-blocked);
|
|
212
214
|
border-color: var(--status-blocked); background: var(--status-blocked-bg); }
|
|
213
215
|
.pill.capped { color: var(--status-cancelled);
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.16.3
|
|
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
|
|
File without changes
|