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.
@@ -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