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,277 @@
1
+ """Classify `gh` subprocess failures so operators get an actionable message.
2
+
3
+ `gh` exits 1 for everything: an unplugged network cable, an expired token
4
+ and a 500 from the API all surface as
5
+ ``returned non-zero exit status 1``. That sends operators debugging auth
6
+ when the real problem is that the laptop is offline (#32).
7
+
8
+ `gh` does write clear wording to stderr, so classification keys off the
9
+ stderr text rather than the exit code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import errno
15
+ import re
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+
20
+
21
+ class GhFailureKind(str, Enum):
22
+ """What actually went wrong behind a non-zero `gh` exit."""
23
+
24
+ NETWORK_UNAVAILABLE = "network_unavailable"
25
+ AUTH = "auth"
26
+ RATE_LIMITED = "rate_limited"
27
+ API_ERROR = "api_error"
28
+ GH_MISSING = "gh_missing"
29
+ TLS_TRUST = "tls_trust"
30
+
31
+
32
+ # Ordered most-specific-first within each group; matched case-insensitively
33
+ # against gh's stderr. Network patterns cover gh's own wording plus the Go
34
+ # net/http and OpenSSL strings it passes through verbatim.
35
+ _NETWORK_PATTERNS = (
36
+ "error connecting to",
37
+ "dial tcp",
38
+ "no such host",
39
+ "network is unreachable",
40
+ "no route to host",
41
+ "connection refused",
42
+ "connection timed out",
43
+ "connection reset by peer",
44
+ # A peer that vanished mid-write. Deliberately not matching bare
45
+ # "EOF" alongside it: gh passes API response bodies through verbatim
46
+ # and "EOF" is common enough in them to misclassify real API errors
47
+ # as outages, which is the mistake in the other direction.
48
+ "broken pipe",
49
+ "i/o timeout",
50
+ "tls handshake",
51
+ "x509:",
52
+ "certificate signed by",
53
+ "context deadline exceeded",
54
+ "temporary failure in name resolution",
55
+ "name resolution",
56
+ "proxyconnect tcp",
57
+ "server misbehaving",
58
+ "check your internet connection",
59
+ )
60
+
61
+ # errnos `subprocess.run` raises when it cannot EXEC the child — the file
62
+ # is absent, unreadable, a directory, or the wrong architecture. These
63
+ # say "your gh install is broken". Resource-exhaustion errnos (EMFILE,
64
+ # ENOMEM, EAGAIN) also surface as OSError here but mean nothing of the
65
+ # sort, and telling that operator to install GitHub CLI sends them the
66
+ # wrong way.
67
+ _EXEC_FAILURE_ERRNOS = frozenset(
68
+ e for e in (
69
+ errno.ENOENT, errno.EACCES, errno.EPERM, errno.ENOEXEC,
70
+ errno.EISDIR, errno.ENOTDIR, errno.ELOOP,
71
+ errno.ENAMETOOLONG, errno.ETXTBSY,
72
+ )
73
+ )
74
+
75
+ # Persistent local trust problems, not outages: a corporate MITM proxy
76
+ # without its CA installed, or a clock skewed far enough to invalidate a
77
+ # valid certificate. "Retry later" is wrong advice for both. A cert valid
78
+ # for the wrong host IS usually a captive portal, so that stays network.
79
+ _TLS_TRUST_PATTERNS = (
80
+ "certificate signed by unknown authority",
81
+ "certificate has expired or is not yet valid",
82
+ "unable to get local issuer certificate",
83
+ "self-signed certificate",
84
+ "self signed certificate",
85
+ )
86
+
87
+ _RATE_LIMIT_PATTERNS = (
88
+ "rate limit exceeded",
89
+ "secondary rate limit",
90
+ "too many requests",
91
+ "http 429",
92
+ )
93
+
94
+ _AUTH_PATTERNS = (
95
+ "bad credentials",
96
+ "requires authentication",
97
+ "gh auth login",
98
+ "not logged in",
99
+ "authentication failed",
100
+ "http 401",
101
+ "required scopes",
102
+ "needs the '",
103
+ "token has not been granted",
104
+ "gh_token",
105
+ "github_token",
106
+ )
107
+
108
+ _HTTP_STATUS_RE = re.compile(r"\(?\bHTTP (\d{3})\b\)?", re.IGNORECASE)
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class GhFailure:
113
+ """A classified `gh` failure plus the raw evidence behind it."""
114
+
115
+ kind: GhFailureKind
116
+ message: str
117
+ stderr: str = ""
118
+ returncode: int | None = None
119
+ status: int | None = None
120
+
121
+ @property
122
+ def is_transient(self) -> bool:
123
+ """True when retrying later could plausibly succeed without the
124
+ operator changing anything."""
125
+ return self.kind in (
126
+ GhFailureKind.NETWORK_UNAVAILABLE,
127
+ GhFailureKind.RATE_LIMITED,
128
+ )
129
+
130
+
131
+ def _decode(stderr: str | bytes | None) -> str:
132
+ if stderr is None:
133
+ return ""
134
+ if isinstance(stderr, bytes):
135
+ stderr = stderr.decode("utf-8", errors="replace")
136
+ return stderr.strip()
137
+
138
+
139
+ # Go renders a failed HTTP round-trip as `*url.Error`:
140
+ # Get "https://api.github.com/user": <cause>
141
+ # By construction that means NO HTTP response was received, so it can
142
+ # never be an API error — whatever the cause reads like. `gh api` prints
143
+ # a real API error in the other shape entirely, `gh: <msg> (HTTP nnn)`,
144
+ # so the two do not overlap. Keying on the shape catches the whole family
145
+ # (EOF, http2 connection lost, request canceled, remote error: tls: …)
146
+ # instead of chasing each new wording as a string.
147
+ _URL_ERROR_RE = re.compile(r'^(?:get|post|put|patch|delete|head)\s+"https?://', re.I)
148
+
149
+
150
+ def _is_transport_failure(text: str) -> bool:
151
+ """True when the text is Go's url.Error shape — request issued, no
152
+ response received."""
153
+ return any(
154
+ _URL_ERROR_RE.match(line.strip())
155
+ for line in text.splitlines()
156
+ if line.strip()
157
+ )
158
+
159
+
160
+ def _kind_for(text: str) -> GhFailureKind:
161
+ lowered = text.lower()
162
+ # Checked before the network patterns: these strings contain
163
+ # "x509:", but retrying never fixes a CA bundle or a skewed clock.
164
+ if any(pattern in lowered for pattern in _TLS_TRUST_PATTERNS):
165
+ return GhFailureKind.TLS_TRUST
166
+ if any(pattern in lowered for pattern in _NETWORK_PATTERNS):
167
+ return GhFailureKind.NETWORK_UNAVAILABLE
168
+ if _is_transport_failure(text):
169
+ return GhFailureKind.NETWORK_UNAVAILABLE
170
+ # Rate-limit bodies mention authentication ("Authenticated requests get
171
+ # a higher rate limit"), so they have to be matched before auth.
172
+ if any(pattern in lowered for pattern in _RATE_LIMIT_PATTERNS):
173
+ return GhFailureKind.RATE_LIMITED
174
+ if any(pattern in lowered for pattern in _AUTH_PATTERNS):
175
+ return GhFailureKind.AUTH
176
+ return GhFailureKind.API_ERROR
177
+
178
+
179
+ def _message_for(kind: GhFailureKind, status: int | None) -> str:
180
+ if kind is GhFailureKind.NETWORK_UNAVAILABLE:
181
+ return (
182
+ "Network unavailable — could not reach api.github.com. "
183
+ "Check connectivity or https://githubstatus.com, then retry."
184
+ )
185
+ if kind is GhFailureKind.RATE_LIMITED:
186
+ return (
187
+ "GitHub rate limit exceeded — wait for the limit to reset, "
188
+ "then retry."
189
+ )
190
+ if kind is GhFailureKind.AUTH:
191
+ return (
192
+ "GitHub credentials rejected — run `gh auth status` "
193
+ "(then `gh auth login`) and retry."
194
+ )
195
+ if kind is GhFailureKind.TLS_TRUST:
196
+ return (
197
+ "TLS certificate not trusted — the connection to api.github.com "
198
+ "was intercepted or the certificate could not be verified. "
199
+ "Install your proxy's CA certificate, or check the system clock. "
200
+ "Retrying will not help."
201
+ )
202
+ if kind is GhFailureKind.GH_MISSING:
203
+ return (
204
+ "The `gh` CLI could not be run — install GitHub CLI, make sure "
205
+ "it is on PATH, and check the file is executable."
206
+ )
207
+ if status is not None:
208
+ return f"GitHub API returned an error (HTTP {status})."
209
+ return "GitHub API call failed."
210
+
211
+
212
+ def classify_gh_failure(
213
+ stderr: str | bytes | None, returncode: int | None = None
214
+ ) -> GhFailure:
215
+ """Classify one `gh` failure from the stderr it wrote."""
216
+ text = _decode(stderr)
217
+ kind = _kind_for(text)
218
+ match = _HTTP_STATUS_RE.search(text)
219
+ status = int(match.group(1)) if match else None
220
+ return GhFailure(
221
+ kind=kind,
222
+ message=_message_for(kind, status),
223
+ stderr=text,
224
+ returncode=returncode,
225
+ status=status,
226
+ )
227
+
228
+
229
+ def gh_failure_from_exception(exc: BaseException) -> GhFailure:
230
+ """Classify whatever a `gh` subprocess call raised.
231
+
232
+ Handles the three things `subprocess.run(..., check=True)` can throw at
233
+ a caller: a non-zero exit, a missing binary, and a timeout — plus bare
234
+ `OSError` for socket-level failures raised by Python rather than gh.
235
+ """
236
+ if isinstance(exc, FileNotFoundError):
237
+ # Checked before OSError: a missing gh is a setup problem, and
238
+ # calling it "offline" would send operators the wrong way.
239
+ return GhFailure(
240
+ kind=GhFailureKind.GH_MISSING,
241
+ message=_message_for(GhFailureKind.GH_MISSING, None),
242
+ stderr=str(exc),
243
+ )
244
+ if isinstance(exc, subprocess.CalledProcessError):
245
+ return classify_gh_failure(exc.stderr, returncode=exc.returncode)
246
+ if isinstance(exc, (subprocess.TimeoutExpired, TimeoutError)):
247
+ # A hung gh is the one exception shape that really does suggest
248
+ # connectivity: the child launched and then stopped responding.
249
+ return GhFailure(
250
+ kind=GhFailureKind.NETWORK_UNAVAILABLE,
251
+ message=_message_for(GhFailureKind.NETWORK_UNAVAILABLE, None),
252
+ stderr=str(exc),
253
+ )
254
+ if isinstance(exc, OSError):
255
+ # An OSError here came from `subprocess.run` failing to launch the
256
+ # child. Python is not doing the networking — gh is — so it can
257
+ # never mean "offline"; reporting it that way is the exact
258
+ # misdiagnosis this module exists to prevent.
259
+ #
260
+ # But only the EXEC-shaped errnos mean the install is broken. A
261
+ # process-table or memory limit (EMFILE, ENOMEM, EAGAIN) also
262
+ # lands here, and answering that with "install GitHub CLI" is
263
+ # just the same misdirection wearing a different hat. Fall
264
+ # through for those: the caller still prints the raw errno text,
265
+ # which is the actionable part.
266
+ if exc.errno in _EXEC_FAILURE_ERRNOS:
267
+ return GhFailure(
268
+ kind=GhFailureKind.GH_MISSING,
269
+ message=_message_for(GhFailureKind.GH_MISSING, None),
270
+ stderr=str(exc),
271
+ )
272
+ return GhFailure(
273
+ kind=GhFailureKind.API_ERROR,
274
+ message=f"Could not run `gh`: {exc}",
275
+ stderr=str(exc),
276
+ )
277
+ return classify_gh_failure(str(exc))
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]