@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/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
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
|
+
}
|