alissa-tools-github-revloop 0.18.0__tar.gz → 0.20.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.18.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.20.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/alissa.py +36 -7
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/ghclient.py +260 -16
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/loop.py +86 -4
- alissa_tools_github_revloop-0.20.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.18.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.20.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -68,17 +68,39 @@ class TaskListFlags:
|
|
|
68
68
|
digest: bool = False
|
|
69
69
|
|
|
70
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
|
+
|
|
71
86
|
def _advertises(helptext: str, flag: str) -> bool:
|
|
72
87
|
"""Whether `flag` appears as an OPTION in a CLI help listing.
|
|
73
88
|
|
|
74
|
-
Anchored to the
|
|
75
|
-
`-h, --help
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
79
100
|
"""
|
|
80
101
|
return re.search(
|
|
81
|
-
rf"(?m)
|
|
102
|
+
rf"(?m)^[ \t]{{0,{MAX_OPTION_INDENT}}}(?:-\w,\s+)?{re.escape(flag)}(?![\w-])",
|
|
103
|
+
helptext,
|
|
82
104
|
) is not None
|
|
83
105
|
|
|
84
106
|
|
|
@@ -408,7 +430,14 @@ class Alissa:
|
|
|
408
430
|
data = run_json(plain, timeout=90) or []
|
|
409
431
|
|
|
410
432
|
tasks = self._tasks_from(data)
|
|
411
|
-
if tasks or argv == plain:
|
|
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).
|
|
412
441
|
return tasks
|
|
413
442
|
|
|
414
443
|
# An empty answer from a narrowed call. A genuinely empty corpus is
|
|
@@ -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,147 @@ 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, stated exactly, because it is the sentence an operator would size
|
|
903
|
+
rate-limit headroom from: this path costs one call for the run listing
|
|
904
|
+
plus one per KEPT workflow, where the read it replaces cost one.
|
|
905
|
+
|
|
906
|
+
Nothing caches that on the production path. `loop._dry_run_rollups` is
|
|
907
|
+
the only rollup memo and only the dry-run branch reads it, so what
|
|
908
|
+
bounds how often the cost is paid is the POLL INTERVAL -- both while
|
|
909
|
+
`_checks_at_spawn` holds a round on `checks_spawn_wait_seconds` and
|
|
910
|
+
while `_gate_on_checks` holds a finished verdict on
|
|
911
|
+
`checks_wait_seconds`, each of which re-reads the rollup every pass.
|
|
912
|
+
|
|
913
|
+
Nor is the fan-out bounded. CHECK_RUN_PAGE_LIMIT bounds the run
|
|
914
|
+
LISTING; the number of kept workflows is whatever the sha carries, and
|
|
915
|
+
each one costs a `jobs` call with `_api`'s 60s timeout, so a
|
|
916
|
+
many-workflow sha turns one rollup read into a long synchronous stall
|
|
917
|
+
inside a poll pass that has other PRs to decide. On this fleet's repos
|
|
918
|
+
(one or two workflows per commit) it is two or three calls; that is a
|
|
919
|
+
fact about these repos, not a bound this code enforces.
|
|
920
|
+
|
|
921
|
+
The trade is still worth taking where it applies: the alternative there
|
|
922
|
+
is a permanent CHECKS_UNKNOWN, which costs one call and can never
|
|
923
|
+
approve.
|
|
924
|
+
"""
|
|
925
|
+
runs = self._rollup_listing(
|
|
926
|
+
f"repos/{owner}/{repo}/actions/runs",
|
|
927
|
+
"workflow_runs",
|
|
928
|
+
"workflow runs",
|
|
929
|
+
params=(f"head_sha={sha}",),
|
|
930
|
+
)
|
|
931
|
+
out: list[CheckContext] = []
|
|
932
|
+
for run_ in self._latest_run_per_workflow(runs):
|
|
933
|
+
out.extend(self._run_contexts(owner, repo, run_))
|
|
934
|
+
return out
|
|
935
|
+
|
|
936
|
+
@staticmethod
|
|
937
|
+
def _latest_run_per_workflow(runs: list[dict]) -> list[dict]:
|
|
938
|
+
"""One run per workflow AND trigger event: the most recent.
|
|
939
|
+
|
|
940
|
+
Two different facts are being separated here, and collapsing them was a
|
|
941
|
+
fail-open bug.
|
|
942
|
+
|
|
943
|
+
SUPERSEDED ATTEMPTS collapse. A sha carries more than one run of the
|
|
944
|
+
same workflow for the same event whenever it was triggered again -- a
|
|
945
|
+
re-run shares its run id, a re-dispatch does not. Reading all of them
|
|
946
|
+
would judge the commit on a superseded attempt: an earlier failed run
|
|
947
|
+
whose re-trigger passed would hold the head red forever.
|
|
948
|
+
|
|
949
|
+
DISTINCT EVENTS DO NOT. A workflow declaring `on: [push, pull_request]`
|
|
950
|
+
produces a `push` run and a `pull_request` run for the same sha, and
|
|
951
|
+
GitHub publishes a check run per job for BOTH -- so the read this
|
|
952
|
+
stands in for reports both. Keying on `workflow_id` alone dropped the
|
|
953
|
+
lower `run_number` of the pair unread, and when the dropped one was the
|
|
954
|
+
red one the fallback answered GREEN for a commit whose real rollup is
|
|
955
|
+
red. That is the fail-open direction this whole component exists to
|
|
956
|
+
avoid, so the key carries `event` as well. A re-run preserves its run's
|
|
957
|
+
`event`, so the superseded-attempt collapse above is unaffected.
|
|
958
|
+
|
|
959
|
+
Recency is `run_number` then `id`, both monotonic per workflow and both
|
|
960
|
+
integers -- deliberately not a timestamp string, which is the field a
|
|
961
|
+
re-run rewrites. A run with no `workflow_id` is not grouped with
|
|
962
|
+
anything (its position in the listing is its key), because collapsing
|
|
963
|
+
unidentified runs together would silently drop contexts.
|
|
964
|
+
"""
|
|
965
|
+
latest: dict[object, dict] = {}
|
|
966
|
+
for index, run_ in enumerate(runs):
|
|
967
|
+
workflow_id = run_.get("workflow_id")
|
|
968
|
+
key: object = (
|
|
969
|
+
("workflow", workflow_id, str(run_.get("event") or ""))
|
|
970
|
+
if workflow_id is not None
|
|
971
|
+
else ("unidentified", index)
|
|
972
|
+
)
|
|
973
|
+
current = latest.get(key)
|
|
974
|
+
if current is None or _run_order(run_) >= _run_order(current):
|
|
975
|
+
latest[key] = run_
|
|
976
|
+
return list(latest.values())
|
|
977
|
+
|
|
978
|
+
def _run_contexts(self, owner: str, repo: str, run_: dict) -> list[CheckContext]:
|
|
979
|
+
"""One workflow run's jobs as contexts, or the run itself if it has none.
|
|
980
|
+
|
|
981
|
+
A run exposes no jobs while it is still queued, and the empty list must
|
|
982
|
+
never read as "nothing failing here". So a run with no readable jobs
|
|
983
|
+
contributes ITSELF as one context, under the same completed/conclusion
|
|
984
|
+
rule: not completed reads as running (the case the gate exists for), and
|
|
985
|
+
a completed run with no jobs is judged on its own conclusion rather than
|
|
986
|
+
vanishing from the rollup.
|
|
987
|
+
"""
|
|
988
|
+
run_id = run_.get("id")
|
|
989
|
+
run_name = str(run_.get("name") or run_.get("display_title") or "workflow run")
|
|
990
|
+
run_url = str(run_.get("html_url") or "")
|
|
991
|
+
jobs: list[dict] = []
|
|
992
|
+
if run_id is not None:
|
|
993
|
+
jobs = self._rollup_listing(
|
|
994
|
+
f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs", "jobs", "jobs"
|
|
995
|
+
)
|
|
996
|
+
if not jobs:
|
|
997
|
+
return [
|
|
998
|
+
CheckContext(
|
|
999
|
+
name=run_name,
|
|
1000
|
+
conclusion=_completed_conclusion(run_),
|
|
1001
|
+
url=run_url,
|
|
1002
|
+
)
|
|
1003
|
+
]
|
|
1004
|
+
return [
|
|
1005
|
+
CheckContext(
|
|
1006
|
+
name=str(job.get("name") or "unnamed job"),
|
|
1007
|
+
conclusion=_completed_conclusion(job),
|
|
1008
|
+
url=str(job.get("html_url") or run_url),
|
|
1009
|
+
)
|
|
1010
|
+
for job in jobs
|
|
1011
|
+
]
|
|
1012
|
+
|
|
769
1013
|
def _commit_statuses(self, owner: str, repo: str, sha: str) -> list[CheckContext]:
|
|
770
1014
|
"""The commit's legacy statuses, one context each.
|
|
771
1015
|
|
|
@@ -407,6 +407,22 @@ CHECKS_FAILING_LINE = "- `{name}` — {conclusion}{url}"
|
|
|
407
407
|
|
|
408
408
|
# Prepended when the gate held the approve for the whole wait bound and the
|
|
409
409
|
# rollup still had not settled, so the verdict lands as a COMMENT.
|
|
410
|
+
# Prepended to an APPROVE whose green came from the Actions-API fallback rather
|
|
411
|
+
# than the check-runs rollup. Not a caveat on the review -- the code verdict is
|
|
412
|
+
# unchanged -- but on the EVIDENCE behind the approve: the fallback cannot see a
|
|
413
|
+
# check run posted by a third-party check app, so "green" here means "every
|
|
414
|
+
# Actions-produced context passed", which is the same thing only on an
|
|
415
|
+
# Actions-only repo.
|
|
416
|
+
CHECKS_FALLBACK_LEAD = (
|
|
417
|
+
"**CI rollup at `{sha}` read through the Actions API.** This deployment's "
|
|
418
|
+
"credential cannot read check runs (GitHub does not offer the `Checks` "
|
|
419
|
+
"permission on fine-grained PATs), so the head was confirmed green from its "
|
|
420
|
+
"workflow runs and jobs instead. That read sees every GitHub-Actions "
|
|
421
|
+
"context and no third-party check app's, so on an Actions-only repo it is "
|
|
422
|
+
"the same answer — flagging the path because an approve is the operator's "
|
|
423
|
+
"merge cue.\n\n"
|
|
424
|
+
)
|
|
425
|
+
|
|
410
426
|
CHECKS_UNSETTLED_LEAD = (
|
|
411
427
|
"**Recorded as a comment, not an approve — the CI rollup at `{sha}` never "
|
|
412
428
|
"concluded.**\n\n"
|
|
@@ -466,10 +482,18 @@ CHECKS_STILL_RUNNING = "Still running at the bound: {names}."
|
|
|
466
482
|
# which waited on nothing and so cannot describe anything as being "at the
|
|
467
483
|
# bound" (see CHECKS_AT_SPAWN_GATE_OFF).
|
|
468
484
|
CHECKS_GATE_OFF_DETAIL = "Still running when the round was queued: {names}."
|
|
485
|
+
# Deliberately does NOT tell the operator to grant `checks: read`, which is what
|
|
486
|
+
# it said before the Actions fallback existed. GitHub does not offer the `Checks`
|
|
487
|
+
# permission on fine-grained PATs at all, and a rollup that reaches UNKNOWN on
|
|
488
|
+
# such a deployment has ALREADY tried the Actions read and failed -- so that
|
|
489
|
+
# advice pointed at the one action that provably cannot work, at the moment the
|
|
490
|
+
# operator is already blocked. The remaining causes are the ones named here.
|
|
469
491
|
CHECKS_UNREADABLE = (
|
|
470
492
|
"The rollup could not be read: `{why}`. An unreadable rollup is not a green "
|
|
471
|
-
"one
|
|
472
|
-
"
|
|
493
|
+
"one. On a credential that cannot read check runs the loop already fell "
|
|
494
|
+
"back to the Actions API, so reaching this point means that read failed "
|
|
495
|
+
"too — check the credential's `Actions: Read`, or whether the listing was "
|
|
496
|
+
"truncated (more contexts than the page bound)."
|
|
473
497
|
)
|
|
474
498
|
|
|
475
499
|
# The operator page for a native verdict post that keeps failing. Loud on
|
|
@@ -586,6 +610,25 @@ _CHECKS_BEFORE_VERDICT = (
|
|
|
586
610
|
"never conclude within that bound, do NOT approve either — request_changes, "
|
|
587
611
|
"saying plainly that the checks at that sha never settled; the next round can "
|
|
588
612
|
"approve the same code on a green head. "
|
|
613
|
+
"(2b) IF THE CHECK-RUNS READ IS FORBIDDEN — a `403 Resource not accessible "
|
|
614
|
+
"by personal access token` means this deployment's credential cannot read "
|
|
615
|
+
"check runs at all, NOT that the commit has no checks: never approve on it. "
|
|
616
|
+
"Read the same commit's CI through the Actions API instead, which the same "
|
|
617
|
+
"credential can read: `gh api "
|
|
618
|
+
"'repos/<org>/<repo>/actions/runs?head_sha=<sha>' --jq "
|
|
619
|
+
"'.workflow_runs[]|[.id,.name,.status,.conclusion,.html_url]|@tsv'`, keep "
|
|
620
|
+
"only the MOST RECENT run per workflow (one sha can carry several runs of "
|
|
621
|
+
"the same workflow, and a superseded attempt is not the answer), then for "
|
|
622
|
+
"each kept run `gh api repos/<org>/<repo>/actions/runs/<run_id>/jobs --jq "
|
|
623
|
+
"'.jobs[]|[.name,.status,.conclusion,.html_url]|@tsv'`. Judge those jobs "
|
|
624
|
+
"under exactly the rules above — concluded and none failed to approve, wait "
|
|
625
|
+
"and re-read within the same bound while any is still running, "
|
|
626
|
+
"request_changes naming the job and linking its run if one failed. A run "
|
|
627
|
+
"that is not `completed` and exposes no jobs yet is still running, never "
|
|
628
|
+
"green. Say in your verdict that the rollup was read through the Actions "
|
|
629
|
+
"API: it sees only Actions-produced contexts, so an operator has to be able "
|
|
630
|
+
"to tell that read from the full one. If the Actions read is forbidden too, "
|
|
631
|
+
"the rollup is unreadable — do not approve. "
|
|
589
632
|
)
|
|
590
633
|
|
|
591
634
|
# The floor under the wait the directive asks a session to observe. The bound
|
|
@@ -1570,7 +1613,19 @@ class ReviewWatcher:
|
|
|
1570
1613
|
"an unreadable task is not a wrong one)",
|
|
1571
1614
|
pr.slug, cached,
|
|
1572
1615
|
)
|
|
1573
|
-
elif self.state.consume_review_task_miss(
|
|
1616
|
+
elif self._pass_tasks is None and self.state.consume_review_task_miss(
|
|
1617
|
+
pr.full_name, pr.number
|
|
1618
|
+
):
|
|
1619
|
+
# The negative answer is worth having only while the corpus has NOT
|
|
1620
|
+
# been fetched this pass. Once the memo is populated -- some earlier
|
|
1621
|
+
# PR of this pass re-armed and paid for it -- answering from the
|
|
1622
|
+
# ledger saves nothing at all (the search is a list comprehension
|
|
1623
|
+
# over rows already in memory) and still spends the window, so a
|
|
1624
|
+
# review task created since this PR's last search would stay
|
|
1625
|
+
# invisible for up to `review_task_miss_ttl_polls` more polls with
|
|
1626
|
+
# the evidence for it sitting right there. Order-dependent within a
|
|
1627
|
+
# pass, and therefore a partial win -- but never a read added, and
|
|
1628
|
+
# sometimes a whole window of latency removed (PR #88 round 1).
|
|
1574
1629
|
log.debug(
|
|
1575
1630
|
"%s: a recent search found no review task and the answer has "
|
|
1576
1631
|
"polls left — skipping the corpus fetch this pass", pr.slug,
|
|
@@ -1592,6 +1647,19 @@ class ReviewWatcher:
|
|
|
1592
1647
|
# `_pass_task_list` and the pass turns it into one PR's SKIPPED),
|
|
1593
1648
|
# so a transient CLI failure can never buy itself a window of
|
|
1594
1649
|
# silence.
|
|
1650
|
+
#
|
|
1651
|
+
# KNOWN GAP, not reachable on any shipping CLI (PR #88 round 1): a
|
|
1652
|
+
# search "ran" is only as good as the corpus it ran over. The two
|
|
1653
|
+
# runtime disproofs in `Alissa.list_tasks` catch a narrowed call that
|
|
1654
|
+
# FAILS and one that answers EMPTY; neither catches one that answers
|
|
1655
|
+
# NON-EMPTY but INCOMPLETE -- a `--status` the API accepts and serves
|
|
1656
|
+
# partially, say. The search then legitimately finds nothing and this
|
|
1657
|
+
# line makes that wrong answer STICKY for a window, where before the
|
|
1658
|
+
# negative cache it was wrong but self-correcting on the next pass.
|
|
1659
|
+
# No CLI generation offers `--status` or `--view digest` yet, so
|
|
1660
|
+
# nothing can reach it today; the first one that does must prove
|
|
1661
|
+
# itself before this row may be trusted. See the operator's-gate
|
|
1662
|
+
# checklist on PR #88.
|
|
1595
1663
|
self.state.record_review_task_miss(
|
|
1596
1664
|
pr.full_name, pr.number, self.config.review_task_miss_ttl_polls
|
|
1597
1665
|
)
|
|
@@ -2394,10 +2462,24 @@ class ReviewWatcher:
|
|
|
2394
2462
|
rollup = self.github.check_rollup(pr.owner, pr.repo, judged)
|
|
2395
2463
|
|
|
2396
2464
|
if rollup.state == CHECKS_GREEN:
|
|
2397
|
-
|
|
2465
|
+
# `info`, not `debug`: this is the line an operator reads to find
|
|
2466
|
+
# out WHICH read path called the head green, and on the deployment
|
|
2467
|
+
# that needs the answer the fallback is every read.
|
|
2468
|
+
log.info(
|
|
2398
2469
|
"%s round %d: CI rollup at %s is %s — approving as usual",
|
|
2399
2470
|
pr.slug, round_, judged[:8], rollup.summary,
|
|
2400
2471
|
)
|
|
2472
|
+
if rollup.via_actions_fallback:
|
|
2473
|
+
# The one state where the marker had nowhere PR-visible to go.
|
|
2474
|
+
# Every other path that surfaces `summary` (the hold activity
|
|
2475
|
+
# comment, the degraded-verdict lead) is reachable only from
|
|
2476
|
+
# PENDING or UNKNOWN, so "approved on green" -- the claim an
|
|
2477
|
+
# operator acts on -- was the single verdict that did not say
|
|
2478
|
+
# it came from the narrower read.
|
|
2479
|
+
return ChecksGate(
|
|
2480
|
+
lead=CHECKS_FALLBACK_LEAD.format(sha=judged[:8]),
|
|
2481
|
+
state=CHECKS_GREEN,
|
|
2482
|
+
)
|
|
2401
2483
|
return ChecksGate()
|
|
2402
2484
|
|
|
2403
2485
|
if rollup.state == CHECKS_RED:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.20.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.18.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|