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.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
@@ -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
+ )