alissa-tools-github-genloop 0.1.0__py3-none-any.whl
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/__init__.py +14 -0
- alissa/tools/github/genloop/__main__.py +89 -0
- alissa/tools/github/genloop/alissa_cli.py +89 -0
- alissa/tools/github/genloop/alissa_client.py +183 -0
- alissa/tools/github/genloop/config.py +294 -0
- alissa/tools/github/genloop/github.py +286 -0
- alissa/tools/github/genloop/loop.py +971 -0
- alissa/tools/github/genloop/loop_events.py +115 -0
- alissa/tools/github/genloop/pass_directive.py +325 -0
- alissa/tools/github/genloop/plan.py +461 -0
- alissa/tools/github/genloop/proc.py +61 -0
- alissa/tools/github/genloop/state.py +113 -0
- alissa/tools/github/genloop/version +1 -0
- alissa/tools/github/genloop/version.py +46 -0
- alissa_tools_github_genloop-0.1.0.dist-info/METADATA +45 -0
- alissa_tools_github_genloop-0.1.0.dist-info/RECORD +21 -0
- alissa_tools_github_genloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/LICENSE +202 -0
- alissa_tools_github_genloop-0.1.0.dist-info/licenses/NOTICE +5 -0
- alissa_tools_github_genloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""The GitHub surface the seat drives, all through `gh api` (which keeps its
|
|
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.
|
|
6
|
+
|
|
7
|
+
The seat touches ONE repository on GitHub -- the plans repository. The fed
|
|
8
|
+
repos are the pass session's to read (with `gh api`, never cloned); nothing
|
|
9
|
+
here reads or writes them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import urllib.parse
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Sequence
|
|
22
|
+
|
|
23
|
+
from .proc import CommandError, run
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
LINT_CHECK_NAME = "plan-lint"
|
|
28
|
+
# A plan PR thread longer than this many pages of 100 comments is read only up to here.
|
|
29
|
+
COMMENT_PAGES_MAX = 10
|
|
30
|
+
_STATUS_RE = re.compile(r"HTTP (\d{3})")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def http_status(exc: CommandError) -> int | None:
|
|
34
|
+
match = _STATUS_RE.search(exc.stderr or "")
|
|
35
|
+
return int(match.group(1)) if match else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class PullRequest:
|
|
40
|
+
number: int
|
|
41
|
+
title: str
|
|
42
|
+
body: str
|
|
43
|
+
state: str
|
|
44
|
+
merged: bool
|
|
45
|
+
head_ref: str
|
|
46
|
+
head_sha: str
|
|
47
|
+
author: str
|
|
48
|
+
url: str
|
|
49
|
+
created_at: str
|
|
50
|
+
closed_at: str
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_payload(cls, data: dict) -> "PullRequest | None":
|
|
54
|
+
try:
|
|
55
|
+
number = int(data["number"])
|
|
56
|
+
except (KeyError, TypeError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
head: dict = data["head"] if isinstance(data.get("head"), dict) else {}
|
|
59
|
+
user: dict = data["user"] if isinstance(data.get("user"), dict) else {}
|
|
60
|
+
return cls(
|
|
61
|
+
number=number,
|
|
62
|
+
title=str(data.get("title") or ""),
|
|
63
|
+
body=str(data.get("body") or ""),
|
|
64
|
+
state=str(data.get("state") or ""),
|
|
65
|
+
merged=bool(data.get("merged_at")) or bool(data.get("merged")),
|
|
66
|
+
head_ref=str(head.get("ref") or ""),
|
|
67
|
+
head_sha=str(head.get("sha") or ""),
|
|
68
|
+
author=str(user.get("login") or ""),
|
|
69
|
+
url=str(data.get("html_url") or ""),
|
|
70
|
+
created_at=str(data.get("created_at") or ""),
|
|
71
|
+
closed_at=str(data.get("closed_at") or ""),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Comment:
|
|
77
|
+
id: int
|
|
78
|
+
author: str
|
|
79
|
+
body: str
|
|
80
|
+
url: str
|
|
81
|
+
created_at: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class GitHub:
|
|
85
|
+
def __init__(self, *, timeout: int = 60):
|
|
86
|
+
self.timeout = timeout
|
|
87
|
+
|
|
88
|
+
# -- transport -------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _api(self, *args: str, body: dict | None = None) -> Any:
|
|
91
|
+
argv = ["gh", "api", *args]
|
|
92
|
+
if body is not None:
|
|
93
|
+
argv += ["--input", "-"]
|
|
94
|
+
out = self._run_stdin(argv, json.dumps(body))
|
|
95
|
+
else:
|
|
96
|
+
out = self._run(argv)
|
|
97
|
+
out = out.strip()
|
|
98
|
+
if not out:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(out)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc
|
|
104
|
+
|
|
105
|
+
def _run(self, argv: Sequence[str]) -> str:
|
|
106
|
+
try:
|
|
107
|
+
return run(argv, timeout=self.timeout)
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
raise CommandError(argv, -1, f"could not execute {argv[0]!r}: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
def _run_stdin(self, argv: Sequence[str], stdin: str) -> str:
|
|
112
|
+
log.debug("exec: %s", " ".join(argv))
|
|
113
|
+
try:
|
|
114
|
+
proc = subprocess.run(list(argv), input=stdin, capture_output=True, text=True, timeout=self.timeout)
|
|
115
|
+
except subprocess.TimeoutExpired as exc:
|
|
116
|
+
raise CommandError(argv, -1, f"timed out after {self.timeout}s") from exc
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
raise CommandError(argv, -1, f"could not execute {argv[0]!r}: {exc}") from exc
|
|
119
|
+
if proc.returncode != 0:
|
|
120
|
+
raise CommandError(argv, proc.returncode, proc.stderr)
|
|
121
|
+
return proc.stdout
|
|
122
|
+
|
|
123
|
+
# -- identity --------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def current_login(self) -> str:
|
|
126
|
+
data = self._api("user")
|
|
127
|
+
login = data.get("login") if isinstance(data, dict) else None
|
|
128
|
+
if not isinstance(login, str) or not login:
|
|
129
|
+
raise CommandError(["gh", "api", "user"], 0, "no login in the answer")
|
|
130
|
+
return login
|
|
131
|
+
|
|
132
|
+
# -- reads -----------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def default_branch(self, repo: str) -> str:
|
|
135
|
+
data = self._api(f"repos/{repo}")
|
|
136
|
+
branch = data.get("default_branch") if isinstance(data, dict) else None
|
|
137
|
+
if not isinstance(branch, str) or not branch:
|
|
138
|
+
raise CommandError(["gh", "api", f"repos/{repo}"], 0, "no default_branch in the answer")
|
|
139
|
+
return branch
|
|
140
|
+
|
|
141
|
+
def branch_sha(self, repo: str, branch: str) -> str | None:
|
|
142
|
+
"""The branch's head commit, or None when the branch does not exist."""
|
|
143
|
+
try:
|
|
144
|
+
data = self._api(f"repos/{repo}/branches/{urllib.parse.quote(branch, safe='')}")
|
|
145
|
+
except CommandError as exc:
|
|
146
|
+
if http_status(exc) == 404:
|
|
147
|
+
return None
|
|
148
|
+
raise
|
|
149
|
+
commit = data.get("commit") if isinstance(data, dict) else None
|
|
150
|
+
sha = commit.get("sha") if isinstance(commit, dict) else None
|
|
151
|
+
return sha if isinstance(sha, str) and sha else None
|
|
152
|
+
|
|
153
|
+
def read_file(self, repo: str, path: str, ref: str) -> str | None:
|
|
154
|
+
"""A file's text at `ref`, or None when absent."""
|
|
155
|
+
try:
|
|
156
|
+
data = self._api(
|
|
157
|
+
"-X", "GET", f"repos/{repo}/contents/{urllib.parse.quote(path)}", "-f", f"ref={ref}",
|
|
158
|
+
)
|
|
159
|
+
except CommandError as exc:
|
|
160
|
+
if http_status(exc) == 404:
|
|
161
|
+
return None
|
|
162
|
+
raise
|
|
163
|
+
if not isinstance(data, dict) or data.get("type") != "file":
|
|
164
|
+
return None
|
|
165
|
+
content = data.get("content")
|
|
166
|
+
if not isinstance(content, str):
|
|
167
|
+
return None
|
|
168
|
+
return base64.b64decode(content).decode("utf-8", "replace")
|
|
169
|
+
|
|
170
|
+
def list_dir(self, repo: str, path: str, ref: str) -> list[str] | None:
|
|
171
|
+
"""The file names directly under `path` at `ref`, or None when absent."""
|
|
172
|
+
try:
|
|
173
|
+
data = self._api(
|
|
174
|
+
"-X", "GET", f"repos/{repo}/contents/{urllib.parse.quote(path)}", "-f", f"ref={ref}",
|
|
175
|
+
)
|
|
176
|
+
except CommandError as exc:
|
|
177
|
+
if http_status(exc) == 404:
|
|
178
|
+
return None
|
|
179
|
+
raise
|
|
180
|
+
if not isinstance(data, list):
|
|
181
|
+
return None
|
|
182
|
+
return sorted(str(e.get("name")) for e in data if isinstance(e, dict) and e.get("type") == "file")
|
|
183
|
+
|
|
184
|
+
def pulls_for_branch(self, repo: str, branch: str) -> list[PullRequest]:
|
|
185
|
+
owner = repo.split("/", 1)[0]
|
|
186
|
+
data = self._api(
|
|
187
|
+
"-X", "GET", f"repos/{repo}/pulls", "-f", f"head={owner}:{branch}", "-f", "state=all",
|
|
188
|
+
"-f", "per_page=10",
|
|
189
|
+
)
|
|
190
|
+
return [pr for pr in (PullRequest.from_payload(d) for d in (data or []) if isinstance(d, dict)) if pr]
|
|
191
|
+
|
|
192
|
+
def list_pulls(self, repo: str, *, state: str, per_page: int = 50) -> list[PullRequest]:
|
|
193
|
+
data = self._api(
|
|
194
|
+
"-X", "GET", f"repos/{repo}/pulls", "-f", f"state={state}", "-f", "sort=updated",
|
|
195
|
+
"-f", "direction=desc", "-f", f"per_page={per_page}",
|
|
196
|
+
)
|
|
197
|
+
return [pr for pr in (PullRequest.from_payload(d) for d in (data or []) if isinstance(d, dict)) if pr]
|
|
198
|
+
|
|
199
|
+
def lint_run(self, repo: str, sha: str) -> dict | None:
|
|
200
|
+
"""The newest `plan-lint` check run on `sha` (by id), or None."""
|
|
201
|
+
data = self._api(
|
|
202
|
+
"-X", "GET", f"repos/{repo}/commits/{sha}/check-runs", "-f", f"check_name={LINT_CHECK_NAME}",
|
|
203
|
+
"-f", "per_page=100",
|
|
204
|
+
)
|
|
205
|
+
runs = data.get("check_runs") if isinstance(data, dict) else None
|
|
206
|
+
named = [r for r in (runs if isinstance(runs, list) else [])
|
|
207
|
+
if isinstance(r, dict) and r.get("name") == LINT_CHECK_NAME]
|
|
208
|
+
if not named:
|
|
209
|
+
return None
|
|
210
|
+
return max(named, key=lambda r: int(r.get("id") or 0))
|
|
211
|
+
|
|
212
|
+
def issue_comments(self, repo: str, number: int) -> list[Comment]:
|
|
213
|
+
"""Every comment, oldest first, page by page (up to
|
|
214
|
+
`COMMENT_PAGES_MAX` pages of 100)."""
|
|
215
|
+
items: list = []
|
|
216
|
+
for page in range(1, COMMENT_PAGES_MAX + 1):
|
|
217
|
+
data = self._api("-X", "GET", f"repos/{repo}/issues/{number}/comments", "-f", "per_page=100",
|
|
218
|
+
"-f", f"page={page}")
|
|
219
|
+
batch = data if isinstance(data, list) else []
|
|
220
|
+
items.extend(batch)
|
|
221
|
+
if len(batch) < 100:
|
|
222
|
+
break
|
|
223
|
+
out: list[Comment] = []
|
|
224
|
+
for item in items:
|
|
225
|
+
if not isinstance(item, dict):
|
|
226
|
+
continue
|
|
227
|
+
user: dict = item["user"] if isinstance(item.get("user"), dict) else {}
|
|
228
|
+
try:
|
|
229
|
+
out.append(Comment(
|
|
230
|
+
id=int(item["id"]), author=str(user.get("login") or ""), body=str(item.get("body") or ""),
|
|
231
|
+
url=str(item.get("html_url") or ""), created_at=str(item.get("created_at") or ""),
|
|
232
|
+
))
|
|
233
|
+
except (KeyError, TypeError, ValueError):
|
|
234
|
+
continue
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
def plans_opened_on(self, repo: str, login: str, day: str) -> int:
|
|
238
|
+
"""D10's day counter: open-or-merged plan PRs by `login` opened on the
|
|
239
|
+
UTC `day` (`YYYY-MM-DD`), a GitHub search."""
|
|
240
|
+
data = self._api(
|
|
241
|
+
"-X", "GET", "search/issues", "-f", f"q=repo:{repo} is:pr author:{login} created:{day}",
|
|
242
|
+
"-f", "per_page=100",
|
|
243
|
+
)
|
|
244
|
+
items = data.get("items") if isinstance(data, dict) else None
|
|
245
|
+
if not isinstance(items, list):
|
|
246
|
+
raise CommandError(["gh", "api", "search/issues"], 0, "search answered no items")
|
|
247
|
+
count = 0
|
|
248
|
+
for item in items:
|
|
249
|
+
if not isinstance(item, dict):
|
|
250
|
+
continue
|
|
251
|
+
pull: dict = item["pull_request"] if isinstance(item.get("pull_request"), dict) else {}
|
|
252
|
+
if item.get("state") == "open" or pull.get("merged_at"):
|
|
253
|
+
count += 1
|
|
254
|
+
return count
|
|
255
|
+
|
|
256
|
+
# -- writes ----------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def open_pr(self, repo: str, *, head: str, base: str, title: str, body: str) -> PullRequest:
|
|
259
|
+
data = self._api(
|
|
260
|
+
"-X", "POST", f"repos/{repo}/pulls",
|
|
261
|
+
body={"head": head, "base": base, "title": title, "body": body, "draft": False},
|
|
262
|
+
)
|
|
263
|
+
pr = PullRequest.from_payload(data) if isinstance(data, dict) else None
|
|
264
|
+
if pr is None:
|
|
265
|
+
raise CommandError(["gh", "api", f"repos/{repo}/pulls"], 0, "PR create answered no number")
|
|
266
|
+
return pr
|
|
267
|
+
|
|
268
|
+
def request_reviewers(self, repo: str, number: int, logins: Sequence[str]) -> None:
|
|
269
|
+
self._api(
|
|
270
|
+
"-X", "POST", f"repos/{repo}/pulls/{number}/requested_reviewers", body={"reviewers": list(logins)},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def comment(self, repo: str, number: int, body: str) -> None:
|
|
274
|
+
self._api("-X", "POST", f"repos/{repo}/issues/{number}/comments", body={"body": body})
|
|
275
|
+
|
|
276
|
+
def close_pr(self, repo: str, number: int) -> None:
|
|
277
|
+
self._api("-X", "PATCH", f"repos/{repo}/pulls/{number}", body={"state": "closed"})
|
|
278
|
+
|
|
279
|
+
def delete_branch(self, repo: str, branch: str) -> None:
|
|
280
|
+
"""Delete a branch; one that is already gone is not an error."""
|
|
281
|
+
try:
|
|
282
|
+
self._api("-X", "DELETE", f"repos/{repo}/git/refs/heads/{urllib.parse.quote(branch, safe='')}")
|
|
283
|
+
except CommandError as exc:
|
|
284
|
+
if http_status(exc) in (404, 422):
|
|
285
|
+
return
|
|
286
|
+
raise
|