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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- 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
|