ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
ctrlrelay/core/github.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
def _find_gh() -> str:
|
|
17
|
+
"""Find gh binary, checking common paths if not in PATH."""
|
|
18
|
+
gh = shutil.which("gh")
|
|
19
|
+
if gh:
|
|
20
|
+
return gh
|
|
21
|
+
for path in ["/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"]:
|
|
22
|
+
if shutil.which(path):
|
|
23
|
+
return path
|
|
24
|
+
return "gh"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class GitHubCLI:
|
|
29
|
+
"""Async wrapper around the gh CLI."""
|
|
30
|
+
|
|
31
|
+
gh_binary: str = field(default_factory=_find_gh)
|
|
32
|
+
timeout: int = 60
|
|
33
|
+
|
|
34
|
+
async def _run_gh(self, *args: str) -> str:
|
|
35
|
+
"""Run gh command and return stdout; raise GitHubError on non-zero.
|
|
36
|
+
|
|
37
|
+
Kills the child and waits for it to reap on timeout so a
|
|
38
|
+
long-running daemon (e.g. 7-day PR-watch loop that retries on
|
|
39
|
+
TimeoutError) doesn't leak subprocesses while the network hangs.
|
|
40
|
+
"""
|
|
41
|
+
cmd = [self.gh_binary, *args]
|
|
42
|
+
proc = await asyncio.create_subprocess_exec(
|
|
43
|
+
*cmd,
|
|
44
|
+
stdout=asyncio.subprocess.PIPE,
|
|
45
|
+
stderr=asyncio.subprocess.PIPE,
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
stdout, stderr = await asyncio.wait_for(
|
|
49
|
+
proc.communicate(), timeout=self.timeout
|
|
50
|
+
)
|
|
51
|
+
except asyncio.TimeoutError:
|
|
52
|
+
# Reap the hung child so we don't accumulate zombies across
|
|
53
|
+
# many retries. kill() is SIGKILL on POSIX; wait() returns
|
|
54
|
+
# quickly because the signal is terminal.
|
|
55
|
+
proc.kill()
|
|
56
|
+
try:
|
|
57
|
+
await proc.wait()
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
if proc.returncode != 0:
|
|
63
|
+
raise GitHubError(f"gh failed: {stderr.decode().strip()}")
|
|
64
|
+
|
|
65
|
+
return stdout.decode()
|
|
66
|
+
|
|
67
|
+
async def list_prs(
|
|
68
|
+
self,
|
|
69
|
+
repo: str,
|
|
70
|
+
state: str = "open",
|
|
71
|
+
limit: int = 100,
|
|
72
|
+
) -> list[dict[str, Any]]:
|
|
73
|
+
"""List pull requests for a repository."""
|
|
74
|
+
output = await self._run_gh(
|
|
75
|
+
"pr", "list",
|
|
76
|
+
"--repo", repo,
|
|
77
|
+
"--state", state,
|
|
78
|
+
"--limit", str(limit),
|
|
79
|
+
"--json", "number,title,author,labels,headRefName,mergeable,reviewDecision",
|
|
80
|
+
)
|
|
81
|
+
return json.loads(output) if output.strip() else []
|
|
82
|
+
|
|
83
|
+
async def list_security_alerts(
|
|
84
|
+
self,
|
|
85
|
+
repo: str,
|
|
86
|
+
state: str = "open",
|
|
87
|
+
) -> list[dict[str, Any]]:
|
|
88
|
+
"""List Dependabot security alerts with pagination."""
|
|
89
|
+
output = await self._run_gh(
|
|
90
|
+
"api",
|
|
91
|
+
"--paginate",
|
|
92
|
+
f"/repos/{repo}/dependabot/alerts",
|
|
93
|
+
"--jq", f'[.[] | select(.state == "{state}")]',
|
|
94
|
+
)
|
|
95
|
+
return json.loads(output) if output.strip() else []
|
|
96
|
+
|
|
97
|
+
async def merge_pr(
|
|
98
|
+
self,
|
|
99
|
+
repo: str,
|
|
100
|
+
pr_number: int,
|
|
101
|
+
method: str = "squash",
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Merge a pull request."""
|
|
104
|
+
merge_flag = f"--{method}"
|
|
105
|
+
await self._run_gh(
|
|
106
|
+
"pr", "merge",
|
|
107
|
+
str(pr_number),
|
|
108
|
+
"--repo", repo,
|
|
109
|
+
merge_flag,
|
|
110
|
+
"--delete-branch",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
async def get_pr_checks(
|
|
114
|
+
self,
|
|
115
|
+
repo: str,
|
|
116
|
+
pr_number: int,
|
|
117
|
+
) -> list[dict[str, Any]]:
|
|
118
|
+
"""Get status checks for a PR.
|
|
119
|
+
|
|
120
|
+
Bypasses `_run_gh` because we need to inspect stdout, stderr, and the
|
|
121
|
+
exit code independently. `gh pr checks`:
|
|
122
|
+
- prints a JSON array on stdout when checks exist (exits non-zero
|
|
123
|
+
when any are pending or failing — the payload is still valid)
|
|
124
|
+
- prints "no checks reported on the '<branch>' branch" to stderr
|
|
125
|
+
and exits non-zero when the PR has no checks at all
|
|
126
|
+
- emits arbitrary errors to stderr (auth, network, missing PR) on
|
|
127
|
+
non-zero exit with empty stdout
|
|
128
|
+
|
|
129
|
+
We return [] only for the "no checks reported" case. Real failures
|
|
130
|
+
raise GitHubError so callers can distinguish "no CI configured" from
|
|
131
|
+
"gh is broken".
|
|
132
|
+
"""
|
|
133
|
+
cmd = [
|
|
134
|
+
self.gh_binary,
|
|
135
|
+
"pr", "checks",
|
|
136
|
+
str(pr_number),
|
|
137
|
+
"--repo", repo,
|
|
138
|
+
"--json", "name,state,bucket,link",
|
|
139
|
+
]
|
|
140
|
+
proc = await asyncio.create_subprocess_exec(
|
|
141
|
+
*cmd,
|
|
142
|
+
stdout=asyncio.subprocess.PIPE,
|
|
143
|
+
stderr=asyncio.subprocess.PIPE,
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
147
|
+
proc.communicate(), timeout=self.timeout,
|
|
148
|
+
)
|
|
149
|
+
except asyncio.TimeoutError:
|
|
150
|
+
proc.kill()
|
|
151
|
+
try:
|
|
152
|
+
await proc.wait()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
raise
|
|
156
|
+
stdout = stdout_bytes.decode().strip()
|
|
157
|
+
stderr = stderr_bytes.decode().strip()
|
|
158
|
+
|
|
159
|
+
# JSON payload on stdout — always trust it, regardless of exit code.
|
|
160
|
+
if stdout:
|
|
161
|
+
return json.loads(stdout)
|
|
162
|
+
|
|
163
|
+
# No stdout. Distinguish "no CI configured" from genuine failures by
|
|
164
|
+
# looking for gh's well-known "no checks reported" message.
|
|
165
|
+
if "no checks reported" in stderr.lower():
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
# Anything else is an honest-to-goodness failure. Don't pretend the
|
|
169
|
+
# repo has no CI.
|
|
170
|
+
raise GitHubError(f"gh pr checks failed: {stderr}")
|
|
171
|
+
|
|
172
|
+
def all_checks_passed(self, checks: list[dict[str, Any]]) -> bool:
|
|
173
|
+
"""Check if all PR checks passed."""
|
|
174
|
+
if not checks:
|
|
175
|
+
return False
|
|
176
|
+
return all(c.get("bucket") in ("pass", "skipping") for c in checks)
|
|
177
|
+
|
|
178
|
+
async def list_assigned_issues(
|
|
179
|
+
self,
|
|
180
|
+
repo: str,
|
|
181
|
+
assignee: str,
|
|
182
|
+
state: str = "open",
|
|
183
|
+
limit: int = 100,
|
|
184
|
+
) -> list[dict[str, Any]]:
|
|
185
|
+
"""List issues assigned to a user."""
|
|
186
|
+
output = await self._run_gh(
|
|
187
|
+
"issue", "list",
|
|
188
|
+
"--repo", repo,
|
|
189
|
+
"--assignee", assignee,
|
|
190
|
+
"--state", state,
|
|
191
|
+
"--limit", str(limit),
|
|
192
|
+
"--json", "number,title,state,body,labels,assignees,createdAt,updatedAt",
|
|
193
|
+
)
|
|
194
|
+
return json.loads(output) if output.strip() else []
|
|
195
|
+
|
|
196
|
+
async def get_issue(
|
|
197
|
+
self,
|
|
198
|
+
repo: str,
|
|
199
|
+
issue_number: int,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Get a single issue by number."""
|
|
202
|
+
output = await self._run_gh(
|
|
203
|
+
"issue", "view",
|
|
204
|
+
str(issue_number),
|
|
205
|
+
"--repo", repo,
|
|
206
|
+
"--json",
|
|
207
|
+
"number,title,state,body,labels,assignees,author,createdAt,updatedAt,comments",
|
|
208
|
+
)
|
|
209
|
+
return json.loads(output)
|
|
210
|
+
|
|
211
|
+
async def create_pr(
|
|
212
|
+
self,
|
|
213
|
+
repo: str,
|
|
214
|
+
title: str,
|
|
215
|
+
body: str,
|
|
216
|
+
head: str,
|
|
217
|
+
base: str = "main",
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Create a pull request."""
|
|
220
|
+
output = await self._run_gh(
|
|
221
|
+
"pr", "create",
|
|
222
|
+
"--repo", repo,
|
|
223
|
+
"--title", title,
|
|
224
|
+
"--body", body,
|
|
225
|
+
"--head", head,
|
|
226
|
+
"--base", base,
|
|
227
|
+
"--json", "number,title,url,state",
|
|
228
|
+
)
|
|
229
|
+
return json.loads(output)
|
|
230
|
+
|
|
231
|
+
async def get_pr_state(
|
|
232
|
+
self,
|
|
233
|
+
repo: str,
|
|
234
|
+
pr_number: int,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Get PR state including merge status."""
|
|
237
|
+
output = await self._run_gh(
|
|
238
|
+
"pr", "view",
|
|
239
|
+
str(pr_number),
|
|
240
|
+
"--repo", repo,
|
|
241
|
+
"--json", "number,state,mergeable,mergeStateStatus,title,url,headRefName,baseRefName",
|
|
242
|
+
)
|
|
243
|
+
return json.loads(output)
|
|
244
|
+
|
|
245
|
+
async def comment_on_issue(
|
|
246
|
+
self,
|
|
247
|
+
repo: str,
|
|
248
|
+
issue_number: int,
|
|
249
|
+
body: str,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Post a comment on an issue."""
|
|
252
|
+
await self._run_gh(
|
|
253
|
+
"issue", "comment",
|
|
254
|
+
str(issue_number),
|
|
255
|
+
"--repo", repo,
|
|
256
|
+
"--body", body,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def close_issue(
|
|
260
|
+
self,
|
|
261
|
+
repo: str,
|
|
262
|
+
issue_number: int,
|
|
263
|
+
comment: str | None = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Close an issue with an optional comment."""
|
|
266
|
+
if comment is not None:
|
|
267
|
+
await self.comment_on_issue(repo, issue_number, comment)
|
|
268
|
+
await self._run_gh(
|
|
269
|
+
"issue", "close",
|
|
270
|
+
str(issue_number),
|
|
271
|
+
"--repo", repo,
|
|
272
|
+
)
|
ctrlrelay/core/obs.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Structured logging for ctrlrelay.
|
|
2
|
+
|
|
3
|
+
Emits newline-delimited JSON records on the ``ctrlrelay`` logger tree so the
|
|
4
|
+
poller and bridge stdout streams (captured by launchd into
|
|
5
|
+
``~/.ctrlrelay/logs/*.log``) carry machine-readable events.
|
|
6
|
+
|
|
7
|
+
Typical usage:
|
|
8
|
+
|
|
9
|
+
from ctrlrelay.core.obs import get_logger, log_event
|
|
10
|
+
|
|
11
|
+
logger = get_logger("transport.socket")
|
|
12
|
+
log_event(
|
|
13
|
+
logger,
|
|
14
|
+
"dev.question.posted",
|
|
15
|
+
session_id=session_id,
|
|
16
|
+
repo=repo,
|
|
17
|
+
issue_number=issue_number,
|
|
18
|
+
transport="socket",
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
_CONFIGURED = False
|
|
31
|
+
|
|
32
|
+
# LogRecord attributes that should not be serialized as "extra" fields.
|
|
33
|
+
_RESERVED_ATTRS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"args",
|
|
36
|
+
"asctime",
|
|
37
|
+
"created",
|
|
38
|
+
"exc_info",
|
|
39
|
+
"exc_text",
|
|
40
|
+
"filename",
|
|
41
|
+
"funcName",
|
|
42
|
+
"levelname",
|
|
43
|
+
"levelno",
|
|
44
|
+
"lineno",
|
|
45
|
+
"message",
|
|
46
|
+
"module",
|
|
47
|
+
"msecs",
|
|
48
|
+
"msg",
|
|
49
|
+
"name",
|
|
50
|
+
"pathname",
|
|
51
|
+
"process",
|
|
52
|
+
"processName",
|
|
53
|
+
"relativeCreated",
|
|
54
|
+
"stack_info",
|
|
55
|
+
"taskName",
|
|
56
|
+
"thread",
|
|
57
|
+
"threadName",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class JSONFormatter(logging.Formatter):
|
|
63
|
+
"""Format records as single-line JSON with ``event`` + extra fields."""
|
|
64
|
+
|
|
65
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
|
|
68
|
+
"level": record.levelname,
|
|
69
|
+
"logger": record.name,
|
|
70
|
+
"event": record.getMessage(),
|
|
71
|
+
}
|
|
72
|
+
for key, value in record.__dict__.items():
|
|
73
|
+
if key in _RESERVED_ATTRS or key.startswith("_"):
|
|
74
|
+
continue
|
|
75
|
+
payload[key] = value
|
|
76
|
+
if record.exc_info:
|
|
77
|
+
payload["exc"] = self.formatException(record.exc_info)
|
|
78
|
+
return json.dumps(payload, default=str)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def configure_logging(level: int = logging.INFO) -> None:
|
|
82
|
+
"""Install a JSON handler on the ``ctrlrelay`` logger tree.
|
|
83
|
+
|
|
84
|
+
Safe to call repeatedly — subsequent calls are no-ops. Records propagate
|
|
85
|
+
to the root logger so pytest's ``caplog`` fixture (and any other ancestor
|
|
86
|
+
handlers) can still observe them.
|
|
87
|
+
"""
|
|
88
|
+
global _CONFIGURED
|
|
89
|
+
if _CONFIGURED:
|
|
90
|
+
return
|
|
91
|
+
logger = logging.getLogger("ctrlrelay")
|
|
92
|
+
logger.setLevel(level)
|
|
93
|
+
handler = logging.StreamHandler(stream=sys.stdout)
|
|
94
|
+
handler.setFormatter(JSONFormatter())
|
|
95
|
+
logger.addHandler(handler)
|
|
96
|
+
_CONFIGURED = True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_logger(name: str) -> logging.Logger:
|
|
100
|
+
"""Return a namespaced logger under ``ctrlrelay.<name>``."""
|
|
101
|
+
configure_logging()
|
|
102
|
+
return logging.getLogger(f"ctrlrelay.{name}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
|
|
106
|
+
"""Emit an INFO-level structured event.
|
|
107
|
+
|
|
108
|
+
``fields`` are attached as LogRecord attributes and surfaced by
|
|
109
|
+
:class:`JSONFormatter`. Avoid using keys that collide with stdlib
|
|
110
|
+
LogRecord attributes (``message``, ``name``, ``args`` …) — those are
|
|
111
|
+
reserved by the logging module and will raise ``KeyError``.
|
|
112
|
+
"""
|
|
113
|
+
logger.info(event, extra=fields)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def hash_text(text: str) -> str:
|
|
117
|
+
"""Short, stable SHA-256 prefix for PII-sensitive payloads."""
|
|
118
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|