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,411 @@
1
+ """Post-merge handling for dev pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ from ctrlrelay.core.github import GitHubCLI
9
+ from ctrlrelay.core.obs import get_logger, log_event
10
+ from ctrlrelay.core.pr_watcher import PRWatcher
11
+ from ctrlrelay.core.state import StateDB
12
+ from ctrlrelay.transports.base import Transport
13
+
14
+ _logger = get_logger("pipelines.post_merge")
15
+
16
+ # Default merge-watch window. Real PR review cycles commonly exceed a
17
+ # day (weekends, time-zone splits, larger changes), and a silent watch
18
+ # timeout means the linked issue never auto-closes and no merge
19
+ # notification fires. 7 days is the sweet spot: covers typical review
20
+ # lag without keeping a zombie task alive for months on an abandoned PR.
21
+ # Operators can still override via an explicit `timeout=` argument.
22
+ DEFAULT_PR_WATCH_TIMEOUT = 7 * 24 * 60 * 60
23
+
24
+ # After a merge is detected, handle_merge can fail transiently on
25
+ # `gh issue close` or the transport.send (network blip, rate limit).
26
+ # Without retry the whole automation is wasted — PR merged, issue
27
+ # stays open forever. Retry with modest backoff; if all attempts fail,
28
+ # the last failure is logged via dev.pr.watch_failed.
29
+ _HANDLE_MERGE_RETRY_ATTEMPTS = 5
30
+ _HANDLE_MERGE_RETRY_BASE_DELAY = 5 # seconds; doubled each attempt
31
+
32
+
33
+ def _stamp_phase(
34
+ state_db: StateDB | None,
35
+ repo: str,
36
+ pr_number: int,
37
+ phase: str,
38
+ session_id: str | None,
39
+ issue_number: int,
40
+ ) -> None:
41
+ """Best-effort persist the current cleanup phase. Called after
42
+ each side effect succeeds so a resumed watcher can skip work
43
+ already done. A DB failure must never abort the cleanup loop —
44
+ the alternative is abandoning a known-merged PR because SQLite
45
+ had a transient hiccup. Worst case with a swallowed failure is
46
+ a duplicate comment on the narrow restart window; best case
47
+ (DB healthy, which is essentially always) resume is exact."""
48
+ if state_db is None:
49
+ return
50
+ try:
51
+ state_db.set_pr_watch_cleanup_phase(repo, pr_number, phase)
52
+ except Exception as e:
53
+ log_event(
54
+ _logger, "dev.pr.watch_persist_failed",
55
+ session_id=session_id, repo=repo,
56
+ issue_number=issue_number, pr_number=pr_number,
57
+ reason=type(e).__name__, error=str(e)[:200],
58
+ phase=f"stamp:{phase}",
59
+ )
60
+
61
+
62
+ async def handle_merge(
63
+ repo: str,
64
+ pr_number: int,
65
+ issue_number: int,
66
+ github: GitHubCLI,
67
+ transport: Transport | None = None,
68
+ ) -> None:
69
+ """Handle post-merge actions."""
70
+ await github.close_issue(
71
+ repo,
72
+ issue_number,
73
+ f"Closed by PR #{pr_number}",
74
+ )
75
+
76
+ if transport:
77
+ await transport.send(
78
+ f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
79
+ )
80
+
81
+
82
+ async def watch_and_handle_merge(
83
+ repo: str,
84
+ pr_number: int,
85
+ issue_number: int,
86
+ github: GitHubCLI,
87
+ transport: Transport | None = None,
88
+ poll_interval: int = 60,
89
+ timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
90
+ ) -> bool:
91
+ """Watch for PR merge and handle post-merge actions."""
92
+ watcher = PRWatcher(github=github, poll_interval=poll_interval)
93
+
94
+ merged = await watcher.wait_for_merge(repo, pr_number, timeout=timeout)
95
+
96
+ if merged:
97
+ await handle_merge(
98
+ repo=repo,
99
+ pr_number=pr_number,
100
+ issue_number=issue_number,
101
+ github=github,
102
+ transport=transport,
103
+ )
104
+ return True
105
+
106
+ return False
107
+
108
+
109
+ async def _handle_merge_with_retry(
110
+ *,
111
+ repo: str,
112
+ pr_number: int,
113
+ issue_number: int,
114
+ github: GitHubCLI,
115
+ transport_factory: Callable[[], Awaitable[Transport | None]] | None,
116
+ session_id: str | None,
117
+ state_db: StateDB | None = None,
118
+ ) -> None:
119
+ """Run the post-merge close + notify steps with per-step idempotent
120
+ retry, rebuilding the transport on each attempt so a bridge restart
121
+ mid-retry doesn't permanently break the notification.
122
+
123
+ Idempotency within a single call: each side-effect is gated by its
124
+ own local flag so retries don't duplicate work.
125
+
126
+ Idempotency across restarts: when ``state_db`` is supplied, each
127
+ completed step stamps a ``cleanup_phase`` on the ``pr_watches``
128
+ row (commented → closed → notified). On entry we read the phase
129
+ and pre-set the local flags so a rehydrated watcher resumes from
130
+ where the prior process left off instead of re-posting the close
131
+ comment or re-firing the Telegram notification.
132
+
133
+ Transport rebuild: ``transport_factory`` is invoked INSIDE each
134
+ attempt so a bridge restart mid-retry gets a fresh socket.
135
+
136
+ On final exhaustion, raises the last exception; pr_watch_task's
137
+ outer handler logs it as dev.pr.watch_failed.
138
+ """
139
+ comment_posted = False
140
+ issue_closed = False
141
+ notification_sent = False
142
+ if state_db is not None:
143
+ # Rehydration: skip every step the prior process already
144
+ # completed. The phase is monotonic (commented → closed →
145
+ # notified) so higher phases imply all lower phases done.
146
+ try:
147
+ prior_phase = state_db.get_pr_watch_cleanup_phase(
148
+ repo, pr_number
149
+ )
150
+ except Exception:
151
+ prior_phase = None
152
+ if prior_phase in ("commented", "closed", "notified"):
153
+ comment_posted = True
154
+ if prior_phase in ("closed", "notified"):
155
+ issue_closed = True
156
+ if prior_phase == "notified":
157
+ notification_sent = True
158
+ last_exc: Exception | None = None
159
+ delay = _HANDLE_MERGE_RETRY_BASE_DELAY
160
+ comment = f"Closed by PR #{pr_number}"
161
+ notification = (
162
+ f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
163
+ )
164
+
165
+ for attempt in range(1, _HANDLE_MERGE_RETRY_ATTEMPTS + 1):
166
+ transport: Transport | None = None
167
+ transport_build_error: Exception | None = None
168
+ if not notification_sent and transport_factory is not None:
169
+ try:
170
+ transport = await transport_factory()
171
+ except Exception as e:
172
+ # Factory raised — bridge likely transiently unavailable.
173
+ # Record the error so we can retry on the next attempt
174
+ # instead of silently dropping the notification.
175
+ transport_build_error = e
176
+ log_event(
177
+ _logger, "dev.pr.watch_transport_failed",
178
+ session_id=session_id, repo=repo,
179
+ issue_number=issue_number, pr_number=pr_number,
180
+ attempt=attempt,
181
+ reason=type(e).__name__, error=str(e)[:200],
182
+ )
183
+ transport = None
184
+ # Factory returned None legitimately (e.g. non-Telegram
185
+ # config or socket intentionally absent) → no notification
186
+ # channel is configured; do NOT retry. `transport_build_error`
187
+ # remains None, so the "done" check below returns cleanly.
188
+
189
+ try:
190
+ if not comment_posted:
191
+ await github.comment_on_issue(repo, issue_number, comment)
192
+ comment_posted = True
193
+ _stamp_phase(state_db, repo, pr_number, "commented",
194
+ session_id, issue_number)
195
+ if not issue_closed:
196
+ await github._run_gh(
197
+ "issue", "close", str(issue_number), "--repo", repo,
198
+ )
199
+ issue_closed = True
200
+ _stamp_phase(state_db, repo, pr_number, "closed",
201
+ session_id, issue_number)
202
+ if not notification_sent and transport is not None:
203
+ await transport.send(notification)
204
+ notification_sent = True
205
+ _stamp_phase(state_db, repo, pr_number, "notified",
206
+ session_id, issue_number)
207
+
208
+ # Done when either: we sent the notification this round, OR
209
+ # the factory returned None meaning no channel was ever
210
+ # configured. Fall through to the retry path ONLY if the
211
+ # factory itself raised — that's a transient failure worth
212
+ # retrying so a brief bridge outage doesn't drop the
213
+ # notification permanently.
214
+ if notification_sent or transport_build_error is None:
215
+ return
216
+ raise transport_build_error
217
+ except asyncio.CancelledError:
218
+ raise
219
+ except Exception as e:
220
+ last_exc = e
221
+ log_event(
222
+ _logger, "dev.pr.handle_merge_retry",
223
+ session_id=session_id, repo=repo,
224
+ issue_number=issue_number, pr_number=pr_number,
225
+ attempt=attempt,
226
+ max_attempts=_HANDLE_MERGE_RETRY_ATTEMPTS,
227
+ comment_posted=comment_posted,
228
+ issue_closed=issue_closed,
229
+ notification_sent=notification_sent,
230
+ reason=type(e).__name__, error=str(e)[:200],
231
+ )
232
+ if attempt >= _HANDLE_MERGE_RETRY_ATTEMPTS:
233
+ break
234
+ await asyncio.sleep(delay)
235
+ delay = min(delay * 2, 300)
236
+ finally:
237
+ if transport is not None:
238
+ try:
239
+ await transport.close()
240
+ except Exception:
241
+ pass
242
+ assert last_exc is not None
243
+ raise last_exc
244
+
245
+
246
+ async def pr_watch_task(
247
+ *,
248
+ repo: str,
249
+ issue_number: int,
250
+ pr_url: str,
251
+ pr_number: int,
252
+ session_id: str | None,
253
+ github: GitHubCLI,
254
+ transport_factory: Callable[[], Awaitable[Transport | None]] | None = None,
255
+ poll_interval: int = 60,
256
+ timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
257
+ state_db: StateDB | None = None,
258
+ ) -> dict[str, Any]:
259
+ """Background-safe wrapper around the merge watcher that emits
260
+ structured ``dev.pr.*`` events and manages a transport LAZILY —
261
+ the transport is only created AFTER the merge is detected, so a
262
+ bridge that's down or restarted during the multi-day watch window
263
+ doesn't leave us with a stale connection at notification time.
264
+
265
+ ``transport_factory`` is an async callable returning a connected
266
+ transport (or None). Called once, right before ``handle_merge``;
267
+ its result is closed immediately after the merge handler runs.
268
+ A raising factory is logged and skipped (merge detection proceeds
269
+ without the notification channel rather than aborting the close).
270
+
271
+ ``state_db`` persists the watcher across poller restarts: a row is
272
+ inserted on ``dev.pr.watching`` and removed on the terminal
273
+ ``dev.pr.merged`` / ``dev.pr.watch_timeout`` events. It is NOT
274
+ removed on ``dev.pr.watch_cancelled`` — a cancellation during
275
+ shutdown must leave the row behind so the next poller startup can
276
+ rehydrate the watcher. StateDB I/O is best-effort: a disk failure
277
+ is swallowed so it can never prevent merge detection from
278
+ continuing in-memory.
279
+
280
+ Returns a dict describing the outcome:
281
+ {"merged": bool, "timed_out": bool, "cancelled": bool, "failed": str|None}
282
+ """
283
+ outcome: dict[str, Any] = {
284
+ "merged": False, "timed_out": False,
285
+ "cancelled": False, "failed": None,
286
+ }
287
+
288
+ # Persist the watch before any long-running work so a crash between
289
+ # `dev.pr.watching` and the first poll doesn't lose the row.
290
+ # Idempotent: a rehydrated task calling this again just refreshes
291
+ # started_at, which is fine.
292
+ if state_db is not None:
293
+ try:
294
+ state_db.add_pr_watch(
295
+ repo=repo, pr_number=pr_number,
296
+ issue_number=issue_number,
297
+ session_id=session_id,
298
+ pr_url=pr_url,
299
+ )
300
+ except Exception as e:
301
+ # Never let a state_db failure kill the watcher — degrade
302
+ # to in-memory-only and log so ops can see the durability
303
+ # gap.
304
+ log_event(
305
+ _logger, "dev.pr.watch_persist_failed",
306
+ session_id=session_id, repo=repo,
307
+ issue_number=issue_number, pr_number=pr_number,
308
+ reason=type(e).__name__, error=str(e)[:200],
309
+ )
310
+
311
+ log_event(
312
+ _logger, "dev.pr.watching",
313
+ session_id=session_id, repo=repo, issue_number=issue_number,
314
+ pr_number=pr_number, pr_url=pr_url,
315
+ )
316
+ try:
317
+ # Rehydration shortcut: if the prior process already observed
318
+ # the merge (any cleanup_phase stamped), skip wait_for_merge
319
+ # entirely and resume cleanup. Re-running wait_for_merge here
320
+ # would let a transient GH error or a brief rehydrate window
321
+ # log dev.pr.watch_timeout and delete the row, permanently
322
+ # dropping the remaining close/notify steps — exactly the
323
+ # failure the phase tracking is supposed to prevent.
324
+ resuming_cleanup = False
325
+ if state_db is not None:
326
+ try:
327
+ prior_phase = state_db.get_pr_watch_cleanup_phase(
328
+ repo, pr_number
329
+ )
330
+ except Exception:
331
+ prior_phase = None
332
+ if prior_phase is not None:
333
+ resuming_cleanup = True
334
+
335
+ if resuming_cleanup:
336
+ merged = True
337
+ log_event(
338
+ _logger, "dev.pr.watch_resumed",
339
+ session_id=session_id, repo=repo,
340
+ issue_number=issue_number, pr_number=pr_number,
341
+ cleanup_phase=prior_phase,
342
+ )
343
+ else:
344
+ watcher = PRWatcher(github=github, poll_interval=poll_interval)
345
+ merged = await watcher.wait_for_merge(
346
+ repo=repo, pr_number=pr_number, timeout=timeout,
347
+ )
348
+ # Record merge detection BEFORE running the post-merge handler,
349
+ # so an exhausted retry loop (e.g. permanent bridge outage) still
350
+ # leaves `outcome["merged"] = True`. The merge really did happen;
351
+ # only the cleanup chain is degraded, and `outcome["failed"]`
352
+ # already surfaces that via the except branch below.
353
+ outcome["merged"] = merged
354
+ outcome["timed_out"] = not merged
355
+ log_event(
356
+ _logger,
357
+ "dev.pr.merged" if merged else "dev.pr.watch_timeout",
358
+ session_id=session_id, repo=repo, issue_number=issue_number,
359
+ pr_number=pr_number,
360
+ )
361
+ if merged:
362
+ # Defer transport construction to inside the retry loop so
363
+ # each attempt gets a fresh connection — a bridge restart
364
+ # during the watch or between retries doesn't leave us
365
+ # hitting a dead socket forever.
366
+ await _handle_merge_with_retry(
367
+ repo=repo, pr_number=pr_number,
368
+ issue_number=issue_number, github=github,
369
+ transport_factory=transport_factory,
370
+ session_id=session_id,
371
+ state_db=state_db,
372
+ )
373
+ # Drop the durable row only AFTER post-merge cleanup fully
374
+ # completes (merged path) or after we've given up on timeout.
375
+ # If _handle_merge_with_retry raises — retries exhausted, bridge
376
+ # permanently broken — the except Exception branch below owns
377
+ # the row lifecycle and leaves it so the next poller startup
378
+ # rehydrates and retries cleanup. wait_for_merge returns True
379
+ # immediately on rehydrate since the PR is already merged.
380
+ if state_db is not None:
381
+ try:
382
+ state_db.remove_pr_watch(repo, pr_number)
383
+ except Exception as e:
384
+ log_event(
385
+ _logger, "dev.pr.watch_persist_failed",
386
+ session_id=session_id, repo=repo,
387
+ issue_number=issue_number, pr_number=pr_number,
388
+ reason=type(e).__name__, error=str(e)[:200],
389
+ phase="remove",
390
+ )
391
+ except asyncio.CancelledError:
392
+ outcome["cancelled"] = True
393
+ log_event(
394
+ _logger, "dev.pr.watch_cancelled",
395
+ session_id=session_id, repo=repo, issue_number=issue_number,
396
+ pr_number=pr_number,
397
+ )
398
+ # Deliberately DO NOT remove the pr_watches row on cancellation.
399
+ # Cancellation is the shutdown signal — the next poller startup
400
+ # must rehydrate this watcher or the PR's post-merge automation
401
+ # is lost for the remainder of its 7-day window.
402
+ raise
403
+ except Exception as e:
404
+ outcome["failed"] = type(e).__name__
405
+ log_event(
406
+ _logger, "dev.pr.watch_failed",
407
+ session_id=session_id, repo=repo, issue_number=issue_number,
408
+ pr_number=pr_number,
409
+ reason=type(e).__name__, error=str(e)[:200],
410
+ )
411
+ return outcome