alissa-tools-github-genloop 0.1.1__tar.gz → 0.1.3__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.1/src/main/alissa_tools_github_genloop.egg-info → alissa_tools_github_genloop-0.1.3}/PKG-INFO +1 -1
  2. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/github.py +103 -6
  3. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/loop.py +10 -5
  4. alissa_tools_github_genloop-0.1.3/src/main/alissa/tools/github/genloop/version +1 -0
  5. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3/src/main/alissa_tools_github_genloop.egg-info}/PKG-INFO +1 -1
  6. alissa_tools_github_genloop-0.1.1/src/main/alissa/tools/github/genloop/version +0 -1
  7. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/LICENSE +0 -0
  8. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/MANIFEST.in +0 -0
  9. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/NOTICE +0 -0
  10. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/README.md +0 -0
  11. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/requirements.txt +0 -0
  12. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/setup.cfg +0 -0
  13. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/setup.py +0 -0
  14. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/__init__.py +0 -0
  15. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/__main__.py +0 -0
  16. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/alissa_cli.py +0 -0
  17. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/alissa_client.py +0 -0
  18. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/config.py +0 -0
  19. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/loop_events.py +0 -0
  20. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/pass_directive.py +0 -0
  21. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/plan.py +0 -0
  22. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/proc.py +0 -0
  23. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/state.py +0 -0
  24. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/version.py +0 -0
  25. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa_tools_github_genloop.egg-info/SOURCES.txt +0 -0
  26. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa_tools_github_genloop.egg-info/dependency_links.txt +0 -0
  27. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/src/main/alissa_tools_github_genloop.egg-info/entry_points.txt +0 -0
  28. {alissa_tools_github_genloop-0.1.1 → alissa_tools_github_genloop-0.1.3}/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.1
3
+ Version: 0.1.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-GENLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -1,8 +1,23 @@
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 denied 403 ("Resource not accessible") is
17
+ latched for the process -- every later read goes straight to the Actions path;
18
+ any other 403 (a secondary rate limit) falls back for that read only. When
19
+ the Actions read fails too, the warning is logged once per PR per head and
20
+ `LintUnreadable` is raised, which the caller skips quietly.
6
21
 
7
22
  The seat touches ONE repository on GitHub -- the plans repository. The fed
8
23
  repos are the pass session's to read (with `gh api`, never cloned); nothing
@@ -25,6 +40,16 @@ from .proc import CommandError, run
25
40
  log = logging.getLogger(__name__)
26
41
 
27
42
  LINT_CHECK_NAME = "plan-lint"
43
+ # The plan-lint workflow of the plans template, matched against an Actions
44
+ # run's `path` or `name` (`GitHub(lint_workflow=...)` names another).
45
+ LINT_WORKFLOW = ".github/workflows/plan-lint.yml"
46
+ # The event whose plan-lint run publishes the `plan-lint` check run -- the
47
+ # template's trusted job (`GitHub(lint_event=...)` names another). Its run's
48
+ # conclusion passes through verbatim, so only `failure` is a red lint, as on
49
+ # the check-runs path; the offline `pull_request` run is never read.
50
+ LINT_EVENT = "pull_request_target"
51
+ # The 403 body of a token that cannot read check runs, as against a rate limit.
52
+ _DENIED_MARKER = "resource not accessible"
28
53
  # A plan PR thread longer than this many pages of 100 comments is read only up to here.
29
54
  COMMENT_PAGES_MAX = 10
30
55
  _STATUS_RE = re.compile(r"HTTP (\d{3})")
@@ -35,6 +60,10 @@ def http_status(exc: CommandError) -> int | None:
35
60
  return int(match.group(1)) if match else None
36
61
 
37
62
 
63
+ class LintUnreadable(CommandError):
64
+ """Neither the check-runs read nor the Actions fallback answered."""
65
+
66
+
38
67
  @dataclass(frozen=True)
39
68
  class PullRequest:
40
69
  number: int
@@ -82,8 +111,15 @@ class Comment:
82
111
 
83
112
 
84
113
  class GitHub:
85
- def __init__(self, *, timeout: int = 60):
114
+ def __init__(self, *, timeout: int = 60, lint_workflow: str = LINT_WORKFLOW, lint_event: str = LINT_EVENT):
86
115
  self.timeout = timeout
116
+ self.lint_workflow = lint_workflow
117
+ self.lint_event = lint_event
118
+ # Latched on the first 403 from check-runs: this token cannot read them.
119
+ self._check_runs_denied = False
120
+ # (repo, pr) -> the head whose unreadable lint has been logged already;
121
+ # one entry per PR, dropped when the PR's lint reads again.
122
+ self._lint_unreadable_logged: dict[tuple[str, int | None], str] = {}
87
123
 
