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.
- {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
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/github.py +134 -6
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/loop.py +10 -5
- alissa_tools_github_genloop-0.1.4/src/main/alissa/tools/github/genloop/version +1 -0
- {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
- alissa_tools_github_genloop-0.1.2/src/main/alissa/tools/github/genloop/version +0 -1
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/LICENSE +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/MANIFEST.in +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/NOTICE +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/README.md +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/requirements.txt +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/setup.cfg +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/setup.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/__init__.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/__main__.py +0 -0
- {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
- {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
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/config.py +0 -0
- {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
- {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
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/plan.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/proc.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/state.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.4}/src/main/alissa/tools/github/genloop/version.py +0 -0
- {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
- {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
- {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
- {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,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`
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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)
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.4
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.1.2
|
|
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
|