alissa-tools-github-revloop 0.18.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.
Files changed (32) hide show
  1. {alissa_tools_github_revloop-0.18.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.19.0}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/alissa.py +36 -7
  3. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/ghclient.py +232 -16
  4. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/loop.py +45 -1
  5. alissa_tools_github_revloop-0.19.0/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_revloop-0.18.0/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/config.py +0 -0
  18. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  19. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  20. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  21. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.18.0 → alissa_tools_github_revloop-0.19.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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 start of a help line (allowing a short alias in front, as in
75
- `-h, --help`) so a flag merely NAMED in some other option's prose -- "Pair
76
- with --include-shared" is in this very help text -- is not read as an offer
77
- of that flag. The trailing guard rejects a longer flag that merely starts
78
- with this one (`--self-only` is not `--self`).
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)^\s*(?:-\w,\s+)?{re.escape(flag)}(?![\w-])", helptext
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
- return f"red — failing: {check_names(self.failing)}"
260
- if self.state == CHECKS_PENDING:
261
- return f"pending — still running: {check_names(self.running)}"
262
- if self.state == CHECKS_UNKNOWN:
263
- return f"unreadable — {self.unreadable or 'no reason recorded'}"
264
- return f"green — {self.total} context(s), none failing or running"
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 rollup_of(contexts: "list[CheckContext]") -> CheckRollup:
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, failing=failing, running=running, total=len(contexts)
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
- contexts.extend(self._check_runs(owner, repo, sha))
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(self, path: str, key: str, what: str) -> list[dict]:
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
@@ -1570,7 +1589,19 @@ class ReviewWatcher:
1570
1589
  "an unreadable task is not a wrong one)",
1571
1590
  pr.slug, cached,
1572
1591
  )
1573
- elif self.state.consume_review_task_miss(pr.full_name, pr.number):
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).
1574
1605
  log.debug(
1575
1606
  "%s: a recent search found no review task and the answer has "
1576
1607
  "polls left — skipping the corpus fetch this pass", pr.slug,
@@ -1592,6 +1623,19 @@ class ReviewWatcher:
1592
1623
  # `_pass_task_list` and the pass turns it into one PR's SKIPPED),
1593
1624
  # so a transient CLI failure can never buy itself a window of
1594
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.
1595
1639
  self.state.record_review_task_miss(
1596
1640
  pr.full_name, pr.number, self.config.review_task_miss_ttl_polls
1597
1641
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.18.0
3
+ Version: 0.19.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera