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.
@@ -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]