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
ctrlrelay/core/poller.py
ADDED
|
@@ -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)
|