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,920 @@
1
+ """GitHub Issue Poller for ctrlrelay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Awaitable, Callable
11
+
12
+ from ctrlrelay.core.github import GitHubCLI, GitHubError
13
+ from ctrlrelay.core.obs import get_logger, log_event
14
+
15
+ _logger = get_logger("core.poller")
16
+
17
+ # Exceptions that are transient and should skip the current repo/iteration
18
+ # rather than tear the whole poll loop down. asyncio.CancelledError is
19
+ # deliberately excluded so a shutdown signal still propagates.
20
+ #
21
+ # GitHubError is included because we can't distinguish transient (rate
22
+ # limit, 5xx, network) from permanent (bad repo name, expired auth, 404)
23
+ # without fragile error-message parsing — classifying both as skip avoids
24
+ # crashes. A persistent-failure counter (see below) makes permanent
25
+ # misconfiguration visible even though it's technically skipped here.
26
+ _TRANSIENT_POLL_ERRORS = (TimeoutError, GitHubError, OSError)
27
+
28
+ # Seed-time retry policy for the per-label include-labels query. A
29
+ # seed-time failure here (not retried) would leak pre-existing
30
+ # labeled backlog as "new work" on the first healthy poll. 3 × 2s
31
+ # handles typical network blips without adding meaningful startup
32
+ # latency. A persistent failure past the retry budget falls back to
33
+ # the existing per-label isolation (log + continue), so assignee-
34
+ # triggered backlog and other labels still seed cleanly.
35
+ _SEED_LABEL_RETRY_ATTEMPTS = 3
36
+ _SEED_LABEL_RETRY_SLEEP_SECONDS = 2.0
37
+
38
+ # After this many consecutive per-repo failures, escalate log level to
39
+ # WARNING so a persistent misconfiguration (expired auth, renamed repo,
40
+ # revoked access) stops hiding behind routine "transient" skip logs.
41
+ _REPO_FAILURE_WARN_THRESHOLD = 3
42
+
43
+
44
+ def _is_issues_disabled_error(exc: Exception) -> bool:
45
+ """Detect the specific GitHubError raised when a repo has its Issues
46
+ feature disabled. This is a permanent state (not a transient API
47
+ failure), so callers should skip the repo rather than retry it on every
48
+ poll cycle."""
49
+ if not isinstance(exc, GitHubError):
50
+ return False
51
+ return "has disabled issues" in str(exc).lower()
52
+
53
+
54
+ @dataclass
55
+ class IssuePoller:
56
+ """Polls GitHub repos for newly assigned issues.
57
+
58
+ Maintains a set of seen issue numbers per repo so that only genuinely new
59
+ issues are surfaced on each call to ``poll()``.
60
+
61
+ By default, new issues are filtered to those where the most recent
62
+ ``assigned`` event naming ``username`` was performed by ``username``
63
+ themselves — i.e. self-assignment only. Repos listed in
64
+ ``accept_foreign_assignments`` bypass this check.
65
+
66
+ ``exclude_labels_by_repo`` gives the operator a way to mark issues as
67
+ "not for the agent" (operator tasks, pure instructions, manual work).
68
+ Matched issues are marked seen so they don't keep reappearing, logged
69
+ under ``poll.issue.excluded_by_label``, and never handed to the dev
70
+ pipeline. The exclusion check runs BEFORE the assignment-event lookup
71
+ so it short-circuits the extra ``gh`` call for excluded issues.
72
+ Matching is case-insensitive.
73
+
74
+ ``include_labels_by_repo`` is the opt-in complement (see #80). When a
75
+ repo has any include-labels configured, the poller drops the
76
+ ``--assignee`` filter on that repo's ``gh`` query and instead fetches
77
+ all open issues, then accepts any issue that **either** is assigned
78
+ to the operator (the pre-#80 behavior) **or** carries at least one of
79
+ the configured labels. Matching is case-insensitive. Label-matched
80
+ issues skip the self-assignment event check — the label itself is
81
+ the trust signal that the operator opted into by configuring it.
82
+ An issue that both carries a matching label and is assigned to the
83
+ operator is accepted exactly once (not duplicated in ``new_issues``
84
+ or ``seen_issues``). A repo with an empty / missing ``include_labels``
85
+ keeps the pre-#80 ``--assignee``-filtered query so we don't
86
+ over-fetch.
87
+
88
+ ``require_labels_by_repo`` tightens the plain-assignment trigger
89
+ (see #139). When a repo has any require-labels configured, bare
90
+ assignment is no longer sufficient by itself — an issue must be
91
+ assigned to the operator AND carry at least one of the configured
92
+ labels to be admitted via the assignment path. ``include_labels``
93
+ is unaffected: a label_match there still admits the issue
94
+ regardless of ``require_labels``. An issue that's assigned but
95
+ missing the required label is left unmarked (not seen) so a later
96
+ label addition still surfaces it on a subsequent poll.
97
+ """
98
+
99
+ github: GitHubCLI
100
+ username: str
101
+ repos: list[str]
102
+ state_file: Path
103
+ seen_issues: dict[str, set[int]] = field(default_factory=dict)
104
+ accept_foreign_assignments: set[str] = field(default_factory=set)
105
+ exclude_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
106
+ include_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
107
+ require_labels_by_repo: dict[str, list[str]] = field(default_factory=dict)
108
+ # Per-repo consecutive-skip counter; populated at runtime by poll() /
109
+ # seed_current(). Not persisted — intentionally resets on daemon
110
+ # restart so an operator fix is exercised before we re-escalate.
111
+ _repo_failure_counts: dict[str, int] = field(default_factory=dict, repr=False)
112
+ # Repos with GitHub Issues feature disabled — a permanent state, not a
113
+ # transient fetch error. Populated on first encounter and kept for the
114
+ # daemon lifetime so we don't spam WARNING logs every 120s cycle.
115
+ # Resets on daemon restart so a fresh detection still runs if the repo
116
+ # re-enables issues in the meantime.
117
+ _issues_disabled_repos: set[str] = field(default_factory=set, repr=False)
118
+
119
+ def __post_init__(self) -> None:
120
+ self._load_state()
121
+
122
+ # ------------------------------------------------------------------
123
+ # State persistence
124
+ # ------------------------------------------------------------------
125
+
126
+ def _load_state(self) -> None:
127
+ """Load seen issues from the JSON state file (if it exists)."""
128
+ if not self.state_file.exists():
129
+ return
130
+ try:
131
+ data = json.loads(self.state_file.read_text())
132
+ raw = data.get("seen_issues", {})
133
+ self.seen_issues = {repo: set(numbers) for repo, numbers in raw.items()}
134
+ except (json.JSONDecodeError, OSError):
135
+ # Corrupt or unreadable state — start fresh
136
+ self.seen_issues = {}
137
+
138
+ def _save_state(self) -> None:
139
+ """Persist seen issues and a ``last_poll`` timestamp to the state file."""
140
+ data = {
141
+ "seen_issues": {
142
+ repo: sorted(numbers) for repo, numbers in self.seen_issues.items()
143
+ },
144
+ "last_poll": datetime.now(timezone.utc).isoformat(),
145
+ }
146
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
147
+ self.state_file.write_text(json.dumps(data, indent=2))
148
+
149
+ def _save_state_best_effort(self) -> None:
150
+ """Try to persist state; log and continue on disk errors.
151
+
152
+ Callers MUST NOT let a _save_state failure propagate out of poll() —
153
+ doing so would drop the new-issues list on the floor while the
154
+ in-memory seen_issues set has already been mutated, silently
155
+ abandoning the work until the daemon restarts.
156
+ """
157
+ try:
158
+ self._save_state()
159
+ except OSError as e:
160
+ log_event(
161
+ _logger,
162
+ "poll.save_state.failed",
163
+ reason=type(e).__name__,
164
+ error=str(e)[:200],
165
+ state_file=str(self.state_file),
166
+ )
167
+
168
+ def _record_repo_failure(
169
+ self,
170
+ repo: str,
171
+ exc: Exception,
172
+ *,
173
+ phase: str = "poll",
174
+ ) -> None:
175
+ """Bump the consecutive-failure counter and log with an escalated
176
+ level once the threshold is reached. ``phase`` distinguishes
177
+ poll-time vs seed-time skips in the event payload."""
178
+ count = self._repo_failure_counts.get(repo, 0) + 1
179
+ self._repo_failure_counts[repo] = count
180
+ fields = {
181
+ "repo": repo,
182
+ "reason": type(exc).__name__,
183
+ "error": str(exc)[:200],
184
+ "consecutive_failures": count,
185
+ "phase": phase,
186
+ }
187
+ if count >= _REPO_FAILURE_WARN_THRESHOLD:
188
+ fields["persistent"] = True
189
+ _logger.warning("poll.repo.skipped", extra=fields)
190
+ else:
191
+ log_event(_logger, "poll.repo.skipped", **fields)
192
+
193
+ def _clear_repo_failure(self, repo: str) -> None:
194
+ """Reset the failure counter after a successful repo lookup."""
195
+ self._repo_failure_counts.pop(repo, None)
196
+
197
+ def _mark_issues_disabled(self, repo: str) -> None:
198
+ """Mark a repo as having GitHub Issues disabled. Logged once at INFO
199
+ level so the operator can see which repos won't be polled; future
200
+ cycles skip the `gh` call entirely until daemon restart."""
201
+ if repo in self._issues_disabled_repos:
202
+ return
203
+ self._issues_disabled_repos.add(repo)
204
+ # Any accumulated transient-failure count is meaningless once we've
205
+ # identified the error as permanent — clear it so the restart counter
206
+ # starts fresh if the repo ever re-enables issues.
207
+ self._repo_failure_counts.pop(repo, None)
208
+ log_event(
209
+ _logger,
210
+ "poll.repo.issues_disabled",
211
+ repo=repo,
212
+ action="skipping permanently until daemon restart",
213
+ )
214
+
215
+ # ------------------------------------------------------------------
216
+ # Public API
217
+ # ------------------------------------------------------------------
218
+
219
+ async def poll(self) -> list[dict[str, Any]]:
220
+ """Poll all configured repos for new issues assigned to ``self.username``.
221
+
222
+ Returns:
223
+ A list of ``{"repo": str, "issue": dict}`` entries for issues that
224
+ have not been seen before. Updates ``seen_issues`` and persists
225
+ state to disk.
226
+
227
+ Per-repo resilience: a transient failure on one repo (network timeout,
228
+ ``gh`` exit, OS error) is logged and skipped so the other repos still
229
+ get polled. Only ``asyncio.CancelledError`` escapes, which allows a
230
+ clean shutdown signal to propagate.
231
+
232
+ Issues carrying any label from ``exclude_labels_by_repo[repo]`` are
233
+ marked seen and dropped before the assignment-event check runs, so
234
+ operator-only / instruction-only issues never reach the dev pipeline
235
+ and we don't pay a second ``gh`` call for them.
236
+
237
+ Repos configured with ``include_labels_by_repo[repo]`` skip the
238
+ server-side ``--assignee`` filter and fetch all open issues, then
239
+ accept any that either (a) match one of the include-labels or
240
+ (b) are assigned to the operator. Issues matching neither are
241
+ left alone (NOT marked seen) so a later label-add or assignment
242
+ will still surface them. See #80.
243
+ """
244
+ new_issues: list[dict[str, Any]] = []
245
+
246
+ for repo in self.repos:
247
+ # Repos with GitHub Issues disabled will never return issues; skip
248
+ # before the `gh` call so we don't log the same error every cycle.
249
+ if repo in self._issues_disabled_repos:
250
+ continue
251
+ # Per-repo include-labels controls the fetch strategy. With
252
+ # no include-labels configured we keep the pre-#80
253
+ # ``--assignee`` server-side filter (cheap, small result).
254
+ # With include-labels configured we run one TARGETED query
255
+ # per trigger — the assignee query plus one
256
+ # ``--label <L>`` query per configured label — and merge by
257
+ # issue number. An unfiltered ``gh issue list`` would cap at
258
+ # --limit (default 100) and silently miss labeled issues on
259
+ # later pages in a busy repo (codex P1 on the first cut).
260
+ include_labels = self.include_labels_by_repo.get(repo, [])
261
+ include_lowered = {label.lower() for label in include_labels}
262
+ # Tracks issue numbers that came from the server-side
263
+ # assignee query. Used in the poll loop below to trust
264
+ # the server filter rather than re-deriving is_assigned
265
+ # from the returned JSON (codex P2 round-8). A payload
266
+ # with a missing/empty assignees field from the assignee
267
+ # query is still a confirmed assignee hit — gh wouldn't
268
+ # have returned it otherwise.
269
+ assignee_trigger_numbers: set[int] = set()
270
+ try:
271
+ if include_lowered:
272
+ by_number: dict[int, dict[str, Any]] = {}
273
+ # Assignee query is the baseline — any failure
274
+ # here is treated as a repo-level failure (same
275
+ # as non-include-labels repos). Label queries
276
+ # below are isolated per-label so one flaky
277
+ # label filter doesn't starve the assignee
278
+ # path (codex P2 round-4).
279
+ assignee_issues = await self.github.list_assigned_issues(
280
+ repo, assignee=self.username,
281
+ )
282
+ for i in assignee_issues:
283
+ try:
284
+ num = int(i["number"])
285
+ except Exception:
286
+ continue
287
+ by_number[num] = i
288
+ assignee_trigger_numbers.add(num)
289
+ for label in include_labels:
290
+ try:
291
+ labeled = await self.github.list_issues_by_label(
292
+ repo, label=label,
293
+ )
294
+ except asyncio.CancelledError:
295
+ raise
296
+ except Exception as label_e:
297
+ # Isolate per-label failures so one bad
298
+ # label query doesn't drop the whole
299
+ # repo's intake. Log and continue — the
300
+ # assignee-triggered issues we already
301
+ # fetched still flow through the normal
302
+ # pipeline; the missed label query will
303
+ # be retried on the next poll cycle.
304
+ log_event(
305
+ _logger,
306
+ "poll.repo.label_query_failed",
307
+ repo=repo,
308
+ label=label,
309
+ reason=type(label_e).__name__,
310
+ error=str(label_e)[:200],
311
+ )
312
+ continue
313
+ for i in labeled:
314
+ try:
315
+ by_number.setdefault(int(i["number"]), i)
316
+ except Exception:
317
+ pass
318
+ issues = list(by_number.values())
319
+ else:
320
+ issues = await self.github.list_assigned_issues(
321
+ repo, assignee=self.username,
322
+ )
323
+ except asyncio.CancelledError:
324
+ raise
325
+ except Exception as e:
326
+ if _is_issues_disabled_error(e):
327
+ self._mark_issues_disabled(repo)
328
+ continue
329
+ # Transient-ish (TimeoutError/GitHubError/OSError) goes through
330
+ # the failure counter so persistent misconfig escalates; any
331
+ # other unexpected exception is logged as a skip too so the
332
+ # surrounding repos still get processed AND new_issues from
333
+ # prior repos reaches the caller. Without this catch, a later
334
+ # repo exploding would leave earlier repos' seen_issues
335
+ # mutated but their new_issues list unreturned.
336
+ if isinstance(e, _TRANSIENT_POLL_ERRORS):
337
+ self._record_repo_failure(repo, e, phase="poll")
338
+ else:
339
+ log_event(
340
+ _logger,
341
+ "poll.repo.unexpected_error",
342
+ repo=repo,
343
+ reason=type(e).__name__,
344
+ error=str(e)[:200],
345
+ phase="poll",
346
+ )
347
+ continue
348
+
349
+ # Successful lookup — clear any accumulated failure count.
350
+ self._clear_repo_failure(repo)
351
+
352
+ seen_for_repo = self.seen_issues.setdefault(repo, set())
353
+ exclude_lowered = {
354
+ label.lower()
355
+ for label in self.exclude_labels_by_repo.get(repo, [])
356
+ }
357
+ require_lowered = {
358
+ label.lower()
359
+ for label in self.require_labels_by_repo.get(repo, [])
360
+ }
361
+ for issue in issues:
362
+ # Per-issue guard so ONE malformed payload (missing 'number',
363
+ # wrong type, non-dict entry) doesn't poison the remaining
364
+ # good issues in the same repo's batch.
365
+ try:
366
+ number = int(issue["number"])
367
+ except asyncio.CancelledError:
368
+ raise
369
+ except Exception as e:
370
+ log_event(
371
+ _logger,
372
+ "poll.issue.malformed",
373
+ repo=repo,
374
+ reason=type(e).__name__,
375
+ error=str(e)[:200],
376
+ )
377
+ continue
378
+
379
+ if number in seen_for_repo:
380
+ continue
381
+
382
+ # Exclude-label filter runs before the assignment-event
383
+ # lookup so operator-only issues never trigger a second
384
+ # gh call, and are permanently marked seen so they stop
385
+ # re-appearing in future polls.
386
+ matched = self._matched_exclude_label(issue, exclude_lowered)
387
+ if matched is not None:
388
+ seen_for_repo.add(number)
389
+ log_event(
390
+ _logger,
391
+ "poll.issue.excluded_by_label",
392
+ repo=repo,
393
+ issue_number=number,
394
+ matched_label=matched,
395
+ )
396
+ continue
397
+
398
+ # Two positive triggers, checked in this order:
399
+ # 1. include-label match (#80): the operator opted this
400
+ # label into the pipeline, so the label IS the trust
401
+ # signal — no self-assignment check needed.
402
+ # 2. assignment to operator: pre-#80 path, still needs
403
+ # the self-assignment event check.
404
+ # With targeted queries (assignee + per-label) every
405
+ # issue surfaced here already matched at least one
406
+ # trigger, but we keep the is_assigned check so the
407
+ # label-match branch below still knows when to skip the
408
+ # self-assignment event lookup. On repos without
409
+ # include_labels, the server already filtered to the
410
+ # assignee, so every issue that makes it here is
411
+ # assignment-triggered — we skip the client-side
412
+ # assignment check to preserve pre-#80 behavior (some
413
+ # payloads don't include ``assignees`` in fixtures or
414
+ # truncated responses).
415
+ label_match = self._matched_include_label(issue, include_lowered)
416
+ if include_lowered:
417
+ # Trust the server-side --assignee filter: an
418
+ # issue returned from that query IS assigned,
419
+ # even if the payload's assignees field is
420
+ # sparse (codex P2 round-8). Only fall back to
421
+ # the client-side JSON check for issues that
422
+ # only came from the label query, since those
423
+ # were not filtered by assignee.
424
+ if number in assignee_trigger_numbers:
425
+ is_assigned = True
426
+ else:
427
+ is_assigned = self._issue_is_assigned_to(
428
+ issue, self.username,
429
+ )
430
+ if (
431
+ is_assigned
432
+ and require_lowered
433
+ and self._matched_require_label(
434
+ issue, require_lowered,
435
+ ) is None
436
+ ):
437
+ # Assigned but missing the required label
438
+ # (#139): the plain-assignment trigger doesn't
439
+ # count on this repo. label_match (below) can
440
+ # still admit the issue independently.
441
+ is_assigned = False
442
+ if label_match is None and not is_assigned:
443
+ # Defensive: a targeted query should never
444
+ # return an issue that matches neither trigger,
445
+ # but if gh's label filter ever has a quirk
446
+ # (renamed labels, case, pagination), we'd
447
+ # rather leave the issue unmarked than
448
+ # swallow it permanently.
449
+ continue
450
+ else:
451
+ # Server-side ``--assignee`` already filtered; by
452
+ # construction every issue reaching here is
453
+ # assignment-triggered — unless require_labels
454
+ # (#139) is configured, in which case the missing
455
+ # label leaves the issue unmarked for a later poll.
456
+ if require_lowered and self._matched_require_label(
457
+ issue, require_lowered,
458
+ ) is None:
459
+ continue
460
+ is_assigned = True
461
+
462
+ if label_match is not None:
463
+ # Label match bypasses the self-assignment check; the
464
+ # operator's config choice is the trust boundary.
465
+ # Mark seen so the same label-triggered issue isn't
466
+ # re-surfaced on every poll.
467
+ seen_for_repo.add(number)
468
+ log_event(
469
+ _logger,
470
+ "poll.issue.included_by_label",
471
+ repo=repo,
472
+ issue_number=number,
473
+ matched_label=label_match,
474
+ )
475
+ new_issues.append({"repo": repo, "issue": issue})
476
+ continue
477
+
478
+ # Assignment-only path: run the self-assignment event
479
+ # check exactly as before.
480
+ events_check_failed = False
481
+ try:
482
+ accepted = await self._is_self_assigned(repo, number)
483
+ except asyncio.CancelledError:
484
+ raise
485
+ except Exception as e:
486
+ # Treat any failure to check assignment events as
487
+ # foreign-equivalent: don't run the pipeline, but leave
488
+ # the issue marked seen so we don't hammer the events
489
+ # endpoint on every poll.
490
+ log_event(
491
+ _logger,
492
+ "poll.issue.assignment_check_failed",
493
+ repo=repo,
494
+ number=number,
495
+ reason=type(e).__name__,
496
+ error=str(e)[:200],
497
+ )
498
+ accepted = False
499
+ events_check_failed = True
500
+
501
+ if accepted:
502
+ # Self-assigned: admit and mark seen.
503
+ seen_for_repo.add(number)
504
+ new_issues.append({"repo": repo, "issue": issue})
505
+ elif events_check_failed:
506
+ # Transient events-API failure (codex P2 round-4).
507
+ # Mark seen regardless of include_labels to avoid
508
+ # retry churn during an outage — would otherwise
509
+ # hit /issues/{n}/events every poll until the API
510
+ # recovers. Tradeoff: if a teammate later adds an
511
+ # opt-in label while this issue is in seen_for_repo,
512
+ # the label won't re-trigger this specific issue.
513
+ # That's narrow enough to accept; re-applying the
514
+ # label after the API recovers is the workaround.
515
+ seen_for_repo.add(number)
516
+ elif not include_lowered:
517
+ # Confirmed foreign-assigned on a pre-#80 repo:
518
+ # mark seen to avoid re-checking events every poll.
519
+ seen_for_repo.add(number)
520
+ # Confirmed foreign on include_labels repos is
521
+ # INTENTIONALLY left unmarked (codex P1 round-2 fix)
522
+ # so a later label addition can still trigger. The
523
+ # cost is one events check per poll per foreign
524
+ # issue — bounded by the operator's own assignment
525
+ # count, which is typically small.
526
+
527
+ # Never propagate a save_state disk failure out of poll() — the
528
+ # caller has work to do with new_issues. Log and move on.
529
+ self._save_state_best_effort()
530
+ return new_issues
531
+
532
+ async def _is_self_assigned(self, repo: str, issue_number: int) -> bool:
533
+ """Check if the most recent ``assigned`` event naming ``self.username``
534
+ was performed by ``self.username`` themselves.
535
+
536
+ Repos in ``accept_foreign_assignments`` short-circuit to ``True``.
537
+ Foreign assignments (or an empty event list) emit a
538
+ ``poll.issue.foreign_assignment`` log record and return ``False``.
539
+ """
540
+ if repo in self.accept_foreign_assignments:
541
+ return True
542
+
543
+ events = await self.github.list_assignment_events(repo, issue_number)
544
+ relevant = [
545
+ e
546
+ for e in events
547
+ if (e.get("assignee") or {}).get("login") == self.username
548
+ ]
549
+ if not relevant:
550
+ log_event(
551
+ _logger,
552
+ "poll.issue.foreign_assignment",
553
+ repo=repo,
554
+ number=issue_number,
555
+ assigner_login=None,
556
+ reason="no_self_assignment_event",
557
+ )
558
+ return False
559
+
560
+ # Events endpoint returns chronological order; the last one wins.
561
+ latest = relevant[-1]
562
+ assigner_login = (latest.get("actor") or {}).get("login")
563
+ if assigner_login == self.username:
564
+ return True
565
+
566
+ log_event(
567
+ _logger,
568
+ "poll.issue.foreign_assignment",
569
+ repo=repo,
570
+ number=issue_number,
571
+ assigner_login=assigner_login,
572
+ )
573
+ return False
574
+
575
+ @staticmethod
576
+ def _matched_exclude_label(
577
+ issue: dict[str, Any], exclude_lowered: set[str]
578
+ ) -> str | None:
579
+ """Return the first issue label that matches ``exclude_lowered``.
580
+
581
+ Labels come back from ``gh issue list`` as ``[{"name": "...", ...}]``;
582
+ we also accept a plain list of strings for flexibility in tests.
583
+ Matching is case-insensitive; the returned value is the label's
584
+ original casing so log output reflects what's actually on the issue.
585
+ """
586
+ if not exclude_lowered:
587
+ return None
588
+ for label in issue.get("labels") or []:
589
+ if isinstance(label, dict):
590
+ name = label.get("name", "")
591
+ else:
592
+ name = str(label)
593
+ if name and name.lower() in exclude_lowered:
594
+ return name
595
+ return None
596
+
597
+ @staticmethod
598
+ def _matched_include_label(
599
+ issue: dict[str, Any], include_lowered: set[str]
600
+ ) -> str | None:
601
+ """Return the first issue label that matches ``include_lowered``.
602
+
603
+ Mirrors ``_matched_exclude_label`` — shares the string/dict
604
+ tolerance and case-insensitive matching so operators can rely on
605
+ identical semantics between the exclude and include knobs. The
606
+ returned value is the label's original casing for logging. See
607
+ #80.
608
+ """
609
+ if not include_lowered:
610
+ return None
611
+ for label in issue.get("labels") or []:
612
+ if isinstance(label, dict):
613
+ name = label.get("name", "")
614
+ else:
615
+ name = str(label)
616
+ if name and name.lower() in include_lowered:
617
+ return name
618
+ return None
619
+
620
+ @staticmethod
621
+ def _matched_require_label(
622
+ issue: dict[str, Any], require_lowered: set[str]
623
+ ) -> str | None:
624
+ """Return the first issue label that matches ``require_lowered``.
625
+
626
+ Mirrors ``_matched_exclude_label`` / ``_matched_include_label``.
627
+ Used to gate the plain-assignment trigger when a repo has
628
+ ``require_labels`` configured. See #139.
629
+ """
630
+ if not require_lowered:
631
+ return None
632
+ for label in issue.get("labels") or []:
633
+ if isinstance(label, dict):
634
+ name = label.get("name", "")
635
+ else:
636
+ name = str(label)
637
+ if name and name.lower() in require_lowered:
638
+ return name
639
+ return None
640
+
641
+ @staticmethod
642
+ def _issue_is_assigned_to(issue: dict[str, Any], username: str) -> bool:
643
+ """Check whether ``username`` is listed in the issue's assignees.
644
+
645
+ The ``gh issue list`` payload returns assignees as
646
+ ``[{"login": "...", ...}]``. When we drop ``--assignee`` from the
647
+ server-side query (include_labels mode) we need this to detect
648
+ the pre-#80 assignment trigger client-side. Case-sensitive
649
+ compare — GitHub logins are themselves case-preserving but
650
+ case-insensitive for matching; the server-side filter
651
+ historically uses exact login compare, so we match that.
652
+ """
653
+ if not username:
654
+ return False
655
+ for a in issue.get("assignees") or []:
656
+ login = (a or {}).get("login") if isinstance(a, dict) else str(a)
657
+ if login == username:
658
+ return True
659
+ return False
660
+
661
+ def mark_seen(self, repo: str, issue_number: int) -> None:
662
+ """Mark an issue as seen without triggering a poll.
663
+
664
+ Useful for pre-seeding state from external sources (e.g. resuming
665
+ after a crash where work was already started).
666
+ """
667
+ self.seen_issues.setdefault(repo, set()).add(issue_number)
668
+ self._save_state()
669
+
670
+ def unmark_seen(self, repo: str, issue_number: int) -> None:
671
+ """Remove an issue from the seen-set so the next poll picks it up
672
+ again. Use this when a handler failed for a transient reason that
673
+ retrying would fix — the canonical case is a per-repo lock
674
+ conflict with a concurrent secops sweep. Without this, the
675
+ issue would be silently dropped forever because
676
+ ``poll()`` marks issues seen **before** handing them to the
677
+ handler, so a single handler failure is fatal by default.
678
+ Disk-save is best-effort; a failed save is logged but never
679
+ propagates."""
680
+ seen = self.seen_issues.get(repo)
681
+ if seen and issue_number in seen:
682
+ seen.discard(issue_number)
683
+ self._save_state_best_effort()
684
+
685
+ async def seed_current(self) -> None:
686
+ """Seed seen_issues with all currently-triggered issues.
687
+
688
+ Call this on first startup to avoid treating existing assignments
689
+ (or label matches) as new. Only issues assigned / labeled AFTER
690
+ this seed will trigger handlers.
691
+
692
+ Repos with ``include_labels`` configured seed BOTH the assignee
693
+ result and each label's targeted result (matching the poll()
694
+ fetch strategy — see poll() for why an unfiltered issue list
695
+ can silently cap at --limit on busy repos). Other repos keep
696
+ the cheap ``--assignee`` query.
697
+
698
+ Failure mode: if a per-repo lookup fails transiently, the seed skips
699
+ that repo and logs ``poll.repo.skipped``. The consequence is that on
700
+ next poll, any currently-assigned issues on the skipped repo will be
701
+ treated as new and picked up — that's safer than crashing first-run.
702
+ """
703
+ for repo in self.repos:
704
+ if repo in self._issues_disabled_repos:
705
+ continue
706
+ include_labels = self.include_labels_by_repo.get(repo, [])
707
+ try:
708
+ if include_labels:
709
+ by_number: dict[int, dict[str, Any]] = {}
710
+ assignee_issues = await self.github.list_assigned_issues(
711
+ repo, assignee=self.username,
712
+ )
713
+ for i in assignee_issues:
714
+ try:
715
+ by_number[int(i["number"])] = i
716
+ except (KeyError, TypeError, ValueError):
717
+ pass
718
+ for label in include_labels:
719
+ # Retry label query on transient failure during
720
+ # seed (codex P2 round-8). If we give up here
721
+ # and the query never succeeds, pre-existing
722
+ # labeled issues wouldn't be in seen_issues,
723
+ # and the first healthy poll would open
724
+ # pipelines for backlog. 3 × 2s handles typical
725
+ # network blips; a persistent failure still
726
+ # falls through to the per-label log + continue.
727
+ labeled = None
728
+ label_err: Exception | None = None
729
+ for attempt in range(_SEED_LABEL_RETRY_ATTEMPTS):
730
+ try:
731
+ labeled = await self.github.list_issues_by_label(
732
+ repo, label=label,
733
+ )
734
+ label_err = None
735
+ break
736
+ except asyncio.CancelledError:
737
+ raise
738
+ except Exception as label_e:
739
+ label_err = label_e
740
+ if attempt < _SEED_LABEL_RETRY_ATTEMPTS - 1:
741
+ await asyncio.sleep(
742
+ _SEED_LABEL_RETRY_SLEEP_SECONDS,
743
+ )
744
+ if labeled is None:
745
+ # All retries exhausted. Isolate the
746
+ # failure so the assignee backlog + any
747
+ # successful label queries still seed
748
+ # cleanly; continue to the next label.
749
+ log_event(
750
+ _logger,
751
+ "poll.seed.label_query_failed",
752
+ repo=repo,
753
+ label=label,
754
+ attempts=_SEED_LABEL_RETRY_ATTEMPTS,
755
+ reason=(
756
+ type(label_err).__name__
757
+ if label_err else "unknown"
758
+ ),
759
+ error=str(label_err)[:200] if label_err else "",
760
+ )
761
+ continue
762
+ for i in labeled:
763
+ try:
764
+ by_number.setdefault(int(i["number"]), i)
765
+ except (KeyError, TypeError, ValueError):
766
+ pass
767
+ issues = list(by_number.values())
768
+ else:
769
+ issues = await self.github.list_assigned_issues(
770
+ repo, assignee=self.username,
771
+ )
772
+ except asyncio.CancelledError:
773
+ raise
774
+ except _TRANSIENT_POLL_ERRORS as e:
775
+ if _is_issues_disabled_error(e):
776
+ self._mark_issues_disabled(repo)
777
+ continue
778
+ self._record_repo_failure(repo, e, phase="seed")
779
+ continue
780
+ self._clear_repo_failure(repo)
781
+ seen_for_repo = self.seen_issues.setdefault(repo, set())
782
+ include_lowered = {label.lower() for label in include_labels}
783
+ require_lowered = {
784
+ label.lower()
785
+ for label in self.require_labels_by_repo.get(repo, [])
786
+ }
787
+ for issue in issues:
788
+ try:
789
+ number = int(issue["number"])
790
+ except (KeyError, TypeError, ValueError):
791
+ # Malformed entries shouldn't abort the seed —
792
+ # poll() has its own per-issue guard and will log
793
+ # them on the next cycle.
794
+ continue
795
+ if include_lowered:
796
+ # #80 codex P1 round-2: in include_labels mode we
797
+ # only seed issues that are CURRENTLY a genuine
798
+ # trigger. Foreign-assigned issues without an
799
+ # opt-in label must NOT be seeded — a teammate
800
+ # may add the label later and the next poll needs
801
+ # to surface it. Label-matched and self-assigned
802
+ # are seeded so first-poll doesn't flood.
803
+ label_match = self._matched_include_label(
804
+ issue, include_lowered,
805
+ )
806
+ if label_match is not None:
807
+ seen_for_repo.add(number)
808
+ continue
809
+ try:
810
+ self_assigned = await self._is_self_assigned(
811
+ repo, number,
812
+ )
813
+ except asyncio.CancelledError:
814
+ raise
815
+ except Exception as e:
816
+ # Timeline API flaked during seed (codex P1
817
+ # round-3). We can't tell self vs foreign
818
+ # right now. Safe-default is to SEED the
819
+ # issue: otherwise the first poll treats
820
+ # pre-existing assigned backlog as new work
821
+ # and may spin up pipelines for it. Missing
822
+ # a post-startup label trigger for this
823
+ # single issue is the acceptable loss; the
824
+ # operator can re-apply the label after the
825
+ # API recovers.
826
+ log_event(
827
+ _logger,
828
+ "poll.seed.assignment_check_failed",
829
+ repo=repo,
830
+ issue_number=number,
831
+ reason=type(e).__name__,
832
+ error=str(e)[:200],
833
+ )
834
+ seen_for_repo.add(number)
835
+ continue
836
+ if self_assigned:
837
+ if require_lowered and self._matched_require_label(
838
+ issue, require_lowered,
839
+ ) is None:
840
+ # Assigned but missing the required label
841
+ # (#139): leave unseeded so a later
842
+ # label-add still surfaces it on the next
843
+ # poll.
844
+ continue
845
+ seen_for_repo.add(number)
846
+ else:
847
+ # Pre-#80 behavior: server-side --assignee already
848
+ # filtered, seed everything so first poll doesn't
849
+ # flood — unless require_labels (#139) is
850
+ # configured, in which case an issue missing the
851
+ # required label is left unseeded.
852
+ if require_lowered and self._matched_require_label(
853
+ issue, require_lowered,
854
+ ) is None:
855
+ continue
856
+ seen_for_repo.add(number)
857
+ self._save_state_best_effort()
858
+
859
+
860
+ async def run_poll_loop(
861
+ poller: IssuePoller,
862
+ handler: Callable[[str, dict[str, Any]], Awaitable[None]],
863
+ interval: int = 300,
864
+ max_iterations: int | None = None,
865
+ ) -> None:
866
+ """Run the polling loop.
867
+
868
+ Args:
869
+ poller: IssuePoller instance
870
+ handler: Async function to call for each new issue (repo, issue)
871
+ interval: Seconds between polls
872
+ max_iterations: Max iterations (None = infinite)
873
+
874
+ Iteration resilience: any non-cancellation exception from the poll or a
875
+ handler call is logged as ``poll.iteration.failed`` and the loop sleeps
876
+ and continues. This keeps a single bad cycle (slow network, one flaky
877
+ handler) from crashing the daemon and forcing a launchd restart.
878
+ """
879
+ iterations = 0
880
+ while max_iterations is None or iterations < max_iterations:
881
+ # Guard poll() separately from the handler dispatch: a malformed
882
+ # poll result shouldn't lose queued work, and a handler failure
883
+ # shouldn't skip the rest of the batch.
884
+ try:
885
+ new_issues = await poller.poll()
886
+ except asyncio.CancelledError:
887
+ raise
888
+ except Exception as e:
889
+ log_event(
890
+ _logger,
891
+ "poll.iteration.failed",
892
+ iteration=iterations,
893
+ phase="poll",
894
+ reason=type(e).__name__,
895
+ error=str(e)[:200],
896
+ )
897
+ new_issues = []
898
+
899
+ # Each handler invocation is isolated. A failure on one issue must
900
+ # not cancel the remaining already-seen-and-persisted issues — those
901
+ # would otherwise be silently dropped until daemon restart.
902
+ for item in new_issues:
903
+ try:
904
+ await handler(item["repo"], item["issue"])
905
+ except asyncio.CancelledError:
906
+ raise
907
+ except Exception as e:
908
+ issue = item.get("issue") or {}
909
+ log_event(
910
+ _logger,
911
+ "poll.handler.failed",
912
+ repo=item.get("repo"),
913
+ issue_number=issue.get("number"),
914
+ reason=type(e).__name__,
915
+ error=str(e)[:200],
916
+ )
917
+
918
+ iterations += 1
919
+ if max_iterations is None or iterations < max_iterations:
920
+ await asyncio.sleep(interval)