ctrlrelay 0.10.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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
ctrlrelay/core/github.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""GitHub CLI (gh) wrapper for ctrlrelay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitHubError(Exception):
|
|
13
|
+
"""Raised when gh CLI operations fail."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# CheckRun.conclusion values (only present once status == "COMPLETED").
|
|
17
|
+
_CHECK_RUN_PASS = frozenset({"SUCCESS", "NEUTRAL"})
|
|
18
|
+
_CHECK_RUN_SKIP = frozenset({"SKIPPED"})
|
|
19
|
+
_CHECK_RUN_CANCEL = frozenset({"CANCELLED"})
|
|
20
|
+
# StatusContext.state values (the legacy commit-status API, e.g. CLA bots).
|
|
21
|
+
_STATUS_CONTEXT_PASS = frozenset({"SUCCESS"})
|
|
22
|
+
_STATUS_CONTEXT_PENDING = frozenset({"PENDING", "EXPECTED"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_check(entry: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Map one `statusCheckRollup` entry to the `{name, state, bucket,
|
|
27
|
+
link}` shape `PRVerifier` expects (see #145).
|
|
28
|
+
|
|
29
|
+
`statusCheckRollup` mixes two GraphQL shapes: `CheckRun` (Actions
|
|
30
|
+
jobs, CodeQL — `status`/`conclusion`) and `StatusContext` (the
|
|
31
|
+
legacy commit-status API, e.g. CLA bots — `state`). `bucket` is
|
|
32
|
+
the only field the rest of the app actually branches on; `state`
|
|
33
|
+
is carried through for parity/debugging.
|
|
34
|
+
"""
|
|
35
|
+
if entry.get("__typename") == "StatusContext":
|
|
36
|
+
state = entry.get("state", "")
|
|
37
|
+
if state in _STATUS_CONTEXT_PASS:
|
|
38
|
+
bucket = "pass"
|
|
39
|
+
elif state in _STATUS_CONTEXT_PENDING:
|
|
40
|
+
bucket = "pending"
|
|
41
|
+
else:
|
|
42
|
+
bucket = "fail"
|
|
43
|
+
return {
|
|
44
|
+
"name": entry.get("context", ""),
|
|
45
|
+
"state": state,
|
|
46
|
+
"bucket": bucket,
|
|
47
|
+
"link": entry.get("targetUrl", ""),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
status = entry.get("status", "")
|
|
51
|
+
conclusion = entry.get("conclusion")
|
|
52
|
+
if status != "COMPLETED":
|
|
53
|
+
bucket = "pending"
|
|
54
|
+
state = status
|
|
55
|
+
elif conclusion in _CHECK_RUN_PASS:
|
|
56
|
+
bucket = "pass"
|
|
57
|
+
state = conclusion
|
|
58
|
+
elif conclusion in _CHECK_RUN_SKIP:
|
|
59
|
+
bucket = "skipping"
|
|
60
|
+
state = conclusion
|
|
61
|
+
elif conclusion in _CHECK_RUN_CANCEL:
|
|
62
|
+
bucket = "cancel"
|
|
63
|
+
state = conclusion
|
|
64
|
+
else:
|
|
65
|
+
bucket = "fail"
|
|
66
|
+
state = conclusion or "FAILURE"
|
|
67
|
+
return {
|
|
68
|
+
"name": entry.get("name", ""),
|
|
69
|
+
"state": state,
|
|
70
|
+
"bucket": bucket,
|
|
71
|
+
"link": entry.get("detailsUrl", ""),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_gh() -> str:
|
|
76
|
+
"""Find gh binary, checking common paths if not in PATH."""
|
|
77
|
+
gh = shutil.which("gh")
|
|
78
|
+
if gh:
|
|
79
|
+
return gh
|
|
80
|
+
for path in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"]:
|
|
81
|
+
if shutil.which(path):
|
|
82
|
+
return path
|
|
83
|
+
return "gh"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class GitHubCLI:
|
|
88
|
+
"""Async wrapper around the gh CLI."""
|
|
89
|
+
|
|
90
|
+
gh_binary: str = field(default_factory=_find_gh)
|
|
91
|
+
timeout: int = 60
|
|
92
|
+
|
|
93
|
+
async def _run_gh(
|
|
94
|
+
self, *args: str, timeout: int | None = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Run gh command and return stdout; raise GitHubError on non-zero.
|
|
97
|
+
|
|
98
|
+
``timeout`` overrides ``self.timeout`` for one call — useful
|
|
99
|
+
for probes (like the open-PR check in worktree reuse) that
|
|
100
|
+
shouldn't inherit the full 60s default when any delay holds
|
|
101
|
+
the repo lock.
|
|
102
|
+
|
|
103
|
+
Kills the child and waits for it to reap on timeout so a
|
|
104
|
+
long-running daemon (e.g. 7-day PR-watch loop that retries on
|
|
105
|
+
TimeoutError) doesn't leak subprocesses while the network hangs.
|
|
106
|
+
"""
|
|
107
|
+
effective_timeout = (
|
|
108
|
+
self.timeout if timeout is None else timeout
|
|
109
|
+
)
|
|
110
|
+
cmd = [self.gh_binary, *args]
|
|
111
|
+
proc = await asyncio.create_subprocess_exec(
|
|
112
|
+
*cmd,
|
|
113
|
+
stdout=asyncio.subprocess.PIPE,
|
|
114
|
+
stderr=asyncio.subprocess.PIPE,
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
stdout, stderr = await asyncio.wait_for(
|
|
118
|
+
proc.communicate(), timeout=effective_timeout
|
|
119
|
+
)
|
|
120
|
+
except asyncio.TimeoutError:
|
|
121
|
+
# Reap the hung child so we don't accumulate zombies across
|
|
122
|
+
# many retries. kill() is SIGKILL on POSIX; wait() returns
|
|
123
|
+
# quickly because the signal is terminal.
|
|
124
|
+
proc.kill()
|
|
125
|
+
try:
|
|
126
|
+
await proc.wait()
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
if proc.returncode != 0:
|
|
132
|
+
raise GitHubError(f"gh failed: {stderr.decode().strip()}")
|
|
133
|
+
|
|
134
|
+
return stdout.decode()
|
|
135
|
+
|
|
136
|
+
async def list_prs(
|
|
137
|
+
self,
|
|
138
|
+
repo: str,
|
|
139
|
+
state: str = "open",
|
|
140
|
+
limit: int = 100,
|
|
141
|
+
head: str | None = None,
|
|
142
|
+
timeout: int | None = None,
|
|
143
|
+
) -> list[dict[str, Any]]:
|
|
144
|
+
"""List pull requests for a repository.
|
|
145
|
+
|
|
146
|
+
When ``head`` is given, restrict the result to PRs whose head
|
|
147
|
+
branch is ``head`` (passed to ``gh pr list --head``). Used by
|
|
148
|
+
the dev pipeline's worktree reuse path to refuse a branch that
|
|
149
|
+
still backs an open PR (issue #52).
|
|
150
|
+
|
|
151
|
+
``timeout`` overrides the default per call so the reuse probe
|
|
152
|
+
can bound latency — the probe holds the repo lock, so a full
|
|
153
|
+
default-timeout hang would stall every other session on the
|
|
154
|
+
same repo.
|
|
155
|
+
"""
|
|
156
|
+
args = [
|
|
157
|
+
"pr", "list",
|
|
158
|
+
"--repo", repo,
|
|
159
|
+
"--state", state,
|
|
160
|
+
"--limit", str(limit),
|
|
161
|
+
"--json", (
|
|
162
|
+
"number,title,author,labels,headRefName,mergeable,"
|
|
163
|
+
"reviewDecision,headRepositoryOwner,headRepository"
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
if head is not None:
|
|
167
|
+
args.extend(["--head", head])
|
|
168
|
+
output = await self._run_gh(*args, timeout=timeout)
|
|
169
|
+
return json.loads(output) if output.strip() else []
|
|
170
|
+
|
|
171
|
+
async def list_security_alerts(
|
|
172
|
+
self,
|
|
173
|
+
repo: str,
|
|
174
|
+
state: str = "open",
|
|
175
|
+
) -> list[dict[str, Any]]:
|
|
176
|
+
"""List Dependabot security alerts with pagination."""
|
|
177
|
+
output = await self._run_gh(
|
|
178
|
+
"api",
|
|
179
|
+
"--paginate",
|
|
180
|
+
f"/repos/{repo}/dependabot/alerts",
|
|
181
|
+
"--jq", f'[.[] | select(.state == "{state}")]',
|
|
182
|
+
)
|
|
183
|
+
return json.loads(output) if output.strip() else []
|
|
184
|
+
|
|
185
|
+
async def merge_pr(
|
|
186
|
+
self,
|
|
187
|
+
repo: str,
|
|
188
|
+
pr_number: int,
|
|
189
|
+
method: str = "squash",
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Merge a pull request."""
|
|
192
|
+
merge_flag = f"--{method}"
|
|
193
|
+
await self._run_gh(
|
|
194
|
+
"pr", "merge",
|
|
195
|
+
str(pr_number),
|
|
196
|
+
"--repo", repo,
|
|
197
|
+
merge_flag,
|
|
198
|
+
"--delete-branch",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def get_pr_checks(
|
|
202
|
+
self,
|
|
203
|
+
repo: str,
|
|
204
|
+
pr_number: int,
|
|
205
|
+
) -> list[dict[str, Any]]:
|
|
206
|
+
"""Get status checks for a PR.
|
|
207
|
+
|
|
208
|
+
Uses `gh pr view --json statusCheckRollup` rather than `gh pr
|
|
209
|
+
checks --json ...` — the latter's `--json` flag isn't available
|
|
210
|
+
on `gh` releases before it was added (confirmed absent on gh
|
|
211
|
+
2.45.0, the current Ubuntu 24.04 package with no newer version
|
|
212
|
+
in the distro repos), which made this unconditionally broken on
|
|
213
|
+
those installs regardless of the PR's actual state (#145).
|
|
214
|
+
|
|
215
|
+
`gh pr view --json` always exits 0 once the PR itself resolves —
|
|
216
|
+
the check states live in the JSON, not the exit code — so unlike
|
|
217
|
+
the old implementation, a non-zero exit here is always a genuine
|
|
218
|
+
failure (auth, network, missing PR): `_run_gh` already raises
|
|
219
|
+
`GitHubError` for that case. No checks yet (or ever) on the PR
|
|
220
|
+
just means an empty `statusCheckRollup` list — the ambiguity
|
|
221
|
+
between "not registered yet" and "no CI configured" is handled
|
|
222
|
+
by `PRVerifier`'s empty-streak retry, not here.
|
|
223
|
+
"""
|
|
224
|
+
stdout = await self._run_gh(
|
|
225
|
+
"pr", "view", str(pr_number),
|
|
226
|
+
"--repo", repo,
|
|
227
|
+
"--json", "statusCheckRollup",
|
|
228
|
+
)
|
|
229
|
+
payload = json.loads(stdout)
|
|
230
|
+
rollup = payload.get("statusCheckRollup") or []
|
|
231
|
+
return [_normalize_check(entry) for entry in rollup]
|
|
232
|
+
|
|
233
|
+
def all_checks_passed(self, checks: list[dict[str, Any]]) -> bool:
|
|
234
|
+
"""Check if all PR checks passed."""
|
|
235
|
+
if not checks:
|
|
236
|
+
return False
|
|
237
|
+
return all(c.get("bucket") in ("pass", "skipping") for c in checks)
|
|
238
|
+
|
|
239
|
+
async def list_assigned_issues(
|
|
240
|
+
self,
|
|
241
|
+
repo: str,
|
|
242
|
+
assignee: str | None,
|
|
243
|
+
state: str = "open",
|
|
244
|
+
limit: int = 100,
|
|
245
|
+
) -> list[dict[str, Any]]:
|
|
246
|
+
"""List issues for a repo, optionally filtered by assignee.
|
|
247
|
+
|
|
248
|
+
When ``assignee`` is ``None`` the ``--assignee`` flag is dropped
|
|
249
|
+
and all open issues are returned. Prefer :meth:`list_issues_by_label`
|
|
250
|
+
for label-triggered polling — the unfiltered call caps at
|
|
251
|
+
``limit`` (default 100) and silently drops anything past that in
|
|
252
|
+
a busy repo. The poller uses the assignee path + per-label
|
|
253
|
+
targeted queries and merges results by issue number.
|
|
254
|
+
"""
|
|
255
|
+
args = [
|
|
256
|
+
"issue", "list",
|
|
257
|
+
"--repo", repo,
|
|
258
|
+
"--state", state,
|
|
259
|
+
"--limit", str(limit),
|
|
260
|
+
"--json", "number,title,state,body,labels,assignees,createdAt,updatedAt",
|
|
261
|
+
]
|
|
262
|
+
if assignee is not None:
|
|
263
|
+
# Insert before ``--state`` to keep the existing call signature
|
|
264
|
+
# shape when assignee is set (makes recorded gh invocations in
|
|
265
|
+
# fixtures / audit logs visually diff-clean).
|
|
266
|
+
args[4:4] = ["--assignee", assignee]
|
|
267
|
+
output = await self._run_gh(*args)
|
|
268
|
+
return json.loads(output) if output.strip() else []
|
|
269
|
+
|
|
270
|
+
async def list_issues_by_label(
|
|
271
|
+
self,
|
|
272
|
+
repo: str,
|
|
273
|
+
label: str,
|
|
274
|
+
state: str = "open",
|
|
275
|
+
limit: int = 1000,
|
|
276
|
+
) -> list[dict[str, Any]]:
|
|
277
|
+
"""List open issues carrying a specific label.
|
|
278
|
+
|
|
279
|
+
Issued per label in ``include_labels`` (issue #80): a single
|
|
280
|
+
``gh issue list --label`` call filters server-side, so the
|
|
281
|
+
result is bounded by the number of issues wearing that label
|
|
282
|
+
rather than by total open issues in the repo. That keeps the
|
|
283
|
+
label-triggered poll path scale-safe on large repos, where
|
|
284
|
+
fetching all open issues (pre-codex-review behavior) would
|
|
285
|
+
silently cap at 100 and miss labeled issues on later pages.
|
|
286
|
+
|
|
287
|
+
``limit`` defaults to 1000 (vs the 100 default on
|
|
288
|
+
``list_assigned_issues``) because an opt-in label can, in
|
|
289
|
+
principle, wear many more issues than a single user is
|
|
290
|
+
assigned to. gh paginates internally up to ``--limit`` so one
|
|
291
|
+
call covers the realistic range. If a repo ever has >1000
|
|
292
|
+
open issues sharing the same opt-in label, the operator will
|
|
293
|
+
need a larger limit or more granular labels — it's a
|
|
294
|
+
configuration smell at that scale.
|
|
295
|
+
|
|
296
|
+
gh CLI treats ``--label foo --label bar`` as AND; the poller
|
|
297
|
+
therefore runs one call per label and dedupes by issue number
|
|
298
|
+
at the caller.
|
|
299
|
+
"""
|
|
300
|
+
args = [
|
|
301
|
+
"issue", "list",
|
|
302
|
+
"--repo", repo,
|
|
303
|
+
"--label", label,
|
|
304
|
+
"--state", state,
|
|
305
|
+
"--limit", str(limit),
|
|
306
|
+
"--json", "number,title,state,body,labels,assignees,createdAt,updatedAt",
|
|
307
|
+
]
|
|
308
|
+
output = await self._run_gh(*args)
|
|
309
|
+
return json.loads(output) if output.strip() else []
|
|
310
|
+
|
|
311
|
+
async def list_assignment_events(
|
|
312
|
+
self,
|
|
313
|
+
repo: str,
|
|
314
|
+
issue_number: int,
|
|
315
|
+
) -> list[dict[str, Any]]:
|
|
316
|
+
"""List ``assigned`` events for an issue in chronological order.
|
|
317
|
+
|
|
318
|
+
Returns the GitHub issue-events payload filtered to ``event == "assigned"``.
|
|
319
|
+
Each entry includes ``actor`` (who performed the assignment) and
|
|
320
|
+
``assignee`` (who was assigned). Used by the poller to verify that the
|
|
321
|
+
most recent self-assignment was actually performed by the operator.
|
|
322
|
+
"""
|
|
323
|
+
output = await self._run_gh(
|
|
324
|
+
"api",
|
|
325
|
+
f"/repos/{repo}/issues/{issue_number}/events",
|
|
326
|
+
"--jq", '[.[] | select(.event=="assigned")]',
|
|
327
|
+
)
|
|
328
|
+
return json.loads(output) if output.strip() else []
|
|
329
|
+
|
|
330
|
+
async def get_issue(
|
|
331
|
+
self,
|
|
332
|
+
repo: str,
|
|
333
|
+
issue_number: int,
|
|
334
|
+
) -> dict[str, Any]:
|
|
335
|
+
"""Get a single issue by number."""
|
|
336
|
+
output = await self._run_gh(
|
|
337
|
+
"issue", "view",
|
|
338
|
+
str(issue_number),
|
|
339
|
+
"--repo", repo,
|
|
340
|
+
"--json",
|
|
341
|
+
"number,title,state,body,labels,assignees,author,createdAt,updatedAt,comments",
|
|
342
|
+
)
|
|
343
|
+
return json.loads(output)
|
|
344
|
+
|
|
345
|
+
async def create_pr(
|
|
346
|
+
self,
|
|
347
|
+
repo: str,
|
|
348
|
+
title: str,
|
|
349
|
+
body: str,
|
|
350
|
+
head: str,
|
|
351
|
+
base: str = "main",
|
|
352
|
+
) -> dict[str, Any]:
|
|
353
|
+
"""Create a pull request."""
|
|
354
|
+
output = await self._run_gh(
|
|
355
|
+
"pr", "create",
|
|
356
|
+
"--repo", repo,
|
|
357
|
+
"--title", title,
|
|
358
|
+
"--body", body,
|
|
359
|
+
"--head", head,
|
|
360
|
+
"--base", base,
|
|
361
|
+
"--json", "number,title,url,state",
|
|
362
|
+
)
|
|
363
|
+
return json.loads(output)
|
|
364
|
+
|
|
365
|
+
async def get_pr_state(
|
|
366
|
+
self,
|
|
367
|
+
repo: str,
|
|
368
|
+
pr_number: int,
|
|
369
|
+
) -> dict[str, Any]:
|
|
370
|
+
"""Get PR state including merge status."""
|
|
371
|
+
output = await self._run_gh(
|
|
372
|
+
"pr", "view",
|
|
373
|
+
str(pr_number),
|
|
374
|
+
"--repo", repo,
|
|
375
|
+
"--json", "number,state,mergeable,mergeStateStatus,title,url,headRefName,baseRefName",
|
|
376
|
+
)
|
|
377
|
+
return json.loads(output)
|
|
378
|
+
|
|
379
|
+
async def get_issue_or_pr_state(
|
|
380
|
+
self,
|
|
381
|
+
repo: str,
|
|
382
|
+
number: int,
|
|
383
|
+
*,
|
|
384
|
+
timeout: int | None = None,
|
|
385
|
+
) -> dict[str, Any]:
|
|
386
|
+
"""Return ``{"number", "state", "is_pr", "merged"}`` for a
|
|
387
|
+
number that may name either an issue or a PR.
|
|
388
|
+
|
|
389
|
+
A BLOCKED question only ever cites ``#N``; nothing in the text
|
|
390
|
+
says which kind it is. The REST ``issues`` endpoint covers both
|
|
391
|
+
— GitHub models a PR as an issue — so one call answers it
|
|
392
|
+
without guessing and retrying. ``state`` is ``"open"`` or
|
|
393
|
+
``"closed"``; ``merged`` is only meaningful when ``is_pr``.
|
|
394
|
+
"""
|
|
395
|
+
output = await self._run_gh(
|
|
396
|
+
"api", f"repos/{repo}/issues/{number}",
|
|
397
|
+
"--jq",
|
|
398
|
+
'{number: .number, state: .state, '
|
|
399
|
+
'is_pr: (.pull_request != null), '
|
|
400
|
+
'merged: (.pull_request.merged_at != null)}',
|
|
401
|
+
timeout=timeout,
|
|
402
|
+
)
|
|
403
|
+
return json.loads(output)
|
|
404
|
+
|
|
405
|
+
async def comment_on_issue(
|
|
406
|
+
self,
|
|
407
|
+
repo: str,
|
|
408
|
+
issue_number: int,
|
|
409
|
+
body: str,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Post a comment on an issue."""
|
|
412
|
+
await self._run_gh(
|
|
413
|
+
"issue", "comment",
|
|
414
|
+
str(issue_number),
|
|
415
|
+
"--repo", repo,
|
|
416
|
+
"--body", body,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
async def close_issue(
|
|
420
|
+
self,
|
|
421
|
+
repo: str,
|
|
422
|
+
issue_number: int,
|
|
423
|
+
comment: str | None = None,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Close an issue with an optional comment."""
|
|
426
|
+
if comment is not None:
|
|
427
|
+
await self.comment_on_issue(repo, issue_number, comment)
|
|
428
|
+
await self._run_gh(
|
|
429
|
+
"issue", "close",
|
|
430
|
+
str(issue_number),
|
|
431
|
+
"--repo", repo,
|
|
432
|
+
)
|