alissa-tools-github-genloop 0.1.2__tar.gz → 0.1.4__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 (28) hide show
  1. {alissa_tools_github_genloop-0.1.2/src/main/alissa_tools_github_genloop.egg-info → alissa_tools_github_genloop-0.1.4}/PKG-INFO +1 -1
  2. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/github.py +134 -6
  3. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/loop.py +10 -5
  4. alissa_tools_github_genloop-0.1.4/src/main/alissa/tools/github/genloop/version +1 -0
  5. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4/src/main/alissa_tools_github_genloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_genloop-0.1.2/src/main/alissa/tools/github/genloop/version +0 -1
  7. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/LICENSE +0 -0
  8. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/MANIFEST.in +0 -0
  9. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/NOTICE +0 -0
  10. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/README.md +0 -0
  11. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/requirements.txt +0 -0
  12. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/setup.cfg +0 -0
  13. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/setup.py +0 -0
  14. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/__init__.py +0 -0
  15. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/__main__.py +0 -0
  16. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/alissa_cli.py +0 -0
  17. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/alissa_client.py +0 -0
  18. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/config.py +0 -0
  19. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/loop_events.py +0 -0
  20. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/pass_directive.py +0 -0
  21. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/plan.py +0 -0
  22. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/proc.py +0 -0
  23. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/state.py +0 -0
  24. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/version.py +0 -0
  25. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa_tools_github_genloop.egg-info/SOURCES.txt +0 -0
  26. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa_tools_github_genloop.egg-info/dependency_links.txt +0 -0
  27. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa_tools_github_genloop.egg-info/entry_points.txt +0 -0
  28. {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa_tools_github_genloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-genloop
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-GENLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1,8 +1,29 @@
1
1
  """The GitHub surface the seat drives, all through `gh api` (which keeps its
2
2
  own auth): the branch read in the plans repository, the PR open with body and
3
- trailers, the reviewer request, the `plan-lint` check-run read, the plan-PR
4
- listing for the day counter, closing an invalid plan, and the comments a
5
- rule is written from.
3
+ trailers, the reviewer request, the `plan-lint` read, the plan-PR listing
4
+ for the day counter, closing an invalid plan, and the comments a rule is
5
+ written from.
6
+
7
+ THE PLAN-LINT READ HAS TWO PATHS
8
+ ================================
9
+ The check-runs API is the first: the newest `plan-lint` check run on the head.
10
+ A fine-grained personal access token cannot read check runs at all (403), so
11
+ on a 403 or 404 the read falls back to the Actions runs for the head SHA, the
12
+ same fallback orcloop's `ci-green` read uses. It reads only the run of record:
13
+ the newest plan-lint run of the trusted event (`pull_request_target`, the job
14
+ that publishes the `plan-lint` check run), mapped onto the check run's
15
+ `{conclusion}` shape. The offline `pull_request` run never counts, as it never
16
+ does on the check-runs path. A `failure` is red only when the job's publish
17
+ step (`Publish the plan-lint check run …`, `if: always()`) succeeded: that
18
+ step is the only thing that creates the check run, and it publishes `failure`
19
+ itself when the linter never reported. When the publish step failed (a 5xx
20
+ on its POST), no check run exists, so the fallback reads None, as the
21
+ check-runs path would; the run's jobs (`actions/runs/<id>/jobs`) say which.
22
+ A denied 403 ("Resource not accessible") is latched for the process -- every
23
+ later read goes straight to the Actions path;
24
+ any other 403 (a secondary rate limit) falls back for that read only. When
25
+ the Actions read fails too, the warning is logged once per PR per head and
26
+ `LintUnreadable` is raised, which the caller skips quietly.
6
27
 
7
28
  The seat touches ONE repository on GitHub -- the plans repository. The fed
8
29
  repos are the pass session's to read (with `gh api`, never cloned); nothing
@@ -25,6 +46,19 @@ from .proc import CommandError, run
25
46
  log = logging.getLogger(__name__)
26
47
 
27
48
  LINT_CHECK_NAME = "plan-lint"
49
+ # The plan-lint workflow of the plans template, matched against an Actions
50
+ # run's `path` or `name` (`GitHub(lint_workflow=...)` names another).
51
+ LINT_WORKFLOW = ".github/workflows/plan-lint.yml"
52
+ # The event whose plan-lint run publishes the `plan-lint` check run -- the
53
+ # template's trusted job (`GitHub(lint_event=...)` names another). Its run's
54
+ # conclusion passes through verbatim, so only `failure` is a red lint, as on
55
+ # the check-runs path; the offline `pull_request` run is never read.
56
+ LINT_EVENT = "pull_request_target"
57
+ # The trusted job's step that publishes the `plan-lint` check run, matched as a
58
+ # prefix of the step name (`GitHub(lint_publish_step=...)` names another).
59
+ LINT_PUBLISH_STEP = "Publish the plan-lint check run"
60
+ # The 403 body of a token that cannot read check runs, as against a rate limit.
61
+ _DENIED_MARKER = "resource not accessible"
28
62
  # A plan PR thread longer than this many pages of 100 comments is read only up to here.
29
63
  COMMENT_PAGES_MAX = 10
30
64
  _STATUS_RE = re.compile(r"HTTP (\d{3})")
@@ -35,6 +69,10 @@ def http_status(exc: CommandError) -> int | None:
35
69
  return int(match.group(1)) if match else None
36
70
 
37
71
 
72
+ class LintUnreadable(CommandError):
73
+ """Neither the check-runs read nor the Actions fallback answered."""
74
+
75
+
38
76
  @dataclass(frozen=True)
39
77
  class PullRequest:
40
78
  number: int
@@ -82,8 +120,17 @@ class Comment:
82
120
 
83
121
 
84
122
  class GitHub:
85
- def __init__(self, *, timeout: int = 60):
123
+ def __init__(self, *, timeout: int = 60, lint_workflow: str = LINT_WORKFLOW, lint_event: str = LINT_EVENT,
124
+ lint_publish_step: str = LINT_PUBLISH_STEP):
86
125
  self.timeout = timeout
126
+ self.lint_workflow = lint_workflow
127
+ self.lint_event = lint_event
128
+ self.lint_publish_step = lint_publish_step
129
+ # Latched on the first 403 from check-runs: this token cannot read them.
130
+ self._check_runs_denied = False
131
+ # (repo, pr) -> the head whose unreadable lint has been logged already;
132
+ # one entry per PR, dropped when the PR's lint reads again.
133
+ self._lint_unreadable_logged: dict[tuple[str, int | None], str] = {}
87
134
 
88
135
  # -- transport -------------------------------------------------------------
89
136
 
@@ -196,8 +243,34 @@ class GitHub:
196
243
  )
197
244
  return [pr for pr in (PullRequest.from_payload(d) for d in (data or []) if isinstance(d, dict)) if pr]
198
245
 
199
- def lint_run(self, repo: str, sha: str) -> dict | None:
200
- """The newest `plan-lint` check run on `sha` (by id), or None."""
246
+ def lint_run(self, repo: str, sha: str, *, pr_number: int | None = None) -> dict | None:
247
+ """The `plan-lint` verdict on `sha` -- the newest check run, or the
248
+ Actions runs when check runs are denied -- or None when there is none."""
249
+ if not self._check_runs_denied:
250
+ try:
251
+ return self._lint_check_run(repo, sha)
252
+ except CommandError as exc:
253
+ status = http_status(exc)
254
+ if status not in (403, 404):
255
+ raise
256
+ if status == 403 and _DENIED_MARKER in (exc.stderr or "").lower():
257
+ self._check_runs_denied = True
258
+ log.info("genloop: check runs answered 403 on %s — reading plan-lint from the Actions runs "
259
+ "from now on", repo)
260
+ key = (repo, pr_number)
261
+ try:
262
+ run = self._lint_actions_run(repo, sha)
263
+ except CommandError as exc:
264
+ if self._lint_unreadable_logged.get(key) != sha:
265
+ self._lint_unreadable_logged[key] = sha
266
+ where = f"#{pr_number}" if pr_number is not None else repo
267
+ log.warning("genloop: could not read plan-lint on %s at %s from check runs or the Actions runs "
268
+ "(%s) — skipped until the head moves", where, sha[:12], exc)
269
+ raise LintUnreadable(exc.argv, exc.returncode, exc.stderr) from exc
270
+ self._lint_unreadable_logged.pop(key, None)
271
+ return run
272
+
273
+ def _lint_check_run(self, repo: str, sha: str) -> dict | None:
201
274
  data = self._api(
202
275
  "-X", "GET", f"repos/{repo}/commits/{sha}/check-runs", "-f", f"check_name={LINT_CHECK_NAME}",
203
276
  "-f", "per_page=100",
@@ -209,6 +282,52 @@ class GitHub:
209
282
  return None
210
283
  return max(named, key=lambda r: int(r.get("id") or 0))
211
284
 
285
+ def _lint_actions_run(self, repo: str, sha: str) -> dict | None:
286
+ """The newest plan-lint run of `lint_event` on `sha` as a check run:
287
+ its conclusion verbatim once completed, pending (None) before, and
288
+ None when there is no such run yet -- or when it failed only because
289
+ its publish step did, so that no check run exists."""
290
+ data = self._api(
291
+ "-X", "GET", f"repos/{repo}/actions/runs", "-f", f"head_sha={sha}", "-f", "per_page=100",
292
+ )
293
+ runs = data.get("workflow_runs") if isinstance(data, dict) else None
294
+ if not isinstance(runs, list):
295
+ raise CommandError(["gh", "api", f"repos/{repo}/actions/runs"], 0, "no workflow_runs in the answer")
296
+ trusted = [r for r in runs if isinstance(r, dict) and self._is_lint_workflow(r)
297
+ and r.get("event") == self.lint_event]
298
+ if not trusted:
299
+ return None
300
+ head = max(trusted, key=_run_order)
301
+ completed = head.get("status") == "completed"
302
+ if completed and head.get("conclusion") == "failure" and not self._lint_published(repo, head):
303
+ return None
304
+ return {
305
+ "id": head.get("id"), "name": LINT_CHECK_NAME, "source": "actions",
306
+ "status": "completed" if completed else str(head.get("status") or "") or "in_progress",
307
+ "conclusion": head.get("conclusion") if completed else None, "html_url": head.get("html_url"),
308
+ }
309
+
310
+ def _lint_published(self, repo: str, run: dict) -> bool:
311
+ """Whether the failed `run` published its `plan-lint` check run: its
312
+ publish step succeeded, or no step of its jobs is the publish step (the
313
+ failure then stands, as before this read)."""
314
+ path = f"repos/{repo}/actions/runs/{run.get('id')}/jobs"
315
+ data = self._api("-X", "GET", path, "-f", "per_page=100")
316
+ jobs = data.get("jobs") if isinstance(data, dict) else None
317
+ if not isinstance(jobs, list):
318
+ raise CommandError(["gh", "api", path], 0, "no jobs in the answer")
319
+ steps = [s for j in jobs if isinstance(j, dict) for s in (j.get("steps") or [])
320
+ if isinstance(s, dict) and str(s.get("name") or "").startswith(self.lint_publish_step)]
321
+ if not steps or any(s.get("conclusion") == "success" for s in steps):
322
+ return True
323
+ log.debug("genloop: plan-lint run %s on %s failed without publishing its check run — no lint, "
324
+ "as on the check-runs path", run.get("id"), repo)
325
+ return False
326
+
327
+ def _is_lint_workflow(self, r: dict) -> bool:
328
+ path = str(r.get("path") or "").split("@", 1)[0]
329
+ return self.lint_workflow in (path, str(r.get("name") or ""))
330
+
212
331
  def issue_comments(self, repo: str, number: int) -> list[Comment]:
213
332
  """Every comment, oldest first, page by page (up to
214
333
  `COMMENT_PAGES_MAX` pages of 100)."""
@@ -284,3 +403,12 @@ class GitHub:
284
403
  if http_status(exc) in (404, 422):
285
404
  return
286
405
  raise
406
+
407
+
408
+ def _run_order(r: dict) -> tuple[str, int]:
409
+ """Newest first by creation time, then by id (ISO-8601 UTC sorts as text)."""
410
+ try:
411
+ run_id = int(r.get("id") or 0)
412
+ except (TypeError, ValueError):
413
+ run_id = 0
414
+ return str(r.get("created_at") or ""), run_id
@@ -17,9 +17,11 @@ ONE POLL, IN ORDER
17
17
  emit their `plan.skipped` lines; `amended` PRs get the reviewer re-requested;
18
18
  `skill-missing` holds every further spawn until the image is fixed.
19
19
  2. **Lint watch.** A plan PR of ours whose `plan-lint` run concluded `failure`
20
- is the generator's to amend on its next pass (D13). One still red after a
21
- pass SPAWNED after the red was first seen has ended is `plan.invalid`: a
22
- comment, the PR closed, the branch deleted, the plan row `rejected`.
20
+ is the generator's to amend on its next pass (D13); a token that cannot
21
+ read check runs reads the plan-lint Actions runs instead (`github.py`). One
22
+ still red after a pass SPAWNED after the red was first seen has ended is
23
+ `plan.invalid`: a comment, the PR closed, the branch deleted, the plan row
24
+ `rejected`.
23
25
  3. **Rejections.** A plan PR closed unmerged with a comment by an operator
24
26
  login, or an operator comment opening `[do-not-propose]`, writes one
25
27
  `proposalRules` row per source key and one for the anchor (§4.4, c7).
@@ -53,7 +55,7 @@ from typing import Callable, Sequence
53
55
  from .alissa_cli import AlissaCli
54
56
  from .alissa_client import AlissaAuthError, AlissaClient, AlissaError, AlissaTransient
55
57
  from .config import BOW_PREFIX, REPOS_SOURCE_STATIC, Config
56
- from .github import GitHub, PullRequest
58
+ from .github import GitHub, LintUnreadable, PullRequest
57
59
  from .loop_events import (
58
60
  KIND_PASS_IDLE,
59
61
  KIND_PASS_SILENT,
@@ -587,7 +589,10 @@ class GenLoop:
587
589
  for pr in pulls:
588
590
  key = META_RED_SINCE + str(pr.number)
589
591
  try:
590
- run = self.github.lint_run(self.repo, pr.head_sha)
592
+ run = self.github.lint_run(self.repo, pr.head_sha, pr_number=pr.number)
593
+ except LintUnreadable:
594
+ # Already logged once for this PR's head by the adapter.
595
+ continue
591
596
  except CommandError as exc:
592
597
  log.warning("genloop: could not read plan-lint on #%d (%s)", pr.number, exc)
593
598
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-genloop
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: ALISSA-TOOLS-GITHUB-GENLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera