alissa-tools-github-genloop 0.1.2__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.
- {alissa_tools_github_genloop-0.1.2/src/main/alissa_tools_github_genloop.egg-info → alissa_tools_github_genloop-0.1.3}/PKG-INFO +1 -1
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/github.py +103 -6
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/loop.py +10 -5
- alissa_tools_github_genloop-0.1.3/src/main/alissa/tools/github/genloop/version +1 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3/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.3}/LICENSE +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/MANIFEST.in +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/NOTICE +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/README.md +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/requirements.txt +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/setup.cfg +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/setup.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/__init__.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/__main__.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/alissa_cli.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/alissa_client.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/config.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/loop_events.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/pass_directive.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/plan.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/proc.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/state.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/src/main/alissa/tools/github/genloop/version.py +0 -0
- {alissa_tools_github_genloop-0.1.2 → alissa_tools_github_genloop-0.1.3}/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.3}/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.3}/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.3}/src/main/alissa_tools_github_genloop.egg-info/top_level.txt +0 -0
|
@@ -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`
|
|
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 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
|
|
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)
|
|
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.3
|
|
@@ -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
|