@aivi/tracker-linear 0.0.0-stage → 0.4.1

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/dist/setup.js ADDED
@@ -0,0 +1,526 @@
1
+ /**
2
+ * The setup `aivi add linear` runs: everything Linear-specific lives in
3
+ * this file. It starts only when a live aivi answers its health endpoint —
4
+ * the test observes what that host's request diary records, so no host, no
5
+ * install. It asks for the app's credentials and proves them at Linear,
6
+ * installs the app through a browser round, then — before writing anything —
7
+ * tests the two systems the module lives on in two waits that run one
8
+ * after the other: that Linear posts deliveries to this URL, and that
9
+ * delegating a throwaway ticket creates an agent session whose created
10
+ * event arrives the same way. The flow draws its own lines with the clack
11
+ * the runner's prompts render with — one live line per wait, settled by
12
+ * clack's own verdict marks: a hollow green diamond for passed, a red
13
+ * square for failed. It ends with its own last line. The ticket is created
14
+ * and archived by the installer; a passed test is nobody's waiting room.
15
+ * Nobody opens an agent session by hand; we only watch what Linear does.
16
+ * Only after the checks have a verdict is anything written. Performing a
17
+ * restart never belongs to this flow — the CLI owns that, and reports it
18
+ * as it happens.
19
+ */
20
+ import { createServer } from 'node:http';
21
+ import { configSchema, errorMessage, hostUrl, PROJECT_ID } from '@aivi/core';
22
+ import { settled } from '@aivi/plugin';
23
+ import { LinearClient } from './client.js';
24
+ import { linearPrimarySecretNames, MODULE_ID } from './config.js';
25
+ import { appWebhookPath } from './routes.js';
26
+ import { isAgentSessionEvent, isIssueEvent, verifyWebhook } from './webhook.js';
27
+ /** How long the diary is watched when a check cannot pass: silence is a
28
+ * result too, and only a waited-out silence proves it. A test where both
29
+ * systems show themselves ends at once and waits for nothing. Tests
30
+ * shorten the window; people get the honest cap. */
31
+ const DEFAULT_WINDOW_MS = 90_000;
32
+ const probeWindowMs = () => Number(process.env.AIVI_PROBE_WINDOW_MS ?? DEFAULT_WINDOW_MS);
33
+ /** What the install asks Linear to let the app do; `app:assignable` is
34
+ * what lets it be a delegate. See `authorizeUrl` for the link itself. */
35
+ const INSTALL_SCOPES = 'read,write,app:assignable,app:mentionable';
36
+ /**
37
+ * The one-shot loopback listener: bind, print, wait, close. Anything that
38
+ * is not our callback — the browser's favicon.ico probe among them — is a
39
+ * 404 that never touches the code. It binds an ephemeral port (0) before
40
+ * the instructions print, so the human registers exactly this callback on
41
+ * the app and the install link is accepted first try.
42
+ */
43
+ const catchInstall = () => {
44
+ let ok = () => { };
45
+ let fail = () => { };
46
+ let bindFailed = () => { };
47
+ const code = new Promise((resolvePromise, rejectPromise) => {
48
+ ok = resolvePromise;
49
+ fail = rejectPromise;
50
+ });
51
+ // A rejection the installer answers with its own message must not crash the
52
+ // process on its way past.
53
+ code.catch(() => { });
54
+ const server = createServer((req, res) => {
55
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
56
+ const got = url.pathname === '/callback' ? url.searchParams.get('code') : null;
57
+ if (!got) {
58
+ res.writeHead(404).end();
59
+ return;
60
+ }
61
+ res
62
+ .writeHead(200, { 'content-type': 'text/html' })
63
+ .end('<!doctype html><title>aivi</title><h1>Linear is installed</h1><p>You can close this window and go back to the CLI.</p>');
64
+ ok(got);
65
+ });
66
+ const ready = new Promise((resolvePromise, rejectPromise) => {
67
+ bindFailed = rejectPromise;
68
+ server.listen(0, '127.0.0.1', () => {
69
+ const address = server.address();
70
+ if (address && typeof address !== 'string')
71
+ resolvePromise(`http://127.0.0.1:${address.port}/callback`);
72
+ else
73
+ rejectPromise(new Error('the install listener could not report its port'));
74
+ });
75
+ });
76
+ ready.catch(() => { });
77
+ server.on('error', error => {
78
+ const failure = new Error(`the install listener could not listen: ${errorMessage(error)}`);
79
+ fail(failure);
80
+ bindFailed(failure);
81
+ });
82
+ return {
83
+ code,
84
+ ready,
85
+ close: () => {
86
+ server.close();
87
+ server.closeAllConnections();
88
+ },
89
+ };
90
+ };
91
+ /**
92
+ * Linear compares `redirect_uri` against the registered string as it is
93
+ * written — percent-encoding it is refused — so the URL goes out exactly
94
+ * as the human registered it. The scopes are what delegation and replies
95
+ * need: `app:assignable` is what lets the app be a delegate.
96
+ */
97
+ const authorizeUrl = (clientId, redirect) => 'https://linear.app/oauth/authorize' +
98
+ `?client_id=${clientId}&redirect_uri=${redirect}&response_type=code&actor=app&scope=${INSTALL_SCOPES}`;
99
+ /**
100
+ * The code is traded for an access token the module never uses: the
101
+ * exchange is what makes the install real, and the app's own
102
+ * client-credentials token is all the module needs from then on.
103
+ */
104
+ const completeInstall = async (ctx, clientId, clientSecret, code, redirect) => {
105
+ const response = await ctx.fetch('https://api.linear.app/oauth/token', {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
108
+ body: new URLSearchParams({
109
+ code,
110
+ redirect_uri: redirect,
111
+ client_id: clientId,
112
+ client_secret: clientSecret,
113
+ grant_type: 'authorization_code',
114
+ }),
115
+ });
116
+ if (!response.ok)
117
+ throw new Error(`Linear refused the install code (HTTP ${response.status}): ${await response.text()}`);
118
+ };
119
+ /** The installer is a second process and file events do not report SQLite's WAL
120
+ * writes (macOS never mentions them, measured 2026-09-26), so the diary is
121
+ * re-read on this quiet interval — one query on an indexed column. */
122
+ const POLL_MS = 2_000;
123
+ const setup = async (ctx) => {
124
+ const windowMs = probeWindowMs();
125
+ const config = configSchema.parse(ctx.config);
126
+ const existingBlock = ctx.config.plugins?.[MODULE_ID];
127
+ const configured = Object.keys(existingBlock?.apps ?? {});
128
+ if (configured.length > 1)
129
+ throw new Error(`this aivi already has ${configured.length} Linear apps; the installer configures the one app — edit config.json and .env by hand (docs/linear.md).`);
130
+ // The test observes what the running host records in its request diary:
131
+ // with no live aivi there is nothing to observe, and finding that out at
132
+ // the end wastes a whole install round. The host is alive when it answers
133
+ // `GET /health` on its endpoint, whatever its version — the same test the
134
+ // CLI uses. The installer does not own restarts, so a no is final.
135
+ const hostIsUp = await ctx
136
+ .fetch(`${hostUrl(config.host)}/health`, { signal: AbortSignal.timeout(2_000) })
137
+ .then(response => response.ok)
138
+ .catch(() => false);
139
+ if (!hostIsUp)
140
+ throw new Error('aivi must be running while the installer tests the webhook: start it (`aivi serve`, or `aivi service restart`) and run `aivi add linear` again.');
141
+ // 1. The name aivi calls this app, and the URL Linear can reach it at.
142
+ const id = configured[0] ??
143
+ settled(ctx, await ctx.prompts.text({
144
+ message: 'What should aivi call this Linear app?',
145
+ placeholder: 'e.g. linear-assistant',
146
+ validate: value => value !== undefined && PROJECT_ID.test(value)
147
+ ? undefined
148
+ : 'lowercase letters, digits, dashes and underscores',
149
+ }));
150
+ const webhookPath = appWebhookPath(id);
151
+ let base = config.host.public;
152
+ if (!base) {
153
+ ctx.prompts.note([
154
+ 'Linear posts webhooks to this aivi, so it needs a URL Linear can reach.',
155
+ 'A tunnel is the usual answer — a Tailscale Funnel or similar pointed at aivi.',
156
+ 'aivi cannot discover this URL itself, so the setup asks once and saves it;',
157
+ 'later runs will not ask.',
158
+ ].join('\n'), 'Linear reaches aivi');
159
+ base = settled(ctx, await ctx.prompts.text({
160
+ message: 'The public URL Linear can reach this aivi at',
161
+ placeholder: 'e.g. https://my-machine.ts.net',
162
+ validate: value => value !== undefined && /^https?:\/\//.test(value)
163
+ ? value.endsWith('/')
164
+ ? 'no trailing /'
165
+ : undefined
166
+ : 'an http(s) URL, like https://my-machine.ts.net',
167
+ }));
168
+ }
169
+ const webhookUrl = `${base}${webhookPath}`;
170
+ // 2. The credentials, proven at Linear before anything is written. The
171
+ // install listener binds first because the app must have its exact
172
+ // Redirect URI registered before the install link is accepted — so
173
+ // the instructions print the URL the installer is really listening on.
174
+ // Every exit from this section closes the listener: a bound server
175
+ // keeps the process alive after the last line is said, and a
176
+ // mistyped secret used to hang the installer on its 401 rather than
177
+ // let it exit (seen live 2026-09-27).
178
+ const install = catchInstall();
179
+ let clientId;
180
+ let clientSecret;
181
+ let client;
182
+ let viewer;
183
+ let teams;
184
+ try {
185
+ const redirect = await install.ready;
186
+ ctx.prompts.note([
187
+ '1. Open https://linear.app/settings/api/applications/new',
188
+ '2. App name is the bot’s name; Developer name is yours.',
189
+ `3. Redirect URIs: ${redirect}`,
190
+ '4. Tick Client credentials and Webhooks.',
191
+ `5. Webhook URL: ${webhookUrl}`,
192
+ ' Tick the checkboxes for Issues under Data change events',
193
+ ' and Agent session events under App events.',
194
+ '6. Press Create, then copy the Client ID and Client Secret below.',
195
+ ].join('\n'), 'Creating the Linear app');
196
+ clientId = settled(ctx, await ctx.prompts.text({
197
+ message: 'The app’s Client ID',
198
+ placeholder: 'cli_…',
199
+ validate: value => (value ? undefined : 'Paste the Client ID from the app page'),
200
+ }));
201
+ clientSecret = settled(ctx, await ctx.prompts.password({
202
+ message: 'The app’s Client Secret',
203
+ validate: value => (value ? undefined : 'Paste the Client Secret from the app page'),
204
+ }));
205
+ client = new LinearClient({ clientId, clientSecret }, { fetch: ctx.fetch });
206
+ viewer = await client.viewerId();
207
+ await ctx.prompts.log.message('Linear took the credentials — the app answers as its own bot user.');
208
+ teams = await client.listTeams();
209
+ await ctx.prompts.log.message(`The app can see ${teams.length ? teams.map(t => `${t.key} (${t.name})`).join(', ') : 'no teams'}.`);
210
+ // The install URL goes out on a bare line: the operator copies it, and
211
+ // note boxes and log guides put characters in the way of copying.
212
+ ctx.print(`Install the app — open this once and allow it:\n\n${authorizeUrl(clientId, redirect)}`);
213
+ const installCode = await install.code;
214
+ await completeInstall(ctx, clientId, clientSecret, installCode, redirect);
215
+ }
216
+ finally {
217
+ install.close();
218
+ }
219
+ await ctx.prompts.log.message('Installed.');
220
+ const webhookSecret = settled(ctx, await ctx.prompts.password({
221
+ message: 'The webhook’s Signing Secret',
222
+ validate: value => (value ? undefined : 'Paste the Signing Secret from the webhook section'),
223
+ }));
224
+ // 3. The test, before any write: one throwaway ticket, proven in two
225
+ // waits that run one after the other, named for the systems the module
226
+ // lives on — first that Linear posts deliveries to this URL, then that
227
+ // delegating the ticket creates an agent session whose created event
228
+ // arrives the same way. Clack animates one live line and prints
229
+ // nothing beside it, so each wait takes the screen in turn: its line
230
+ // says what just landed, and its verdict closes the line with clack's
231
+ // own marks — a hollow green diamond for passed, a red square for
232
+ // failed — and a failure names the likeliest cause. With the module not
233
+ // yet loaded, every delivery is recorded untouched: pure observation
234
+ // of Linear's behavior. A wait that passes is not made to sit out a
235
+ // window; the window is what a failure needs, because silence is a
236
+ // result too and only a waited-out silence proves it.
237
+ const checks = [];
238
+ if (!teams.length) {
239
+ await ctx.prompts.log.message('The app sees no teams, so there is nowhere to put the throwaway ticket — the checks are skipped.');
240
+ }
241
+ else {
242
+ // What the test does is said before the operator is asked to point it
243
+ // at a team; starting it takes no further confirmation.
244
+ ctx.prompts.note([
245
+ 'The installer will now test if the webhook works. It does this by',
246
+ 'creating a temporary ticket, delegating it to the app, and watching',
247
+ 'the webhooks Linear posts back. It archives the ticket when the',
248
+ 'test ends. When Linear can reach this aivi, the test takes seconds.',
249
+ ].join('\n'), 'Testing the configuration');
250
+ const teamId = settled(ctx, await ctx.prompts.select({
251
+ message: 'Which team gets the throwaway ticket?',
252
+ options: teams.map(t => ({ value: t.id, label: `${t.key} — ${t.name}` })),
253
+ }));
254
+ // The store door the runner supplies: the home database is open for this
255
+ // block and closed after it, whatever the two waits do.
256
+ await ctx.withStore(async (store) => {
257
+ // Whatever the diary holds before the test ticket exists is nobody's
258
+ // business; the cursor starts at its newest row.
259
+ const cursor = latestId(store);
260
+ const issue = await client.createIssue({
261
+ teamId,
262
+ title: 'aivi add test — safe to delete',
263
+ description: `Created by \`aivi add linear\` to test the webhook at ${webhookUrl}. The installer archives it when the test ends.`,
264
+ });
265
+ await ctx.prompts.log.message(`Created ${issue.identifier}.`);
266
+ const probe = { ctx, client, viewer, windowMs, webhookPath, webhookSecret, checks };
267
+ const posted = await checkWebhookDeliveries(probe, store, issue, cursor);
268
+ await checkAgentSessionEvents(probe, store, issue, posted);
269
+ // The installer made the ticket, so the installer tidies it — Linear's
270
+ // delete archives it, off the board. A delete that fails is said, not
271
+ // swallowed, so a ticket is never left without the operator knowing.
272
+ try {
273
+ await client.deleteIssue(issue.id);
274
+ await ctx.prompts.log.message(`${issue.identifier} archived.`);
275
+ }
276
+ catch (error) {
277
+ await ctx.prompts.log.message(`Could not archive ${issue.identifier} — delete it by hand (${errorMessage(error)}).`);
278
+ }
279
+ });
280
+ }
281
+ // 4. Only now is anything written: the secrets under the bare primary
282
+ // names, `host.public` if the installer asked for it, and the
283
+ // `tracker-linear` block with this app added.
284
+ if (!config.host.public)
285
+ await ctx.writeConfigBlock(['host', 'public'], base);
286
+ await ctx.writeSecret(linearPrimarySecretNames.clientId, clientId);
287
+ await ctx.writeSecret(linearPrimarySecretNames.clientSecret, clientSecret);
288
+ await ctx.writeSecret(linearPrimarySecretNames.webhookSecret, webhookSecret);
289
+ await ctx.writeConfigBlock(['plugins', MODULE_ID], {
290
+ ...existingBlock,
291
+ apps: { ...(existingBlock?.apps ?? {}), [id]: {} },
292
+ });
293
+ const failed = checks.filter(c => !c.pass);
294
+ const outcome = !checks.length
295
+ ? 'nothing was tested — the app sees no teams, so there was nowhere to put the throwaway ticket'
296
+ : failed.length
297
+ ? `FAILED — ${failed.map(c => `${c.name}: ${c.detail}`).join('; ')}. Fix that and run the install again`
298
+ : 'both checks passed';
299
+ // The installer started only because a live aivi answered its health
300
+ // endpoint, so what loads the module is its own line to say; when a
301
+ // service is installed the CLI restarts it anyway and reports that as
302
+ // it happens. Performing a restart never belongs to this flow.
303
+ return {
304
+ module: MODULE_ID,
305
+ summary: failed.length
306
+ ? `Linear is configured: ${outcome}.`
307
+ : `Linear is configured: ${outcome}. Restart aivi to load Linear.`,
308
+ };
309
+ };
310
+ /** The first wait, on the live line: Linear must post the ticket's creation
311
+ * to the URL before anything else has proof to stand on. `stop` closes a
312
+ * line with clack's settled mark, a hollow green diamond; `cancel` with a
313
+ * red square. A verdict is said on the live line and recorded for the
314
+ * summary, together, in every branch. */
315
+ async function checkWebhookDeliveries(probe, store, issue, cursor) {
316
+ const { ctx, windowMs, webhookPath, webhookSecret, checks } = probe;
317
+ const webhooks = ctx.prompts.spinner();
318
+ webhooks.start(`webhooks: waiting for Linear to post about ${issue.identifier}…`);
319
+ let posted;
320
+ try {
321
+ posted = await waitDiary(store, webhookPath, webhookSecret, issue.id, cursor, windowMs, isIssueEvent, line => webhooks.message(line));
322
+ }
323
+ catch (error) {
324
+ webhooks.cancel('webhooks — a delivery arrived that did not verify');
325
+ throw error;
326
+ }
327
+ if (posted.mine) {
328
+ const detail = `${posted.mine} issue ${posted.mine === 1 ? 'delivery' : 'deliveries'} arrived and verified`;
329
+ webhooks.stop(`webhooks — ${detail}`);
330
+ checks.push({ name: 'webhooks', pass: true, detail });
331
+ }
332
+ else if (posted.others) {
333
+ const detail = `deliveries arrived, but none about ${issue.identifier} — the categories ticked on the webhook decide what Linear posts`;
334
+ webhooks.cancel(`webhooks — ${detail}`);
335
+ checks.push({ name: 'webhooks', pass: false, detail });
336
+ }
337
+ else {
338
+ const detail = `nothing arrived in ${windowMs / 1000} s — the Webhook URL on the app is not this one, the categories are off, or Linear cannot reach this machine`;
339
+ webhooks.cancel(`webhooks — ${detail}`);
340
+ checks.push({ name: 'webhooks', pass: false, detail });
341
+ }
342
+ return posted;
343
+ }
344
+ /** The second wait needs a delegation of its own: becoming the delegate is
345
+ * what makes Linear create the session, and the session's own delivery
346
+ * proves the app is subscribed to those too. It is skipped only when the
347
+ * webhooks wait proved nothing arrives at all — the same silence waited
348
+ * out twice proves nothing new. */
349
+ async function checkAgentSessionEvents(probe, store, issue, posted) {
350
+ const { ctx, client, viewer, windowMs, webhookPath, webhookSecret, checks } = probe;
351
+ if (!posted.mine && !posted.others) {
352
+ await ctx.prompts.log.message('agent events: skipped — it waits on the same webhooks; with nothing arriving it could prove nothing.');
353
+ checks.push({
354
+ name: 'agent events',
355
+ pass: false,
356
+ detail: 'not tested — no webhook arrived for the webhooks check either',
357
+ });
358
+ return;
359
+ }
360
+ const events = ctx.prompts.spinner();
361
+ events.start(`agent events: delegating ${issue.identifier} to the app…`);
362
+ let delegated;
363
+ // Linear's own answer to the delegation names the session it
364
+ // created; its arrival is settled the instant the mutation lands.
365
+ let made = false;
366
+ try {
367
+ const answer = await client.setDelegate(issue.id, viewer);
368
+ made = answer.issue?.agentSessions.nodes.some(s => s.status === 'pending') ?? false;
369
+ events.message('Delegated — waiting for the session event…');
370
+ delegated = await waitDiary(store, webhookPath, webhookSecret, issue.id, posted.last, windowMs, isAgentSessionEvent, line => events.message(line));
371
+ }
372
+ catch (error) {
373
+ events.cancel('agent events — stopped before the session event could be seen');
374
+ throw error;
375
+ }
376
+ if (delegated.mine) {
377
+ const detail = made
378
+ ? 'delegation created the session and its created event arrived'
379
+ : 'a created session event arrived though the delegate answer named no session';
380
+ events.stop(`agent events — ${detail}`);
381
+ checks.push({ name: 'agent events', pass: true, detail });
382
+ }
383
+ else if (made) {
384
+ const detail = `the session was created but its event did not arrive in ${windowMs / 1000} s — tick Agent session events under App events on the app`;
385
+ events.cancel(`agent events — ${detail}`);
386
+ checks.push({ name: 'agent events', pass: false, detail });
387
+ }
388
+ else {
389
+ const detail = 'no session at all — the app took the delegation but Linear made nothing to work from';
390
+ events.cancel(`agent events — ${detail}`);
391
+ checks.push({ name: 'agent events', pass: false, detail });
392
+ }
393
+ }
394
+ /** What one delivery is about, and whether it names our test ticket. */
395
+ const namesIssue = (payload, issueId) => isIssueEvent(payload)
396
+ ? payload.data.id === issueId
397
+ : isAgentSessionEvent(payload) && payload.agentSession.issue?.id === issueId;
398
+ /** One line saying what a verified delivery is, in Linear's own words, naming
399
+ * the issue it is about — during a test several issues can be in play. The
400
+ * status our side answered with is left out on purpose: with the module not
401
+ * yet loaded nothing can answer 2xx, and the delivery arriving is the point. */
402
+ const describeDelivery = (payload) => {
403
+ if (isAgentSessionEvent(payload)) {
404
+ const issue = payload.agentSession.issue?.identifier ?? 'an issue the payload did not name';
405
+ return `agent-session event: ${payload.action}${payload.agentSession.status ? `, status ${payload.agentSession.status}` : ''} on ${issue}`;
406
+ }
407
+ if (isIssueEvent(payload)) {
408
+ const changed = payload.action === 'update' && payload.updatedFrom ? Object.keys(payload.updatedFrom).join(', ') : '';
409
+ return `issue ${payload.action} on ${payload.data.identifier ?? 'an issue the payload did not name'}${changed ? `, changed ${changed}` : ''}`;
410
+ }
411
+ return `${payload.type} ${payload.action}`;
412
+ };
413
+ /** The newest diary row id right now. Whatever exists before a test starts is
414
+ * nobody's business; anything the test draws lands with a larger id. */
415
+ const latestId = (store) => {
416
+ let last = 0;
417
+ let rows = store.requests({ sinceId: last });
418
+ while (rows.length) {
419
+ last = rows[rows.length - 1].id;
420
+ rows = store.requests({ sinceId: last });
421
+ }
422
+ return last;
423
+ };
424
+ /**
425
+ * Watch the diary from `cursor` until one verified delivery that `accepts`
426
+ * names our ticket, or `ms` has passed. A wait that passes is not made to
427
+ * sit out its window; the window is what a failure needs, because silence
428
+ * is a result too and only a waited-out silence proves it. The installer is
429
+ * another process than the server, and file events do not report SQLite's
430
+ * WAL writes — macOS never mentions them (measured 2026-09-26) — so a quiet
431
+ * re-read of one indexed query is the reliable arrival signal. Every
432
+ * verified delivery is said the moment it lands, whatever issue it names:
433
+ * the line belongs to the wait's spinner. A delivery that fails the
434
+ * module's own `verifyWebhook` ends the installer at once — the signing
435
+ * secret would be wrong for every delivery, so waiting cannot help.
436
+ */
437
+ async function waitDiary(store, path, webhookSecret, issueId, cursor, ms, accepts, say) {
438
+ let mine = 0;
439
+ let others = 0;
440
+ let last = cursor;
441
+ await new Promise((settled, failed) => {
442
+ let done = false;
443
+ let poll;
444
+ let expiry;
445
+ const read = () => {
446
+ // `aivi host clear-logs` can empty the diary and restart its ids below
447
+ // our cursor; when that happens, follow the fresh diary, not the mark.
448
+ if (latestId(store) < last)
449
+ last = 0;
450
+ for (const row of store.requests({ method: 'POST', path, sinceId: last })) {
451
+ last = row.id;
452
+ if (row.truncated)
453
+ throw new Error(`a delivery arrived but its body is not readable JSON — the diary cut it off (row ${row.id}) — verification needs the whole body.`);
454
+ const verdict = verifyWebhook({
455
+ body: Buffer.from(row.body ?? '', 'utf8'),
456
+ signature: row.headers['linear-signature'],
457
+ secret: webhookSecret,
458
+ now: row.at,
459
+ });
460
+ if (!verdict.ok)
461
+ throw new Error(`a delivery arrived but failed the module's own check: ${verdict.reason} (HTTP ${row.status}).` +
462
+ (verdict.reason.includes('signature')
463
+ ? ' The signing secret pasted here is not the one Linear sends.'
464
+ : ''));
465
+ say(`Heard: ${describeDelivery(verdict.payload)}`); // every delivery, the moment it lands
466
+ if (!namesIssue(verdict.payload, issueId)) {
467
+ others++;
468
+ continue;
469
+ }
470
+ // The proof is in: a passed wait is nobody's waiting room, and the
471
+ // stop's own read finishes nothing this loop has left to say.
472
+ if (accepts(verdict.payload)) {
473
+ mine++;
474
+ stop();
475
+ return;
476
+ }
477
+ }
478
+ };
479
+ const stop = (error) => {
480
+ if (done)
481
+ return;
482
+ done = true;
483
+ if (poll !== undefined)
484
+ clearTimeout(poll);
485
+ if (expiry !== undefined)
486
+ clearTimeout(expiry);
487
+ if (error === undefined) {
488
+ try {
489
+ read(); // the deadline read: complete even if a tick was late
490
+ }
491
+ catch (cause) {
492
+ failed(cause);
493
+ return;
494
+ }
495
+ settled();
496
+ }
497
+ else
498
+ failed(error);
499
+ };
500
+ const tick = () => {
501
+ try {
502
+ read();
503
+ }
504
+ catch (cause) {
505
+ stop(cause);
506
+ return;
507
+ }
508
+ // Self-rescheduling: one pending timer, always, and a slow read
509
+ // cannot pile up behind itself. And a read that ended the wait is
510
+ // never re-armed: no timer outlives the verdict.
511
+ if (done)
512
+ return;
513
+ poll = setTimeout(tick, POLL_MS);
514
+ };
515
+ poll = setTimeout(tick, POLL_MS); // the reliable arrival signal across processes: a re-read
516
+ expiry = setTimeout(() => stop(), ms); // the window's end: a known instant
517
+ try {
518
+ read(); // whatever already arrived before the watching began
519
+ }
520
+ catch (cause) {
521
+ stop(cause);
522
+ }
523
+ });
524
+ return { mine, others, last };
525
+ }
526
+ export default setup;