88
124
  # -- transport -------------------------------------------------------------
89
125
 
@@ -196,8 +232,34 @@ class GitHub:
196
232
  )
197
233
  return [pr for pr in (PullRequest.from_payload(d) for d in (data or []) if isinstance(d, dict)) if pr]
198
234
 
199
- def lint_run(self, repo: str, sha: str) -> dict | None:
200
- """The newest `plan-lint` check run on `sha` (by id), or None."""
235
+ def lint_run(self, repo: str, sha: str, *, pr_number: int | None = None) -> dict | None:
236
+ """The `plan-lint` verdict on `sha` -- the newest check run, or the
237
+ Actions runs when check runs are denied -- or None when there is none."""
238
+ if not self._check_runs_denied:
239
+ try:
240
+ return self._lint_check_run(repo, sha)
241
+ except CommandError as exc:
242
+ status = http_status(exc)
243
+ if status not in (403, 404):
244
+ raise
245
+ if status == 403 and _DENIED_MARKER in (exc.stderr or "").lower():
246
+ self._check_runs_denied = True
247
+ log.info("genloop: check runs answered 403 on %s — reading plan-lint from the Actions runs "
248
+ "from now on", repo)
249
+ key = (repo, pr_number)
250
+ try:
251
+ run = self._lint_actions_run(repo, sha)
252
+ except CommandError as exc:
253
+ if self._lint_unreadable_logged.get(key) != sha:
254
+ self._lint_unreadable_logged[key] = sha
255
+ where = f"#{pr_number}" if pr_number is not None else repo
256
+ log.warning("genloop: could not read plan-lint on %s at %s from check runs or the Actions runs "
257
+ "(%s) — skipped until the head moves", where, sha[:12], exc)
258
+ raise LintUnreadable(exc.argv, exc.returncode, exc.stderr) from exc
259
+ self._lint_unreadable_logged.pop(key, None)
260
+ return run
261
+
262
+ def _lint_check_run(self, repo: str, sha: str) -> dict | None:
201
263
  data = self._api(
202
264
  "-X", "GET", f"repos/{repo}/commits/{sha}/check-runs", "-f", f"check_name={LINT_CHECK_NAME}",
203
265
  "-f", "per_page=100",
@@ -209,6 +271,32 @@ class GitHub:
209
271
  return None
210
272
  return max(named, key=lambda r: int(r.get("id") or 0))
211
273
 
274
+ def _lint_actions_run(self, repo: str, sha: str) -> dict | None:
275
+ """The newest plan-lint run of `lint_event` on `sha` as a check run:
276
+ its conclusion verbatim once completed, pending (None) before, and
277
+ None when there is no such run yet."""
278
+ data = self._api(
279
+ "-X", "GET", f"repos/{repo}/actions/runs", "-f", f"head_sha={sha}", "-f", "per_page=100",
280
+ )
281
+ runs = data.get("workflow_runs") if isinstance(data, dict) else None
282
+ if not isinstance(runs, list):
283
+ raise CommandError(["gh", "api", f"repos/{repo}/actions/runs"], 0, "no workflow_runs in the answer")
284
+ trusted = [r for r in runs if isinstance(r, dict) and self._is_lint_workflow(r)
285
+ and r.get("event") == self.lint_event]
286
+ if not trusted:
287
+ return None
288
+ head = max(trusted, key=_run_order)
289
+ completed = head.get("status") == "completed"
290
+ return {
291
+ "id": head.get("id"), "name": LINT_CHECK_NAME, "source": "actions",
292
+ "status": "completed" if completed else str(head.get("status") or "") or "in_progress",
293
+ "conclusion": head.get("conclusion") if completed else None, "html_url": head.get("html_url"),
294
+ }
295
+
296
+ def _is_lint_workflow(self, r: dict) -> bool:
297
+ path = str(r.get("path") or "").split("@", 1)[0]
298
+ return self.lint_workflow in (path, str(r.get("name") or ""))
299
+
212
300
  def issue_comments(self, repo: str, number: int) -> list[Comment]:
213
301
  """Every comment, oldest first, page by page (up to
214
302
  `COMMENT_PAGES_MAX` pages of 100)."""
@@ -284,3 +372,12 @@ class GitHub:
284
372
  if http_status(exc) in (404, 422):
285
373
  return
286
374
  raise
375
+
376
+
377
+ def _run_order(r: dict) -> tuple[str, int]:
378
+ """Newest first by creation time, then by id (ISO-8601 UTC sorts as text)."""
379
+ try:
380
+ run_id = int(r.get("id") or 0)
381
+ except (TypeError, ValueError):
382
+ run_id = 0
383
+ 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.1
3
+ Version: 0.1.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-GENLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera