alissa-tools-github-devloop 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,406 @@
1
+ """GitHub access via `gh api`.
2
+
3
+ Note: this targets gh 2.4.0, which predates `gh search`. Every query goes
4
+ through `gh api` against the REST v3 endpoints instead.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import tempfile
14
+ from dataclasses import dataclass
15
+
16
+ from . import proc
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ # Origin-task convention: the session that files a devloop issue leaves an
21
+ # `Alissa-Task: TASK-<n>` trailer line in the body. An anchored trailer match
22
+ # wins outright; the loose first-TASK-<n> scan is only the fallback for bodies
23
+ # that never wrote the trailer.
24
+ # `[ \t]*` rather than `\s*`: the trailer's ref must sit on the SAME line --
25
+ # `\s` matches newlines, which would let a dangling `Alissa-Task:` reach
26
+ # across blank lines into unrelated prose and claim someone else's TASK ref.
27
+ TASK_TRAILER_RE = re.compile(r"^Alissa-Task:[ \t]*TASK-(\d+)", re.MULTILINE)
28
+ TASK_REF_RE = re.compile(r"TASK-(\d+)")
29
+
30
+ # linked_open_prs walks the issue timeline page by page (a long pre-label
31
+ # discussion can push the PR cross-reference past any single page), stopping
32
+ # at the first short page, with a hard ceiling so a pathological timeline
33
+ # cannot turn one liveness probe into an unbounded request storm. 5 pages =
34
+ # 500 events; past that the probe gives up and reports what it saw -- a
35
+ # known limit, same spirit as search_issues' 100-result window.
36
+ TIMELINE_PAGE_SIZE = 100
37
+ TIMELINE_MAX_PAGES = 5
38
+
39
+
40
+ def parse_task_ref(body: str) -> int | None:
41
+ """Origin Alissa task number from an issue body, or None if absent.
42
+
43
+ Precedence: a line-anchored `Alissa-Task: TASK-<n>` trailer wins wherever
44
+ it sits in the body, so prose mentions of other tasks earlier in the text
45
+ cannot outrank it. Only when no trailer line exists does the loose scan
46
+ run, and there the first TASK-<n> wins.
47
+ """
48
+ text = body or ""
49
+ match = TASK_TRAILER_RE.search(text) or TASK_REF_RE.search(text)
50
+ return int(match.group(1)) if match else None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Issue:
55
+ owner: str
56
+ repo: str
57
+ number: int
58
+ state: str
59
+ title: str
60
+ body: str
61
+ url: str
62
+ assignees: tuple[str, ...]
63
+ labels: tuple[str, ...]
64
+
65
+ @property
66
+ def full_name(self) -> str:
67
+ return f"{self.owner}/{self.repo}"
68
+
69
+ @property
70
+ def issue_slug(self) -> str:
71
+ # Named issue_slug, not slug: the ledger's `repo_slug` key wants
72
+ # `full_name` (owner/repo), and a property called plain `slug` made
73
+ # `record_spawn(repo_slug=issue.slug, ...)` read natural but wrong.
74
+ return f"{self.owner}/{self.repo}#{self.number}"
75
+
76
+
77
+ class RateLimited(RuntimeError):
78
+ pass
79
+
80
+
81
+ class IdentityMismatch(RuntimeError):
82
+ """Configured developer identity disagrees with the gh token."""
83
+
84
+
85
+ class AssignmentRejected(RuntimeError):
86
+ """`assign_self` returned 2xx but the login is not on the issue.
87
+
88
+ GitHub silently drops assignees that lack push access on the repo and
89
+ still answers with the (unchanged) issue. Left unchecked, the server-side
90
+ in-flight marker never lands: nothing on GitHub tells humans or other
91
+ daemons the issue is being worked, and a lost ledger would respawn a
92
+ developer against it immediately. This is a permanent, operator-fixable
93
+ condition (grant the account push access or drop the repo from the
94
+ allowlist), not a retry-later one."""
95
+
96
+
97
+ # Actual rate-limit signals in `gh` stderr. A bare HTTP 403 is NOT one:
98
+ # GitHub answers permission failures (token scope, SAML, fine-grained PAT
99
+ # restrictions) with 403 too, and those are permanent operator errors that
100
+ # must surface as CommandError, not as a transient retry-later signal.
101
+ _RATE_LIMIT_MARKERS = (
102
+ "rate limit", # covers "API rate limit exceeded" and "secondary rate limit"
103
+ "x-ratelimit-remaining: 0",
104
+ "retry-after",
105
+ )
106
+
107
+
108
+ def _is_rate_limited(exc: proc.CommandError) -> bool:
109
+ blob = exc.stderr.lower()
110
+ return any(marker in blob for marker in _RATE_LIMIT_MARKERS)
111
+
112
+
113
+ class GitHub:
114
+ def __init__(self, login: str | None = None):
115
+ self._login = login
116
+
117
+ def token_login(self) -> str:
118
+ """Who the gh token actually belongs to. `gh api --jq` prints scalars
119
+ raw (unquoted), so this is deliberately not parsed as JSON -- which is
120
+ why it cannot go through `_api`, but it shares the same rate-limit
121
+ classification."""
122
+ try:
123
+ return proc.run(["gh", "api", "user", "--jq", ".login"]).strip()
124
+ except proc.CommandError as exc:
125
+ if _is_rate_limited(exc):
126
+ raise RateLimited(exc.stderr.strip()[:300]) from exc
127
+ raise
128
+
129
+ @property
130
+ def login(self) -> str:
131
+ if self._login is None:
132
+ self._login = self.token_login()
133
+ return self._login
134
+
135
+ def verify_identity(self) -> str:
136
+ """`assign_self` posts `self.login` as assignee, but that request is
137
+ authorized by the gh token. If a configured developer_login disagrees
138
+ with the token, the daemon would assign the token's account while
139
+ evaluate() looks for the configured one -- its own in-flight work
140
+ would read as foreign-assigned and wedge shut, and the in-flight
141
+ marker would break. Fail loudly instead."""
142
+ actual = self.token_login()
143
+ if self._login is not None and self._login != actual:
144
+ raise IdentityMismatch(
145
+ f"configured developer_login={self._login!r} but the gh token "
146
+ f"belongs to {actual!r}. Assignment follows the token, so the "
147
+ f"in-flight marker would break. Fix developer_login (or set it "
148
+ f"to null to auto-detect), or re-authenticate gh."
149
+ )
150
+ self._login = actual
151
+ return actual
152
+
153
+ def _api(self, *args: str, timeout: int = 60):
154
+ try:
155
+ return proc.run_json(["gh", "api", *args], timeout=timeout)
156
+ except proc.CommandError as exc:
157
+ if _is_rate_limited(exc):
158
+ raise RateLimited(exc.stderr.strip()[:300]) from exc
159
+ raise
160
+
161
+ SEARCH_PER_PAGE = 100
162
+
163
+ def search_issues(
164
+ self, label: str, repos: tuple[str, ...] = ()
165
+ ) -> list[tuple[str, str, int, tuple[str, ...] | None]]:
166
+ """Open issues carrying the trigger label across the watched repos,
167
+ as `(owner, repo, number, assignee_logins)` hits.
168
+
169
+ Deliberately NO `no:assignee` qualifier: the search returns assigned
170
+ issues so the daemon re-sees its own in-flight work on every poll --
171
+ that is what lets evaluate() age a stale spawn, re-enqueue it, and
172
+ escalate a cap-out. Assignment-based filtering happens in evaluate()
173
+ via the issue() re-fetch instead (foreign assignee → skip; fresh
174
+ self-spawn → in-flight). The label is quoted because it contains a
175
+ colon (`alissa:develop`).
176
+
177
+ `assignee_logins` is the search payload's OWN assignee data, surfaced
178
+ so the caller can cheaply pre-skip issues that are visibly someone
179
+ else's without paying the issue() re-fetch for them (issues parked on
180
+ a human otherwise cost one re-fetch per poll, forever). It is None --
181
+ unknown, not unassigned -- when the item carried no parseable
182
+ assignee list. Caveat: search results can lag the live issue, so this
183
+ is only ever a pre-filter for the FOREIGN case; the authoritative
184
+ re-fetch guard stays in evaluate() for everything the daemon might
185
+ act on, and a stale mis-skip self-heals on a later poll once the
186
+ index catches up.
187
+
188
+ Fails closed on an empty allowlist: without `repo:` qualifiers the
189
+ query would run against ALL of GitHub, and the caller self-assigns
190
+ and spawns developers against whatever comes back. An empty `repos`
191
+ therefore means "watch nothing", never "watch everything".
192
+ """
193
+ # Empty/whitespace ENTRIES fail closed too, not just an empty
194
+ # container: ("",) is truthy, and a bare `repo:` qualifier from a
195
+ # degenerate entry would put the unscoped-search blocker back on
196
+ # upstream leniency this client cannot verify.
197
+ repos = tuple(r.strip() for r in repos if r and r.strip())
198
+ if not repos:
199
+ log.warning(
200
+ "search_issues: repo allowlist is empty -- nothing is watched. "
201
+ "An unscoped search would match all of GitHub, so this fails "
202
+ "closed; configure `repos` to watch something."
203
+ )
204
+ return []
205
+
206
+ query = f'is:issue is:open label:"{label}"'
207
+ for full_name in repos:
208
+ query += f" repo:{full_name}"
209
+
210
+ payload = self._api(
211
+ "-X",
212
+ "GET",
213
+ "search/issues",
214
+ "-f",
215
+ f"q={query}",
216
+ "-f",
217
+ f"per_page={self.SEARCH_PER_PAGE}",
218
+ )
219
+ items = (payload or {}).get("items", [])
220
+ if len(items) == self.SEARCH_PER_PAGE:
221
+ log.warning(
222
+ "search_issues: got exactly %d items -- results are likely "
223
+ "truncated. Pagination is deliberately not implemented, so %d "
224
+ "results per poll is a hard ceiling, and the window only "
225
+ "drains as issues CLOSE or lose the trigger label (assignment "
226
+ "no longer removes them from this query) -- a backlog past "
227
+ "the ceiling keeps its tail invisible until it shrinks. This "
228
+ "warning is the visibility mechanism.",
229
+ self.SEARCH_PER_PAGE,
230
+ self.SEARCH_PER_PAGE,
231
+ )
232
+
233
+ out: list[tuple[str, str, int, tuple[str, ...] | None]] = []
234
+ for item in items:
235
+ # repository_url looks like https://api.github.com/repos/<owner>/<repo>
236
+ parts = item.get("repository_url", "").rstrip("/").split("/")
237
+ if len(parts) < 2:
238
+ log.warning("could not parse repo from %s", item.get("repository_url"))
239
+ continue
240
+ try:
241
+ number = int(item["number"])
242
+ except (KeyError, TypeError, ValueError):
243
+ log.warning("could not parse issue number from %r", item.get("number"))
244
+ continue
245
+ # None means UNKNOWN (no parseable assignee data on the item),
246
+ # never unassigned -- absence of evidence must fall through to
247
+ # the issue() re-fetch, not read as "free to take".
248
+ raw = item.get("assignees")
249
+ assignees: tuple[str, ...] | None
250
+ if isinstance(raw, list):
251
+ assignees = tuple(
252
+ a["login"]
253
+ for a in raw
254
+ if isinstance(a, dict) and a.get("login")
255
+ )
256
+ else:
257
+ assignees = None
258
+ out.append((parts[-2], parts[-1], number, assignees))
259
+ return out
260
+
261
+ def issue(self, owner: str, repo: str, number: int) -> Issue:
262
+ # `or {}`: run_json returns None on empty stdout, and the siblings
263
+ # (search_issues, assign_self) already guard the same way.
264
+ data = self._api(f"repos/{owner}/{repo}/issues/{number}") or {}
265
+ return Issue(
266
+ owner=owner,
267
+ repo=repo,
268
+ number=number,
269
+ state=data.get("state", ""),
270
+ title=data.get("title", ""),
271
+ body=data.get("body") or "",
272
+ url=data.get("html_url", ""),
273
+ assignees=tuple(
274
+ (a.get("login") or "") for a in (data.get("assignees") or [])
275
+ ),
276
+ labels=tuple(
277
+ (lb.get("name") or "") if isinstance(lb, dict) else str(lb)
278
+ for lb in (data.get("labels") or [])
279
+ ),
280
+ )
281
+
282
+ def linked_open_prs(self, owner: str, repo: str, number: int) -> list[str]:
283
+ """URLs of OPEN same-repo pull requests linked to the issue -- the
284
+ external liveness signal behind the stale-retry decision.
285
+
286
+ Source: the issue's timeline, filtered to `cross-referenced` events
287
+ whose source is an open pull request IN THIS REPO. A cross-referenced
288
+ event is produced by ANY mention of the issue anywhere on GitHub -- a
289
+ comment on an unrelated PR, a "related to #<n>" in another repo's PR
290
+ body, a bot linking issues -- not only by the PR implementing it, so
291
+ the source's html_url must live under
292
+ `https://github.com/{owner}/{repo}/pull/`; without that filter, any
293
+ open PR anywhere that once mentioned the issue would defer the retry
294
+ for as long as it stays open. (A PR whose body carries `Closes #<n>`
295
+ cross-references the issue the moment it is opened, so the developer
296
+ sessions this daemon spawns always produce the event -- and their PRs
297
+ are same-repo by construction.) The timeline is the cheapest liveness
298
+ signal available: a handful of authenticated GETs (usually one) with
299
+ no extra token scope,
300
+ and -- unlike probing the developer's tmux session -- it does not
301
+ couple the daemon to whatever host the session runs on.
302
+ Closed/merged PRs are deliberately excluded: abandoned work must
303
+ not keep deferring the retry.
304
+
305
+ The timeline is walked in pages: ascending chronological order puts
306
+ everything that happened on the issue BEFORE the PR was opened --
307
+ comments, labeled/assigned/mentioned events, bot noise -- ahead of
308
+ the cross-reference, so a long pre-label discussion can push it past
309
+ the first page, and a first-page-only probe would mis-read live work
310
+ as dead (and respawn over it). The walk is bounded: it stops at the
311
+ first short page (the timeline is exhausted) and hard-caps at
312
+ TIMELINE_MAX_PAGES pages (TIMELINE_MAX_PAGES * TIMELINE_PAGE_SIZE
313
+ events) -- a cross-reference past that ceiling is invisible to the
314
+ probe: a documented limit, same spirit as search_issues' 100-result
315
+ window. NOT gh's `--paginate`: gh 2.4.0 concatenates the pages'
316
+ JSON arrays (`[...][...]`), which run_json cannot parse -- an
317
+ explicit `page=` loop is the safe shape for this client. Malformed
318
+ events are skipped, never fatal -- one odd timeline entry must not
319
+ wedge the staleness decision.
320
+ """
321
+ same_repo_pull = f"https://github.com/{owner}/{repo}/pull/"
322
+ urls: list[str] = []
323
+ for page in range(1, TIMELINE_MAX_PAGES + 1):
324
+ events = (
325
+ self._api(
326
+ "-X",
327
+ "GET",
328
+ f"repos/{owner}/{repo}/issues/{number}/timeline",
329
+ "-f",
330
+ f"per_page={TIMELINE_PAGE_SIZE}",
331
+ "-f",
332
+ f"page={page}",
333
+ )
334
+ or []
335
+ )
336
+ if not isinstance(events, list):
337
+ break
338
+ for event in events:
339
+ if (
340
+ not isinstance(event, dict)
341
+ or event.get("event") != "cross-referenced"
342
+ ):
343
+ continue
344
+ source = event.get("source")
345
+ src = source.get("issue") if isinstance(source, dict) else None
346
+ if not isinstance(src, dict):
347
+ continue
348
+ if not src.get("pull_request"):
349
+ continue # the cross-reference came from a plain issue
350
+ if src.get("state") != "open":
351
+ continue
352
+ url = src.get("html_url") or ""
353
+ if not url.startswith(same_repo_pull):
354
+ continue # a mention from another repo says nothing here
355
+ urls.append(url)
356
+ if len(events) < TIMELINE_PAGE_SIZE:
357
+ break # short page: the timeline is exhausted
358
+ # One PR can cross-reference the issue more than once (body edit,
359
+ # comment mention); report it once.
360
+ return list(dict.fromkeys(urls))
361
+
362
+ def assign_self(self, owner: str, repo: str, number: int) -> None:
363
+ """Assign the token identity -- the server-side in-flight marker.
364
+
365
+ gh 2.4.0 has no `-f key[]=value` array syntax (that landed later) and
366
+ the endpoint wants `{"assignees": [...]}`, so the body goes through
367
+ `--input <file>` instead.
368
+
369
+ The endpoint does NOT fail loudly when the assignment is rejected:
370
+ GitHub drops assignees lacking push access and still returns 2xx with
371
+ the issue unchanged. The response body is therefore checked and
372
+ `AssignmentRejected` raised when the login did not land.
373
+ """
374
+ payload = json.dumps({"assignees": [self.login]})
375
+ fd, body_path = tempfile.mkstemp(prefix="devloop-assign-", suffix=".json")
376
+ try:
377
+ with os.fdopen(fd, "w") as handle:
378
+ handle.write(payload)
379
+ data = self._api(
380
+ "-X",
381
+ "POST",
382
+ f"repos/{owner}/{repo}/issues/{number}/assignees",
383
+ "--input",
384
+ body_path,
385
+ )
386
+ finally:
387
+ os.unlink(body_path)
388
+
389
+ landed = tuple(
390
+ (a.get("login") or "") for a in ((data or {}).get("assignees") or [])
391
+ )
392
+ if self.login not in landed:
393
+ raise AssignmentRejected(
394
+ f"{owner}/{repo}#{number}: GitHub accepted the request but "
395
+ f"{self.login!r} is not among the issue assignees {landed!r} -- "
396
+ f"the account likely lacks push access on {owner}/{repo}, so "
397
+ f"the in-flight marker cannot land. Grant access or remove the "
398
+ f"repo from the allowlist."
399
+ )
400
+
401
+ def comment(self, owner: str, repo: str, number: int, body: str) -> None:
402
+ self._api(
403
+ f"repos/{owner}/{repo}/issues/{number}/comments",
404
+ "-f",
405
+ f"body={body}",
406
+ )