@edgehero/pi-dispatch-receiver 0.1.0

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.
package/src/poller.mjs ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * The polling producer (issue #81, second half): the same trigger gate and the same queue as the
3
+ * webhook receiver, fed by READING api.github.com instead of by being reachable from it. It makes the
4
+ * public webhook URL optional -- no DNS, no tunnel, no inbound port -- at the accepted cost of ~one
5
+ * cycle (default 60s) of latency.
6
+ *
7
+ * TRUST FRAMING, stated once: the webhook path's HMAC gate defends an INBOUND surface, where anyone
8
+ * who can reach the port can hand us a payload. Polling has no inbound surface to defend -- every
9
+ * byte processed here arrived over TLS from api.github.com on a connection this process opened with
10
+ * the operator's own credential. What the HMAC bought there, the outbound TLS handshake buys here.
11
+ *
12
+ * REUSE, NEVER RE-DERIVE -- the design rule this whole module is built around:
13
+ * - Each REST object is reshaped into the WEBHOOK PAYLOAD it corresponds to, then run through the
14
+ * receiver's own `parseSubset` and the UNCHANGED `filter()` with the same cfg/selfId. The gate --
15
+ * label allowlist, author_association, PR author gate, bot-loop guard -- has exactly one
16
+ * implementation, and the poller cannot drift from it because it never reimplements a byte of it.
17
+ * - A verdict's job is enqueued via the shared `enqueueGitHubJob`, replica fanout and all,
18
+ * mirroring receiver.mjs line for line.
19
+ * - The poller never interprets issue/comment/PR text itself: bodies pass through as DATA into the
20
+ * same subset fields the webhook path fills (CONST-ISSUE-TEXT-IS-DATA).
21
+ *
22
+ * WHAT IS POLLED, covering all three webhook trigger types:
23
+ * - label: GET /repos/{o}/{r}/issues/events -- `labeled` entries cover issues AND PRs (a PR
24
+ * is an issue here; `issue.pull_request` marks it, exactly the discriminator the
25
+ * webhook subset uses). PR label events fetch the PR object once so the synthesized
26
+ * payload carries the same author_association/labels/head/base a `pull_request
27
+ * labeled` delivery would.
28
+ * - comment: GET /repos/{o}/{r}/issues/comments?since=<cursor> -- PR conversation comments ARE
29
+ * issue comments, so one endpoint covers both, as one webhook event does. The
30
+ * comment object lacks the issue fields the subset needs (number/title/body/marker),
31
+ * so each NEW comment fetches its issue once.
32
+ * - pull_request: GET /repos/{o}/{r}/pulls?state=open, diffed against a per-PR head-sha snapshot:
33
+ * unknown number -> `opened`; known number, new sha -> `synchronize`; a number that
34
+ * left the open list and came back -> `reopened` (best-effort: the list shows
35
+ * presence, not transitions, so close->reopen inside one cycle is invisible, and a
36
+ * reopen that also pushed fires a single `reopened` where webhooks would fire two
37
+ * events -- the fresh sha still rides the job's target).
38
+ *
39
+ * DEDUP IDS (REQ-DEDUP-BY-DELIVERY-GUID): polling has no delivery GUID, so each source mints a
40
+ * deterministic stand-in that is stable across retried cycles -- `poll-e<eventId>` (label events),
41
+ * `poll-c<commentId>` (comments), `poll-pr<number>-<headSha7>` (PR actions; sha-keyed so a retried
42
+ * cycle cannot double-enqueue while a real new push mints a new id -- with the honest corollary that
43
+ * a same-sha reopen inside the retention window coalesces with its own `opened` job). The shared
44
+ * `gh-` prefix is added by enqueueGitHubJob's jobId path, same as for webhook deliveries.
45
+ *
46
+ * CURSORS AND ETAGS live in redis, namespaced per repo:
47
+ * poll:<owner/repo>:cursor:events last processed /issues/events id (numeric, monotonic)
48
+ * poll:<owner/repo>:cursor:comments newest processed comment updated_at (second-precision ISO)
49
+ * poll:<owner/repo>:cursor:prs ISO of when the PR snapshot was armed (presence = armed)
50
+ * poll:<owner/repo>:prs hash: PR number -> head sha while open, "closed" once gone
51
+ * poll:<owner/repo>:etag:<endpoint> conditional-GET validator (endpoint: events|comments|pulls)
52
+ * All keys carry a ~35-day TTL and are refreshed TOGETHER after each successful repo poll. 35 days
53
+ * deliberately exceeds the 31-day gh-* jobId retention (REQ-DEDUP-BY-DELIVERY-GUID): the cursor and
54
+ * the jobId are the poller's two dedup layers, and refreshing/expiring the cursor family as a unit
55
+ * means an actively polled repo always has a coherent live family, while a repo REMOVED from the set
56
+ * decays cleanly past both windows and re-arms from scratch if it ever returns. A partially expired
57
+ * family (say, the armed marker without the sha hash) would misread every open PR as newly opened.
58
+ *
59
+ * FIRST BOOT / NEW REPO: cursors are initialized to NOW / the current ids WITHOUT enqueuing anything.
60
+ * A fresh poller must not replay a repo's backlog -- a label applied months ago was a human approval
61
+ * for THAT moment's issue text and budget, not a standing order; the operator arms polling from now.
62
+ *
63
+ * WHICH REPOS: POLL_REPOS wins when set (see poller-config.mjs). With GITHUB_AUTH_SOURCE=app and no
64
+ * POLL_REPOS, the App installation's repository list is the source of truth, refreshed every
65
+ * DISCOVERY_EVERY cycles so an operator who installs the App on a new repo gets polling without a
66
+ * restart. Zero repos from either mechanism fails loud at boot.
67
+ *
68
+ * POLITENESS: per-endpoint-per-repo ETags make the idle steady state nearly free (304s do not count
69
+ * against the rate limit, per GitHub's docs); `x-poll-interval` is honored as the MINIMUM cycle
70
+ * delay when present; an exhausted rate limit (403/429 with x-ratelimit-remaining: 0) sleeps until
71
+ * x-ratelimit-reset plus jitter and says so.
72
+ *
73
+ * FAILURE ISOLATION: one repo's API error skips that repo for the cycle, logged, and never kills the
74
+ * loop -- one archived or deleted repo must not stall nine live ones. Config errors are tagged
75
+ * `piDispatchConfig` and exit 2 via the receiver bin's entryExitCode; graceful shutdown on
76
+ * SIGTERM/SIGINT finishes the in-flight repo, prints the cycle summary, and closes redis/queue.
77
+ *
78
+ * no-pii-in-logs, same as the receiver: stable identifiers only (repo full_name, delivery stand-in,
79
+ * flow, reasons, counts) -- never a title, a body, or a login.
80
+ */
81
+
82
+ import { createSign } from "node:crypto";
83
+ import { readFile as fsReadFile } from "node:fs/promises";
84
+ import { configError } from "@edgehero/pi-dispatch/config";
85
+ import { parseConnection } from "@edgehero/pi-dispatch/connection";
86
+ import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
87
+ import { enqueueGitHubJob, makeQueue } from "@edgehero/pi-dispatch/queue";
88
+ import { filter } from "./filter.mjs";
89
+ import { parseSubset } from "./receiver.mjs";
90
+ import { loadPollerConfig } from "./poller-config.mjs";
91
+
92
+ const API_URL = "https://api.github.com";
93
+ // 35 days: strictly outlives the 31-day gh-* job retention -- see the header's cursor/TTL section.
94
+ const CURSOR_TTL_SECONDS = 35 * 24 * 3600;
95
+ // Installation repo-list refresh cadence, in cycles (~10 minutes at the default interval): fresh
96
+ // enough that a newly installed repo starts polling soon, cheap enough to be invisible in the quota.
97
+ const DISCOVERY_EVERY = 10;
98
+ // Page bounds. The events endpoint has no `since`, so a backlog deeper than this is skipped with an
99
+ // explicit log line rather than stalling the cycle; comments self-heal (the cursor advances and the
100
+ // next cycle picks up the rest); the open-PR list just caps how many open PRs the diff can see.
101
+ const MAX_EVENT_PAGES = 5;
102
+ const MAX_PR_PAGES = 10;
103
+ // The hash value marking a PR that left the open list. Cannot collide with a head sha (hex only).
104
+ const CLOSED_MARKER = "closed";
105
+
106
+ /** Thrown by the API helper when the credential's quota is exhausted; carries the reset time in ms. */
107
+ class RateLimited extends Error {
108
+ constructor(resetMs) {
109
+ super("github rate limit exhausted");
110
+ this.resetMs = resetMs;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Boot the poller: config, HARD-FAIL identity, repo set, then the cycle loop. Collaborators are
116
+ * injected with real defaults (start.mjs's convention), so the whole producer is testable offline
117
+ * with no GitHub, no Valkey, and no timers:
118
+ * { fetchFn, redis, queueFn, out, now, random, sleep, selfIdFn, tokenFn, fsDeps }
119
+ * Returns `{ stop, done }`: `stop()` ends the loop after the in-flight work; `done` resolves once
120
+ * owned connections are closed.
121
+ */
122
+ export async function startPoller(env = process.env, deps = {}) {
123
+ const {
124
+ fetchFn = globalThis.fetch,
125
+ out = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`),
126
+ now = Date.now,
127
+ random = Math.random,
128
+ sleep,
129
+ redis,
130
+ queueFn,
131
+ selfIdFn,
132
+ tokenFn,
133
+ readFile = fsReadFile,
134
+ fsDeps = {},
135
+ makeAuth = makeGitHubAuth,
136
+ makeQueueFn = makeQueue,
137
+ } = deps;
138
+
139
+ const cfg = loadPollerConfig(env, fsDeps);
140
+
141
+ // HARD-FAIL identity resolution, exactly start.mjs's boot gate and for the same reason: selfId is
142
+ // the bot-loop guard's sole input, and a poller running without it would read the harness's own
143
+ // completion comment next cycle and enqueue it -- an unbounded paid recursion, only slower than the
144
+ // webhook version. No try/catch: an unresolvable identity must prevent the loop from ever starting.
145
+ let auth = null;
146
+ const getAuth = async () => (auth ??= await makeAuth(cfg.github));
147
+ const selfId = selfIdFn ? await selfIdFn(cfg.github) : (await getAuth()).selfId;
148
+ out({ event: "self_identity", id: selfId, source: cfg.github.source });
149
+
150
+ // The polling credential comes from the same auth config the worker validates. pat/gh hand back
151
+ // the operator's own token via the shared mintToken; the app source mints an installation token
152
+ // itself (below) because the worker's mint is per-repo-scoped BY DESIGN (CONST-TOKEN-SCOPED-PER-JOB
153
+ // bounds a token that enters a sandbox) while this one never leaves this process and must both read
154
+ // every polled repo and list the installation -- a scope no single-repo token can carry.
155
+ const mintToken = tokenFn ?? (cfg.github.source === "app"
156
+ ? makeAppInstallationTokenFn(cfg.github, { fetchFn, readFile, now })
157
+ : async () => (await getAuth()).mintToken());
158
+
159
+ // Cursor store. ioredis is imported lazily so tests injecting a fake never load the driver. The
160
+ // client rides out disconnects (ioredis reconnects on its own) -- same posture as the receiver's
161
+ // queue connection: a long-running producer should survive a Valkey restart.
162
+ let redisClient = redis ?? null;
163
+ let ownRedis = false;
164
+ if (redisClient === null) {
165
+ const { default: Redis } = await import("ioredis");
166
+ redisClient = new Redis(cfg.valkeyUrl);
167
+ ownRedis = true;
168
+ }
169
+
170
+ // The enqueue seam. The default is the shared enqueueGitHubJob over a real queue -- never a local
171
+ // re-spelling of queue.add (DES-TRIGGER-OUTSIDE-PI's producer rule: dedup/retention has one owner).
172
+ let queue = null;
173
+ let enqueue = queueFn ?? null;
174
+ if (enqueue === null) {
175
+ queue = makeQueueFn(parseConnection(cfg.valkeyUrl));
176
+ enqueue = (job) => enqueueGitHubJob(queue, job);
177
+ }
178
+
179
+ const closeOwned = async () => {
180
+ if (queue) await queue.close();
181
+ if (ownRedis) await redisClient.quit();
182
+ };
183
+
184
+ const ctx = { cfg, selfId, fetchFn, redis: redisClient, enqueue, out, now, random };
185
+
186
+ // The repo set: explicit POLL_REPOS, or the App installation's list (cfg.repos === null only when
187
+ // the source is app -- poller-config enforces it). An empty discovery is a config error, not an
188
+ // idle loop: a poller watching nothing must refuse to start, not report healthy cycles forever.
189
+ let repos = cfg.repos;
190
+ if (repos === null) {
191
+ try {
192
+ repos = await discoverRepos(ctx, makeApi(ctx, await mintToken(), emptyStats()));
193
+ } catch (err) {
194
+ await closeOwned();
195
+ throw err;
196
+ }
197
+ if (repos.length === 0) {
198
+ await closeOwned();
199
+ throw configError(
200
+ "the App installation grants no repositories -- install the GitHub App on the repos to poll, or set POLL_REPOS=owner/name[,owner/name...] explicitly",
201
+ );
202
+ }
203
+ out({ event: "poll_repos_discovered", repos: repos.length });
204
+ }
205
+
206
+ out({ event: "poller_started", repos: repos.length, intervalSeconds: cfg.intervalSeconds, valkey: cfg.valkeyUrl, discovery: cfg.repos === null });
207
+
208
+ let stopped = false;
209
+ let wake;
210
+ const stopWaker = new Promise((resolve) => {
211
+ wake = resolve;
212
+ });
213
+ const stop = () => {
214
+ stopped = true;
215
+ wake();
216
+ };
217
+ const sleepFn = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
218
+
219
+ const done = (async () => {
220
+ let cycleNo = 0;
221
+ while (!stopped) {
222
+ const stats = emptyStats();
223
+ const startedAt = now();
224
+ let rateResetMs = null;
225
+
226
+ // A token-mint failure (network blip, gh relogin) skips the cycle with a log line rather than
227
+ // killing the loop: genuinely bad auth config already hard-failed at the identity boot gate.
228
+ let token = null;
229
+ try {
230
+ token = await mintToken();
231
+ } catch (err) {
232
+ out({ event: "poll_token_failed", reason: err?.message });
233
+ }
234
+
235
+ if (token !== null) {
236
+ const api = makeApi(ctx, token, stats);
237
+
238
+ // Low-frequency installation-list refresh. A refresh failure KEEPS the current list -- a
239
+ // discovery outage must not stop polling the repos already armed.
240
+ if (cfg.repos === null && cycleNo > 0 && cycleNo % DISCOVERY_EVERY === 0) {
241
+ try {
242
+ const found = await discoverRepos(ctx, api);
243
+ if (found.length > 0) repos = found;
244
+ else out({ event: "poll_discovery_empty", kept: repos.length });
245
+ } catch (err) {
246
+ if (err instanceof RateLimited) rateResetMs = err.resetMs;
247
+ else out({ event: "poll_discovery_failed", reason: err?.message });
248
+ }
249
+ }
250
+
251
+ if (rateResetMs === null) {
252
+ for (const repo of repos) {
253
+ if (stopped) break; // graceful: finish the in-flight repo, not the whole roster
254
+ try {
255
+ await pollRepo(ctx, api, repo, stats);
256
+ } catch (err) {
257
+ if (err instanceof RateLimited) {
258
+ // The quota is credential-global, so one repo hitting it means they all would:
259
+ // abort the roster and sleep out the reset instead of burning N more refusals.
260
+ rateResetMs = err.resetMs;
261
+ out({ event: "poll_rate_limited", resetAt: new Date(err.resetMs).toISOString() });
262
+ break;
263
+ }
264
+ // One repo's failure is one repo's failure: log and keep going -- an archived or
265
+ // deleted repo must not stall the live ones. Its cursors simply do not advance, so
266
+ // nothing is lost if it comes back.
267
+ out({ event: "poll_repo_failed", repo, reason: err?.message });
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ cycleNo++;
274
+ out({ event: "poll_cycle", repos: repos.length, fetched: stats.fetched, enqueued: stats.enqueued, notModified: stats.notModified, ms: now() - startedAt });
275
+ if (stopped) break;
276
+
277
+ // The delay: the configured interval, raised by any x-poll-interval hint seen this cycle, and
278
+ // raised again past the rate-limit reset (plus jitter, so a fleet of pollers does not return
279
+ // in one synchronized stampede).
280
+ let delayMs = Math.max(cfg.intervalSeconds, stats.minDelaySeconds) * 1000;
281
+ if (rateResetMs !== null) delayMs = Math.max(delayMs, rateResetMs - now() + jitterMs(random));
282
+ await Promise.race([sleepFn(delayMs), stopWaker]);
283
+ }
284
+ await closeOwned();
285
+ })();
286
+
287
+ // Signal handlers only on a real run (no injected sleep) -- start.mjs's rule, same reason: under
288
+ // test injection the fakes are per-test, and a process-wide handler would leak across tests.
289
+ if (sleep === undefined) {
290
+ const shutdown = async (signal) => {
291
+ out({ event: "poller_stopping", signal });
292
+ stop();
293
+ await done;
294
+ process.exit(0);
295
+ };
296
+ process.once("SIGTERM", () => void shutdown("SIGTERM"));
297
+ process.once("SIGINT", () => void shutdown("SIGINT"));
298
+ }
299
+
300
+ return { stop, done };
301
+ }
302
+
303
+ function emptyStats() {
304
+ return { fetched: 0, enqueued: 0, notModified: 0, minDelaySeconds: 0 };
305
+ }
306
+
307
+ /** 1-30s of jitter for the post-rate-limit wake, spread by the injected `random`. */
308
+ function jitterMs(random) {
309
+ return 1_000 + Math.floor(random() * 29_000);
310
+ }
311
+
312
+ /** The redis key family for one repo -- see the schema table in the module header. */
313
+ function keyNames(repo) {
314
+ const p = `poll:${repo}`;
315
+ return {
316
+ events: `${p}:cursor:events`,
317
+ comments: `${p}:cursor:comments`,
318
+ prsArmed: `${p}:cursor:prs`,
319
+ prs: `${p}:prs`,
320
+ etagEvents: `${p}:etag:events`,
321
+ etagComments: `${p}:etag:comments`,
322
+ etagPulls: `${p}:etag:pulls`,
323
+ };
324
+ }
325
+
326
+ async function setWithTtl(ctx, key, value) {
327
+ await ctx.redis.set(key, value, "EX", CURSOR_TTL_SECONDS);
328
+ }
329
+
330
+ /** Refresh the whole key family together -- coherence over per-key precision (see module header). */
331
+ async function touchRepo(ctx, repo) {
332
+ const k = keyNames(repo);
333
+ for (const key of [k.events, k.comments, k.prsArmed, k.prs, k.etagEvents, k.etagComments, k.etagPulls]) {
334
+ await ctx.redis.expire(key, CURSOR_TTL_SECONDS);
335
+ }
336
+ }
337
+
338
+ /** Second-precision ISO (GitHub's own timestamp shape), so cursor comparisons never mix precisions. */
339
+ function isoSeconds(ms) {
340
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z");
341
+ }
342
+
343
+ /**
344
+ * A per-cycle GitHub API view: one token, one stats object, ETag discipline in one place.
345
+ *
346
+ * `get(path, etagKey, revalidate)`: when `etagKey` is set the stored validator is sent as
347
+ * If-None-Match and the fresh one stored on 200; a 304 is counted and returned as `{ notModified }`.
348
+ * `revalidate: false` (used while ARMING, i.e. no cursor yet) still STORES the response's ETag but
349
+ * never sends one -- arming needs the body, and a stray stored validator answering 304 on an unarmed
350
+ * endpoint would leave it unarmed forever.
351
+ *
352
+ * An exhausted quota (403/429 with x-ratelimit-remaining: 0) throws RateLimited for the cycle loop
353
+ * to sleep out; any other non-2xx throws a plain error for per-repo isolation to log.
354
+ */
355
+ function makeApi(ctx, token, stats) {
356
+ return {
357
+ async get(path, etagKey = null, revalidate = true) {
358
+ const headers = {
359
+ accept: "application/vnd.github+json",
360
+ "user-agent": "pi-dispatch-poller",
361
+ "x-github-api-version": "2022-11-28",
362
+ authorization: `Bearer ${token}`,
363
+ };
364
+ if (etagKey && revalidate) {
365
+ const etag = await ctx.redis.get(etagKey);
366
+ if (etag) headers["if-none-match"] = etag;
367
+ }
368
+ const res = await ctx.fetchFn(`${API_URL}${path}`, { headers });
369
+
370
+ const pollHint = Number(res.headers?.get?.("x-poll-interval"));
371
+ if (Number.isFinite(pollHint) && pollHint > stats.minDelaySeconds) stats.minDelaySeconds = pollHint;
372
+
373
+ if (res.status === 304) {
374
+ stats.notModified += 1;
375
+ return { notModified: true };
376
+ }
377
+ if ((res.status === 403 || res.status === 429) && res.headers?.get?.("x-ratelimit-remaining") === "0") {
378
+ const reset = Number(res.headers.get("x-ratelimit-reset"));
379
+ throw new RateLimited(Number.isFinite(reset) && reset > 0 ? reset * 1000 : ctx.now() + 60_000);
380
+ }
381
+ if (!res.ok) {
382
+ throw new Error(`GET ${path} -> HTTP ${res.status}`);
383
+ }
384
+ if (etagKey) {
385
+ const tag = res.headers?.get?.("etag");
386
+ if (tag) await ctx.redis.set(etagKey, tag, "EX", CURSOR_TTL_SECONDS);
387
+ }
388
+ stats.fetched += 1;
389
+ return { json: await res.json() };
390
+ },
391
+ };
392
+ }
393
+
394
+ /**
395
+ * List the App installation's repositories (paginated). Archived repos are not filtered here: an
396
+ * archived repo simply produces nothing, and per-repo error isolation covers any oddity -- fewer
397
+ * special cases beats a slightly shorter roster.
398
+ */
399
+ async function discoverRepos(ctx, api) {
400
+ const names = [];
401
+ for (let page = 1; page <= 10; page++) {
402
+ const r = await api.get(`/installation/repositories?per_page=100&page=${page}`);
403
+ const batch = Array.isArray(r.json?.repositories) ? r.json.repositories : [];
404
+ for (const item of batch) {
405
+ if (typeof item?.full_name === "string" && item.full_name !== "") names.push(item.full_name);
406
+ }
407
+ if (batch.length < 100) break;
408
+ }
409
+ return names;
410
+ }
411
+
412
+ /** One repo, one cycle: the three endpoints, then a TTL touch so the key family ages as a unit. */
413
+ async function pollRepo(ctx, api, repo, stats) {
414
+ await pollLabelEvents(ctx, api, repo, stats);
415
+ await pollComments(ctx, api, repo, stats);
416
+ await pollPulls(ctx, api, repo, stats);
417
+ await touchRepo(ctx, repo);
418
+ }
419
+
420
+ /**
421
+ * The label feed: /issues/events, cursor = last processed event id (numeric and monotonic, which is
422
+ * what makes per-event cursor advancement safe here -- each processed event's own id IS the resume
423
+ * point, so a mid-list failure retries exactly the unprocessed tail and nothing twice).
424
+ */
425
+ async function pollLabelEvents(ctx, api, repo, stats) {
426
+ const k = keyNames(repo);
427
+ const cursorRaw = await ctx.redis.get(k.events);
428
+ const armed = cursorRaw !== null;
429
+
430
+ const first = await api.get(`/repos/${repo}/issues/events?per_page=100`, k.etagEvents, armed);
431
+ if (first.notModified) return;
432
+ const pageOne = Array.isArray(first.json) ? first.json : [];
433
+
434
+ if (!armed) {
435
+ // ARM WITHOUT REPLAY: record the newest id and enqueue nothing -- the backlog's labels were
436
+ // approvals for a different moment (module header). An empty repo arms at 0.
437
+ const maxId = pageOne.reduce((m, e) => (typeof e?.id === "number" && e.id > m ? e.id : m), 0);
438
+ await setWithTtl(ctx, k.events, String(maxId));
439
+ ctx.out({ event: "poll_armed", repo, endpoint: "events", cursor: maxId });
440
+ return;
441
+ }
442
+
443
+ const cursor = Number(cursorRaw);
444
+ // The endpoint is newest-first with no `since`, so page deeper only while the OLDEST fetched entry
445
+ // is still newer than the cursor. Beyond the page bound the remainder is skipped -- said out loud,
446
+ // because a silent gap here would be labels that never fire.
447
+ let events = pageOne;
448
+ let page = 2;
449
+ while (events.length > 0 && lastId(events) > cursor && page <= MAX_EVENT_PAGES) {
450
+ const more = await api.get(`/repos/${repo}/issues/events?per_page=100&page=${page}`);
451
+ const batch = Array.isArray(more.json) ? more.json : [];
452
+ if (batch.length === 0) break;
453
+ events = events.concat(batch);
454
+ page++;
455
+ }
456
+ if (events.length > 0 && lastId(events) > cursor && page > MAX_EVENT_PAGES) {
457
+ ctx.out({ event: "poll_events_gap", repo, cursor });
458
+ }
459
+
460
+ const fresh = events
461
+ .filter((e) => typeof e?.id === "number" && e.id > cursor)
462
+ .sort((a, b) => a.id - b.id); // oldest first: process in the order the webhooks would have fired
463
+ for (const ev of fresh) {
464
+ if (ev.event === "labeled" && ev.issue) {
465
+ await handleLabeledEvent(ctx, api, repo, ev, stats);
466
+ }
467
+ // Advance ONLY after the event is handled: an enqueue/fetch failure above leaves the cursor on
468
+ // the last success, so the retry next cycle resumes at the exact failed event.
469
+ await setWithTtl(ctx, k.events, String(ev.id));
470
+ }
471
+ }
472
+
473
+ function lastId(events) {
474
+ const id = events[events.length - 1]?.id;
475
+ return typeof id === "number" ? id : Number.MAX_SAFE_INTEGER; // malformed tail: stop paging, keep what we have
476
+ }
477
+
478
+ /**
479
+ * One `labeled` event -> the webhook payload it corresponds to -> the unchanged gate.
480
+ *
481
+ * The issue-vs-PR split mirrors the webhook exactly: the events feed hands us the ISSUE view, whose
482
+ * `pull_request` field is the same presence marker parseSubset reads on an issue_comment. An issue
483
+ * routes as `issues labeled` with the issue object as-is. A PR must route as `pull_request labeled`,
484
+ * and the issue view lacks what that path gates on (author_association, the PR's own labels,
485
+ * head/base), so the PR object is fetched once per event -- the price of field-for-field parity with
486
+ * the webhook subset, paid only on NEW labeled events.
487
+ */
488
+ async function handleLabeledEvent(ctx, api, repo, ev, stats) {
489
+ const deliveryId = `poll-e${ev.id}`;
490
+ if (ev.issue.pull_request != null) {
491
+ const pr = (await api.get(`/repos/${repo}/pulls/${ev.issue.number}`)).json;
492
+ const payload = { action: "labeled", sender: { id: ev.actor?.id }, pull_request: pr, repository: { full_name: repo } };
493
+ await gate(ctx, "pull_request", payload, deliveryId, stats);
494
+ } else {
495
+ const payload = { action: "labeled", sender: { id: ev.actor?.id }, issue: ev.issue, repository: { full_name: repo } };
496
+ await gate(ctx, "issues", payload, deliveryId, stats);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * The comment feed: /issues/comments?since=<cursor>, cursor = newest processed updated_at.
502
+ *
503
+ * `since` selects by UPDATE time, but the webhook path only ever consumes `issue_comment created` --
504
+ * so a returned comment counts as NEW exactly when its created_at is later than the cycle-start
505
+ * cursor; an edit of an older comment is skipped, as the webhook filter would have skipped the
506
+ * `edited` action. The strict compare leaves a sub-second race (a comment created in the same second
507
+ * as the cursor, after the fetch) which the NEXT cycle's inclusive `since` cannot re-distinguish; the
508
+ * alternative (>=) would re-enqueue the boundary comment every idle cycle forever, leaning on jobId
509
+ * dedup to absorb the noise. One-second blindness loses to permanent noise.
510
+ *
511
+ * The cursor is persisted once, AFTER the list: comment timestamps are not identities, so a per-item
512
+ * cursor could strand a same-second neighbor on a crash. A mid-list failure therefore retries the
513
+ * whole batch next cycle, and `gh-poll-c<id>` jobId dedup absorbs the overlap.
514
+ */
515
+ async function pollComments(ctx, api, repo, stats) {
516
+ const k = keyNames(repo);
517
+ const cursor = await ctx.redis.get(k.comments);
518
+ if (cursor === null) {
519
+ // ARM WITHOUT REPLAY, and without even a fetch: "everything before now is history" needs no body.
520
+ await setWithTtl(ctx, k.comments, isoSeconds(ctx.now()));
521
+ ctx.out({ event: "poll_armed", repo, endpoint: "comments" });
522
+ return;
523
+ }
524
+
525
+ const r = await api.get(
526
+ `/repos/${repo}/issues/comments?sort=updated&direction=asc&since=${encodeURIComponent(cursor)}&per_page=100`,
527
+ k.etagComments,
528
+ );
529
+ if (r.notModified) return;
530
+ const comments = Array.isArray(r.json) ? r.json : [];
531
+
532
+ let advanced = cursor;
533
+ for (const c of comments) {
534
+ if (typeof c?.id === "number" && typeof c?.created_at === "string" && c.created_at > cursor) {
535
+ await handleComment(ctx, api, repo, c, stats);
536
+ }
537
+ if (typeof c?.updated_at === "string" && c.updated_at > advanced) advanced = c.updated_at;
538
+ }
539
+ if (advanced !== cursor) await setWithTtl(ctx, k.comments, advanced);
540
+ // A backlog deeper than one page self-heals: the cursor advanced to this page's newest update, so
541
+ // the next cycle's `since` picks up the remainder -- no pagination loop to bound here.
542
+ }
543
+
544
+ /**
545
+ * One new comment -> the `issue_comment created` payload -> the unchanged gate. The comment object
546
+ * names its issue only by URL, so the issue is fetched once per NEW comment (rare and cheap) to fill
547
+ * the subset's issue fields -- number, title, body, and the pull_request marker that makes a PR
548
+ * conversation comment route as a pull_request target, exactly as the webhook payload's issue does.
549
+ */
550
+ async function handleComment(ctx, api, repo, c, stats) {
551
+ const number = issueNumberOf(c.issue_url);
552
+ if (number === null) return;
553
+ const issue = (await api.get(`/repos/${repo}/issues/${number}`)).json;
554
+ const payload = {
555
+ action: "created",
556
+ sender: { id: c.user?.id },
557
+ issue,
558
+ comment: { body: c.body, author_association: c.author_association },
559
+ repository: { full_name: repo },
560
+ };
561
+ await gate(ctx, "issue_comment", payload, `poll-c${c.id}`, stats);
562
+ }
563
+
564
+ /** `.../repos/o/r/issues/17` -> 17, or null when the URL does not end in an issue number. */
565
+ function issueNumberOf(issueUrl) {
566
+ const m = /\/issues\/(\d+)$/.exec(String(issueUrl ?? ""));
567
+ return m ? Number(m[1]) : null;
568
+ }
569
+
570
+ /**
571
+ * The PR feed: the full open list, diffed against the per-PR head-sha snapshot (see module header
572
+ * for the opened/synchronize/reopened derivation and its stated approximations). One more is worth
573
+ * naming here: the list has no "who did this", so `sender` is the PR AUTHOR -- exact for `opened`,
574
+ * an approximation for `synchronize`/`reopened` where the webhook names the pusher/reopener. The
575
+ * bot-loop guard consequence is the safe direction for the common case: the harness's own PRs carry
576
+ * its own user id and stay self-filtered on every action.
577
+ */
578
+ async function pollPulls(ctx, api, repo, stats) {
579
+ const k = keyNames(repo);
580
+ const armed = (await ctx.redis.get(k.prsArmed)) !== null;
581
+
582
+ const first = await api.get(`/repos/${repo}/pulls?state=open&sort=updated&direction=asc&per_page=100`, k.etagPulls, armed);
583
+ if (first.notModified) return;
584
+ let open = Array.isArray(first.json) ? first.json : [];
585
+ let batch = open;
586
+ let page = 2;
587
+ while (batch.length === 100 && page <= MAX_PR_PAGES) {
588
+ const more = await api.get(`/repos/${repo}/pulls?state=open&sort=updated&direction=asc&per_page=100&page=${page}`);
589
+ batch = Array.isArray(more.json) ? more.json : [];
590
+ open = open.concat(batch);
591
+ page++;
592
+ }
593
+
594
+ if (!armed) {
595
+ // ARM WITHOUT REPLAY: snapshot the open set so the next cycle diffs against reality. The hash is
596
+ // written before the armed marker, so a crash between the two re-arms cleanly (marker-without-
597
+ // hash is the dangerous order -- it would read every open PR as newly opened).
598
+ for (const pr of open) {
599
+ if (pr?.number != null) await ctx.redis.hset(k.prs, String(pr.number), pr.head?.sha ?? "");
600
+ }
601
+ await ctx.redis.expire(k.prs, CURSOR_TTL_SECONDS);
602
+ await setWithTtl(ctx, k.prsArmed, isoSeconds(ctx.now()));
603
+ ctx.out({ event: "poll_armed", repo, endpoint: "pulls", open: open.length });
604
+ return;
605
+ }
606
+
607
+ const known = await ctx.redis.hgetall(k.prs);
608
+ const seen = new Set();
609
+ for (const pr of open) {
610
+ if (pr?.number == null) continue;
611
+ const field = String(pr.number);
612
+ seen.add(field);
613
+ const sha = pr.head?.sha ?? "";
614
+ const prev = known[field];
615
+ let action = null;
616
+ if (prev === undefined) action = "opened";
617
+ else if (prev === CLOSED_MARKER) action = "reopened";
618
+ else if (prev !== sha) action = "synchronize";
619
+ if (action === null) continue;
620
+
621
+ const payload = { action, sender: { id: pr.user?.id }, pull_request: pr, repository: { full_name: repo } };
622
+ await gate(ctx, "pull_request", payload, `poll-pr${pr.number}-${sha.slice(0, 7)}`, stats);
623
+ // Snapshot after the gate, so a failed enqueue retries this PR next cycle at the same sha.
624
+ await ctx.redis.hset(k.prs, field, sha);
625
+ }
626
+ // A known PR absent from the full open list left it (closed one way or another); mark it so a
627
+ // later reappearance reads as `reopened` rather than as brand new.
628
+ for (const field of Object.keys(known)) {
629
+ if (!seen.has(field) && known[field] !== CLOSED_MARKER) {
630
+ await ctx.redis.hset(k.prs, field, CLOSED_MARKER);
631
+ }
632
+ }
633
+ }
634
+
635
+ /**
636
+ * The single choke point every synthesized payload passes through, and deliberately the receiver's
637
+ * exact pipeline: parseSubset -> filter (UNCHANGED, same cfg/selfId) -> replica fanout ->
638
+ * enqueueGitHubJob, with the receiver's own log shapes. Partial replica failure is idempotent for the
639
+ * same reason it is there: the failed cycle re-runs, replicas 1..k-1 dedup on their taken jobIds.
640
+ */
641
+ async function gate(ctx, eventName, payload, deliveryId, stats) {
642
+ const result = filter(eventName, parseSubset(payload), ctx.cfg, ctx.selfId, deliveryId);
643
+ if (!result.enqueue) {
644
+ ctx.out({ event: "dropped", delivery: deliveryId, reason: result.reason });
645
+ return;
646
+ }
647
+ const replicas = result.job.replicas ?? 1;
648
+ for (let i = 1; i <= replicas; i++) {
649
+ await ctx.enqueue(replicas > 1 ? { ...result.job, replica: i } : result.job);
650
+ }
651
+ stats.enqueued += replicas;
652
+ ctx.out({ event: "enqueued", delivery: deliveryId, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas });
653
+ }
654
+
655
+ /**
656
+ * The default app-source polling credential: a self-minted, UNSCOPED installation token, cached until
657
+ * ~5 minutes before its 1-hour expiry.
658
+ *
659
+ * Two deliberate departures from the worker's mint, both explained by where the token goes. The
660
+ * worker's `mintToken` scopes to ONE repo because its token enters a sandbox with adversarial text
661
+ * (CONST-TOKEN-SCOPED-PER-JOB bounds that blast radius); this token never leaves this process, and it
662
+ * must read every polled repo AND call GET /installation/repositories -- a scope no single-repo token
663
+ * has. And the mint is a hand-rolled RS256 App JWT + one POST rather than @octokit/auth-app, because
664
+ * the receiver package does not depend on that library and a hoisted import it never declared would
665
+ * break a standalone install; the mint is small enough to own (node:crypto signs, fetch posts).
666
+ */
667
+ function makeAppInstallationTokenFn(github, { fetchFn, readFile, now }) {
668
+ let cached = null; // { token, expiresAtMs }
669
+ return async () => {
670
+ if (cached !== null && cached.expiresAtMs - now() > 5 * 60_000) return cached.token;
671
+ const pem = await readFile(github.privateKeyPath, "utf8");
672
+ const jwt = appJwt(github.appId, pem, now());
673
+ const res = await fetchFn(`${API_URL}/app/installations/${github.installationId}/access_tokens`, {
674
+ method: "POST",
675
+ headers: {
676
+ accept: "application/vnd.github+json",
677
+ "user-agent": "pi-dispatch-poller",
678
+ "x-github-api-version": "2022-11-28",
679
+ authorization: `Bearer ${jwt}`,
680
+ },
681
+ });
682
+ if (!res.ok) throw new Error(`installation token mint -> HTTP ${res.status}`);
683
+ const body = await res.json();
684
+ if (typeof body?.token !== "string" || body.token === "") {
685
+ // The money-hole invariant, same as get-token.mjs: an empty token must never flow onward.
686
+ throw new Error("installation token mint returned no token");
687
+ }
688
+ cached = { token: body.token, expiresAtMs: Date.parse(body.expires_at ?? "") || now() + 55 * 60_000 };
689
+ return cached.token;
690
+ };
691
+ }
692
+
693
+ /** GitHub App JWT (RS256): iat backdated 60s for clock skew, 9-minute expiry (below the 10-min cap). */
694
+ function appJwt(appId, privateKeyPem, nowMs) {
695
+ const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
696
+ const iat = Math.floor(nowMs / 1000) - 60;
697
+ const unsigned = `${b64({ alg: "RS256", typ: "JWT" })}.${b64({ iat, exp: iat + 600, iss: String(appId) })}`;
698
+ const signature = createSign("RSA-SHA256").update(unsigned).sign(privateKeyPem).toString("base64url");
699
+ return `${unsigned}.${signature}`;
700
+ }