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.
- alissa/tools/github/devloop/__init__.py +11 -0
- alissa/tools/github/devloop/__main__.py +217 -0
- alissa/tools/github/devloop/alissa.py +75 -0
- alissa/tools/github/devloop/config.py +359 -0
- alissa/tools/github/devloop/ghclient.py +406 -0
- alissa/tools/github/devloop/loop.py +620 -0
- alissa/tools/github/devloop/proc.py +56 -0
- alissa/tools/github/devloop/state.py +113 -0
- alissa/tools/github/devloop/version +1 -0
- alissa/tools/github/devloop/version.py +46 -0
- alissa_tools_github_devloop-0.1.0.dist-info/METADATA +61 -0
- alissa_tools_github_devloop-0.1.0.dist-info/RECORD +15 -0
- alissa_tools_github_devloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_devloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_devloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|