@aivi/tracker-github 0.0.0-stage → 0.0.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.
@@ -0,0 +1,505 @@
1
+ /** GitHub's **platform**: the `Platform` contract of `@aivi/plugin/tracker`
2
+ * spelled in GitHub's words. It owns everything GitHub-shaped below the
3
+ * neutral surface — the one app and its credential (through `@aivi/github`),
4
+ * the webhook route and its seal, the board reads and the `Agent` field, the
5
+ * status comment and its hidden state (D6, D7) — and translates at the
6
+ * border: deliveries become `TrackerEvent`s, neutral updates become GitHub
7
+ * mutations. The module's decision code imports **no** GitHub types through
8
+ * this file.
9
+ *
10
+ * **Conversations** are the adapter's own affair, and GitHub's are the
11
+ * ticket and its status comment (no agent session exists for an app — D5):
12
+ *
13
+ * - `github` — the app's own feed: desk calls and notices, no session.
14
+ * - `github:owner/repo#47` — a person brought the ticket: the **assistant's**
15
+ * conversation, the ticket itself.
16
+ * - `github:owner/repo#47:c123` — a **run's** conversation: the status
17
+ * comment `startSession` created (D6). Its body carries the machine state
18
+ * as a hidden JSON comment (D7); visible words, plan checklist, and the
19
+ * hidden line are all one edited comment.
20
+ */
21
+ import { getLogger } from '@aivi/core';
22
+ import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from '@aivi/github';
23
+ import { MODULE_ID } from './config.js';
24
+ import { projectForIssue, projectGithub } from './projects.js';
25
+ import { DeliveryLedger, editedFieldName, GITHUB_DELIVERY_HEADER, GITHUB_EVENT_HEADER, isBotAuthor, trustedAuthor, } from './webhook.js';
26
+ /** The app's own feed: desk calls and notices speak here — there is no
27
+ * session to name, and one app speaks for the home. */
28
+ export const GITHUB_FEED = 'github';
29
+ /** A run conversation's session id: the ticket with its status comment's id
30
+ * on it — `owner/repo#47:c123`. */
31
+ const RUN_SESSION = /^(.+#\d+):c(\d+)$/;
32
+ /** A bare ticket ref as a conversation names it: `owner/repo#47`. */
33
+ const TICKET_SESSION = /^([^/]+\/[^#]+)#(\d+)$/;
34
+ /** The hidden machine-state line (D7): JSON in an HTML comment at the body's
35
+ * end. GitHub shows a person the words and hands aivi the state. */
36
+ const META_LINE = /<!--\s*(\{[\s\S]*\})\s*-->\s*$/;
37
+ /** Read a status comment's body into its parts. A body with no hidden line
38
+ * (a person edited it away) still reads: the state defaults to a standing
39
+ * status, and the words are whatever stands. */
40
+ export function parseStatus(body) {
41
+ const found = META_LINE.exec(body);
42
+ let meta = { kind: 'status' };
43
+ let rest = body;
44
+ if (found) {
45
+ try {
46
+ meta = JSON.parse(found[1]);
47
+ }
48
+ catch {
49
+ /* a person broke the JSON: the state is unreadable, the status stands */
50
+ }
51
+ rest = body.slice(0, found.index).trimEnd();
52
+ }
53
+ const lines = rest.split('\n');
54
+ const head = lines.shift() ?? '';
55
+ const planLines = [];
56
+ const at = lines.indexOf('### Plan');
57
+ if (at >= 0)
58
+ for (const line of lines.slice(at + 1))
59
+ if (line.startsWith('- '))
60
+ planLines.push(line);
61
+ return { head, planLines, meta };
62
+ }
63
+ /** The whole body from its parts: head line, plan section when there is
64
+ * one, and the hidden line last. */
65
+ export function renderStatus(head, planLines, meta) {
66
+ const parts = [head];
67
+ if (planLines.length)
68
+ parts.push(['### Plan', ...planLines].join('\n'));
69
+ parts.push(`<!-- ${JSON.stringify(meta)} -->`);
70
+ return `${parts.join('\n\n')}\n`;
71
+ }
72
+ const planStepLine = (step) => step.status === 'completed'
73
+ ? `- [x] ${step.content}`
74
+ : step.status === 'canceled'
75
+ ? `- [-] ${step.content}`
76
+ : `- [ ] ${step.content}`;
77
+ /** The error a read answers when the surface it read is gone: shaped so
78
+ * `isGoneError` says gone, because the closer's drop-the-debt path keys on
79
+ * it exactly as Linear's does on `Entity not found`. */
80
+ const gone = (what) => {
81
+ const error = new Error(`${what} is gone (deleted, or never visible to the app)`);
82
+ error.status = 404;
83
+ return error;
84
+ };
85
+ export class GithubPlatform {
86
+ id = MODULE_ID;
87
+ loaded;
88
+ client;
89
+ webhookSecret;
90
+ log;
91
+ deliveries = new DeliveryLedger();
92
+ sinks = new Set();
93
+ /** The divider line for comments (ruled 2026-10-07): who GitHub says may
94
+ * talk. Read per delivery from `author_association`; an untrusted comment
95
+ * is nothing — no event, no reply, no log line. */
96
+ trust;
97
+ /** Which board node ids aivi speaks, by board number — warmed at start.
98
+ * A `projects_v2_item` delivery names its board by node id and nothing
99
+ * else; a board nobody configured is noise and says nothing. */
100
+ boardNodes = new Map();
101
+ constructor(loaded, client, webhookSecret, log = getLogger(['aivi', MODULE_ID]), trust = 'members') {
102
+ this.loaded = loaded;
103
+ this.client = client;
104
+ this.webhookSecret = webhookSecret;
105
+ this.log = log;
106
+ this.trust = trust;
107
+ }
108
+ /** Resolve every configured board's fields once, at module start: it says
109
+ * of a missing `Status` or `Agent` field before any walk asks, and it
110
+ * teaches the delivery filter which boards are ours. */
111
+ async warm() {
112
+ for (const project of this.loaded.projects) {
113
+ if (project.removed)
114
+ continue;
115
+ const github = projectGithub(this.loaded, project.id);
116
+ if (!github)
117
+ continue;
118
+ const fields = await this.client.boardFields(github.board);
119
+ this.boardNodes.set(fields.projectId, github.board);
120
+ }
121
+ }
122
+ // ——— board lookups ———
123
+ /** Which board speaks for a repository's issues, and for a ticket by way
124
+ * of its repository. */
125
+ boardOfRepo(repo) {
126
+ return projectForIssue(this.loaded, { repo: repo })?.github;
127
+ }
128
+ boardOf(issueId) {
129
+ const repo = issueId.slice(0, issueId.indexOf('#'));
130
+ const github = this.boardOfRepo(repo);
131
+ if (!github)
132
+ throw new Error(`${issueId} feeds no aivi project: ${repo} is in no project's repos`);
133
+ return github;
134
+ }
135
+ // ——— the neutral surface ———
136
+ async laneStates(_conversation, teamId) {
137
+ const github = this.boardOfRepo(teamId);
138
+ if (!github)
139
+ throw new Error(`${teamId} feeds no aivi project: it has no board and no states`);
140
+ return (await this.client.laneOptions(github.board)).map(o => ({ id: o.id, name: o.name, type: '' }));
141
+ }
142
+ /** `createStates` is deliberately absent (D4): lanes map onto the board's
143
+ * own Status options, and aivi never creates or renames one — the human's
144
+ * board is not aivi's to edit. */
145
+ async apply(_conversation, issueId, update) {
146
+ if (update.kind === 'move') {
147
+ await this.client.setLane(issueId, this.boardOf(issueId).board, update.lane);
148
+ return;
149
+ }
150
+ if (update.kind === 'label') {
151
+ if (update.on)
152
+ await this.client.addLabel(issueId, update.label);
153
+ else
154
+ await this.client.removeLabel(issueId, update.label);
155
+ return;
156
+ }
157
+ await this.client.createComment(issueId, update.text);
158
+ }
159
+ async issue(_conversation, issueId) {
160
+ return this.neutralIssue(await this.client.issue(issueId));
161
+ }
162
+ async assign(_conversation, issueId, userId) {
163
+ await this.client.setAgent(issueId, this.boardOf(issueId).board, userId);
164
+ }
165
+ async unassign(_conversation, issueId) {
166
+ await this.client.setAgent(issueId, this.boardOf(issueId).board, null);
167
+ }
168
+ /** Open the run's conversation (D6): one status comment, the first word
169
+ * visible and the machine state hidden under it. Its id, with the ticket
170
+ * on it, is the conversation handle the module records. */
171
+ async startSession(_conversation, issueId) {
172
+ const made = await this.client.createComment(issueId, renderStatus('**Preparing the workspace…**', [], { kind: 'status' }));
173
+ return `${issueId}:c${made.id}`;
174
+ }
175
+ idFor(sessionId) {
176
+ return `${GITHUB_FEED}:${sessionId}`;
177
+ }
178
+ parts(conversation) {
179
+ if (conversation === GITHUB_FEED)
180
+ return { app: GITHUB_FEED, session: '' };
181
+ const session = conversation.startsWith(`${GITHUB_FEED}:`)
182
+ ? conversation.slice(GITHUB_FEED.length + 1)
183
+ : conversation;
184
+ return { app: GITHUB_FEED, session };
185
+ }
186
+ async orgOf(_conversation) {
187
+ return this.client.org;
188
+ }
189
+ /** Who aivi is on a ticket: the `Agent` field's option named for the app
190
+ * (D5) — the same word on every board; per-board option ids are the
191
+ * client's resolution detail. */
192
+ ownerOf(_conversation) {
193
+ return this.client.delegateName;
194
+ }
195
+ /** One message into a conversation, and the conversation decides what a
196
+ * transient word **is**: inside a run it edits the status comment (no
197
+ * second comment may linger — transient by contract); on a ticket it is
198
+ * the assistant's placeholder, a real comment the engine may edit by id
199
+ * and delete when the answer lands. */
200
+ async comment(conversation, text, kind) {
201
+ const session = this.parts(conversation).session;
202
+ const run = RUN_SESSION.exec(session);
203
+ if (run) {
204
+ if (kind === 'progress') {
205
+ await this.progress(conversation, { text });
206
+ return undefined;
207
+ }
208
+ const made = await this.client.createComment(run[1], text);
209
+ return String(made.id);
210
+ }
211
+ if (!TICKET_SESSION.test(session))
212
+ throw new Error(`${conversation} is not a conversation a comment lands on`);
213
+ const made = await this.client.createComment(session, text);
214
+ return String(made.id);
215
+ }
216
+ /** Replace the status comment's line — the only edit a progress word may
217
+ * make. A frozen comment (the closing landed) is left as it stands: the
218
+ * closing is the last word, and a trailing in-flight edit never speaks
219
+ * after it. */
220
+ async progress(conversation, line) {
221
+ const { ref, commentId } = this.runOf(conversation);
222
+ const body = await this.client.getComment(ref, commentId);
223
+ if (body === undefined)
224
+ throw gone(`the status comment of ${ref}`);
225
+ const standing = parseStatus(body);
226
+ if (standing.meta.kind === 'done')
227
+ return;
228
+ const tool = line.tool ? ` · \`${line.tool.name}${line.tool.detail ? ` ${line.tool.detail}` : ''}\`` : '';
229
+ await this.client.updateComment(ref, commentId, renderStatus(`**${line.text}**${tool}`, standing.planLines, standing.meta));
230
+ }
231
+ /** A run that died before it had a conversation still speaks on the
232
+ * ticket: silence is not an answer (ruled 2026-10-02). */
233
+ async notify(_conversation, issueId, text) {
234
+ await this.client.createComment(issueId, text);
235
+ }
236
+ /** Has this run's result been rendered? GitHub keeps no session to ask:
237
+ * the answer is a comment on the ticket whose hidden line says
238
+ * `kind: closing` **and names this run's status comment** — so a catch-up
239
+ * says each run's result once, and a second run on the same ticket is a
240
+ * different conversation with its own result. */
241
+ async resultShown(conversation) {
242
+ const { ref, commentId } = this.runOf(conversation);
243
+ for (const comment of await this.client.comments(ref)) {
244
+ if (!isBotAuthor({ type: comment.authorIsBot ? 'Bot' : 'User' }))
245
+ continue;
246
+ const meta = parseStatus(comment.body).meta;
247
+ if (meta.kind === 'closing' && Number(meta.status) === commentId)
248
+ return true;
249
+ }
250
+ return false;
251
+ }
252
+ /** Put the worker's question to the people (required of every tracker):
253
+ * GitHub Issues renders a numbered comment; the answer arrives as an
254
+ * ordinary comment and the open OpenCode form tells it from chatter. */
255
+ async ask(conversation, question) {
256
+ const { ref } = this.runOf(conversation);
257
+ const lines = [`**Question** · ${question.question}`];
258
+ if (question.options?.length)
259
+ lines.push(question.options
260
+ .map((o, i) => `${i + 1}. ${o.label}${o.value === o.label ? '' : ` — \`${o.value}\``}`)
261
+ .join('\n'));
262
+ await this.client.createComment(ref, renderStatus(lines.join('\n\n'), [], { kind: 'question' }));
263
+ }
264
+ /** Show the worker's plan: the whole checklist, replacing what stood, in
265
+ * the status comment's own plan section. */
266
+ async plan(conversation, plan) {
267
+ const { ref, commentId } = this.runOf(conversation);
268
+ const body = await this.client.getComment(ref, commentId);
269
+ if (body === undefined)
270
+ throw gone(`the status comment of ${ref}`);
271
+ const standing = parseStatus(body);
272
+ await this.client.updateComment(ref, commentId, renderStatus(standing.head, plan.steps.map(planStepLine), standing.meta));
273
+ }
274
+ // ——— the ticket desk (the app's own feed; conversation selects nothing) ———
275
+ async ticketComments(_conversation, issueId) {
276
+ return this.client.comments(issueId);
277
+ }
278
+ async addComment(_conversation, issueId, text) {
279
+ await this.client.createComment(issueId, text);
280
+ }
281
+ async editIssue(_conversation, issueId, changes) {
282
+ return this.neutralIssue(await this.client.updateIssue(issueId, changes));
283
+ }
284
+ async createIssue(_conversation, input) {
285
+ const { lane, ...words } = input;
286
+ const made = await this.client.createIssue(input.teamId, {
287
+ title: words.title,
288
+ ...(words.description !== undefined ? { body: words.description } : {}),
289
+ });
290
+ if (lane) {
291
+ const board = this.boardOfRepo(input.teamId)?.board;
292
+ if (board === undefined)
293
+ throw new Error(`${input.teamId} feeds no aivi project: ${lane} is nowhere to land it`);
294
+ const options = await this.client.laneOptions(board);
295
+ if (!options.some(o => o.name === lane))
296
+ throw new Error(`board ${board} of ${this.client.org} has no Status option "${lane}": it exists on the board or aivi will not guess past it`);
297
+ await this.client.setLane(made.ref, board, lane);
298
+ }
299
+ return this.neutralIssue(made);
300
+ }
301
+ async teamLabels(_conversation, teamId) {
302
+ return this.client.repoLabels(teamId);
303
+ }
304
+ events(sink) {
305
+ this.sinks.add(sink);
306
+ return () => this.sinks.delete(sink);
307
+ }
308
+ // ——— the closing's own words (the module's, not the contract's) ———
309
+ /** The run's final word: a comment of its own, tied to its status comment
310
+ * so `resultShown` can swear each run said its result once. */
311
+ async markClosing(conversation, text, success) {
312
+ const { ref, commentId } = this.runOf(conversation);
313
+ await this.client.createComment(ref, renderStatus(text, [], { kind: 'closing', status: commentId, ok: success }));
314
+ }
315
+ /** Freeze the status comment: the working line goes, the terminal word
316
+ * stands. Idempotent — a retry lands on an already-frozen comment as
317
+ * nothing. */
318
+ async freezeStatus(conversation, head) {
319
+ const { ref, commentId } = this.runOf(conversation);
320
+ const body = await this.client.getComment(ref, commentId);
321
+ if (body === undefined)
322
+ throw gone(`the status comment of ${ref}`);
323
+ const standing = parseStatus(body);
324
+ if (standing.meta.kind === 'done')
325
+ return;
326
+ await this.client.updateComment(ref, commentId, renderStatus(head, standing.planLines, { ...standing.meta, kind: 'done' }));
327
+ }
328
+ // ——— inbound: the whole delivery surface ———
329
+ /** One delivery, whole: the seal first (bytes, not re-serialized JSON),
330
+ * the redelivery filter second, then the acknowledgement — GitHub retries
331
+ * anything but a 200, and a handler that fails later must not invite a
332
+ * replay of what already ran. */
333
+ async deliver(request) {
334
+ const event = request.headers[GITHUB_EVENT_HEADER];
335
+ const delivery = request.headers[GITHUB_DELIVERY_HEADER];
336
+ const signature = request.headers[WEBHOOK_SIGNATURE_HEADER];
337
+ if (!event)
338
+ return { status: 400, body: { error: 'missing X-GitHub-Event' } };
339
+ if (!delivery)
340
+ return { status: 400, body: { error: 'missing X-GitHub-Delivery' } };
341
+ if (!signature || !verifyWebhookSignature(this.webhookSecret, request.body, signature)) {
342
+ this.log.warn('webhook.refused', { delivery, reason: 'signature mismatch' });
343
+ return { status: 401, body: { error: 'signature mismatch' } };
344
+ }
345
+ if (!this.deliveries.fresh(delivery))
346
+ return { status: 200, body: { ok: true, duplicate: true } };
347
+ let payload;
348
+ try {
349
+ payload = JSON.parse(request.body.toString('utf8'));
350
+ }
351
+ catch {
352
+ return { status: 400, body: { error: 'body is not JSON' } };
353
+ }
354
+ this.dispatch(event, payload).catch(error => {
355
+ this.log.warn('webhook.failed', { delivery, event, error });
356
+ });
357
+ return { status: 200, body: { ok: true } };
358
+ }
359
+ /** Translate one delivery into normalized events — or into nothing.
360
+ * `ping`, `installation`, board reorderings, bot comments (aivi answering
361
+ * itself), and every action outside the routing set are noise (live,
362
+ * 2026-10-07). */
363
+ async dispatch(event, payload) {
364
+ if (event === 'issue_comment') {
365
+ const p = payload;
366
+ if (p.action !== 'created' || isBotAuthor(p.comment?.user) || p.issue?.number === undefined)
367
+ return;
368
+ // The divider line (ruled 2026-10-07): on a public repository anyone may
369
+ // comment, but only a trusted author reaches a worker or the assistant.
370
+ // An untrusted one is dropped here — the one gate for all three paths
371
+ // (form answer, steer, assistant) — with no reply (silence does not
372
+ // advertise the bot) and no log line (drive-by comments are a public
373
+ // repo's weather). A lane or label move needs board write access GitHub
374
+ // itself enforces, so the other two events need no gate.
375
+ if (!trustedAuthor(p.comment?.author_association, this.trust))
376
+ return;
377
+ await this.emit({
378
+ kind: 'prompted',
379
+ id: `comment:${p.comment.id}`,
380
+ conversation: this.idFor(`${p.repository.full_name}#${p.issue.number}`),
381
+ ...(p.comment.body === null ? {} : { body: p.comment.body }),
382
+ });
383
+ return;
384
+ }
385
+ if (event === 'issues') {
386
+ const p = payload;
387
+ // The number is inside `issue` — a top-level one is undefined, and an
388
+ // id built from undefined is the failure the live ledger caught.
389
+ if (p.issue?.number === undefined)
390
+ return;
391
+ const changed = issueChanges(p.action);
392
+ if (!changed.length)
393
+ return;
394
+ await this.emit({
395
+ kind: 'updated',
396
+ conversation: GITHUB_FEED,
397
+ issueId: `${p.repository.full_name}#${p.issue.number}`,
398
+ changed,
399
+ });
400
+ return;
401
+ }
402
+ if (event === 'projects_v2_item') {
403
+ const p = payload;
404
+ const item = p.projects_v2_item;
405
+ if (!item)
406
+ return;
407
+ const boardNumber = this.boardNodes.get(item.project_node_id);
408
+ if (boardNumber === undefined)
409
+ return; // a board aivi does not speak
410
+ let changed;
411
+ if (p.action === 'created')
412
+ changed = ['state']; // added to the board: the walk should look
413
+ else if (p.action === 'edited') {
414
+ const field = editedFieldName(p.changes);
415
+ if (field === undefined)
416
+ return;
417
+ changed = field === 'Status' ? ['state'] : field === 'Agent' ? ['delegate'] : [];
418
+ }
419
+ else
420
+ return; // reordered, archived, moved: noise or nothing routing keys on
421
+ if (!changed.length)
422
+ return;
423
+ const nodeId = item.content_node_id;
424
+ if (!nodeId)
425
+ return;
426
+ const ref = await this.client.nodeRef(nodeId);
427
+ if (!ref)
428
+ return; // a draft or a pull request: not a ticket
429
+ await this.emit({ kind: 'updated', conversation: GITHUB_FEED, issueId: ref, changed });
430
+ return;
431
+ }
432
+ // ping, installation, repository events and everything else: nothing.
433
+ }
434
+ async emit(event) {
435
+ for (const sink of this.sinks)
436
+ await sink(event);
437
+ }
438
+ // ——— mapping ———
439
+ /** A GitHub issue in neutral words. The lane is the ticket's Status on the
440
+ * board its repository feeds; a repository outside every project has no
441
+ * lane and no delegate, and routing says so rather than inventing one. */
442
+ neutralIssue(issue) {
443
+ const board = this.boardOfRepo(issue.repo)?.board;
444
+ const lane = board === undefined ? undefined : this.client.laneOf(issue, board);
445
+ const agent = board === undefined ? undefined : this.client.agentOn(issue, board);
446
+ return {
447
+ id: issue.ref,
448
+ identifier: issue.ref,
449
+ title: issue.title,
450
+ description: issue.body,
451
+ // GitHub names no branch for a ticket; the tracker generates one (D8).
452
+ branchName: '',
453
+ url: issue.url,
454
+ state: { id: lane?.optionId ?? '', name: lane?.name ?? '', type: '' },
455
+ teamId: issue.repo,
456
+ labels: issue.labels,
457
+ delegateId: agent ?? null,
458
+ assignee: issue.assignees[0] ? { id: issue.assignees[0].id, name: issue.assignees[0].name } : null,
459
+ // GitHub never deletes an issue; a `deleted` delivery says so by the
460
+ // ticket answering nothing, and that is `gone`'s path.
461
+ archived: false,
462
+ completed: issue.state === 'closed',
463
+ blockedBy: issue.blockedBy,
464
+ };
465
+ }
466
+ /** The ticket and status-comment id behind a run conversation, or the
467
+ * plain error that this conversation is not one. */
468
+ runOf(conversation) {
469
+ const session = this.parts(conversation).session;
470
+ const found = RUN_SESSION.exec(session);
471
+ if (!found)
472
+ throw new Error(`${conversation} is not a run conversation (github:<owner>/<repo>#<n>:<comment id>)`);
473
+ return { ref: found[1], commentId: Number(found[2]) };
474
+ }
475
+ /** A ticket conversation's ref, if the conversation is one. */
476
+ ticketOf(conversation) {
477
+ const session = this.parts(conversation).session;
478
+ return TICKET_SESSION.test(session) ? session : undefined;
479
+ }
480
+ }
481
+ /** The neutral changes an `issues` action carries, or none. `assigned` and
482
+ * `unassigned` are **assignees** — a person's word on GitHub, not the
483
+ * delegate, which lives in the board's `Agent` field (D5). */
484
+ export function issueChanges(action) {
485
+ if (action === 'labeled' || action === 'unlabeled')
486
+ return ['labels'];
487
+ if (action === 'closed' || action === 'reopened')
488
+ return ['state'];
489
+ if (action === 'deleted')
490
+ return ['archive'];
491
+ return [];
492
+ }
493
+ /** The public route GitHub is pointed at — no `/app` tail: the seal and the
494
+ * one app make a second path meaningless. */
495
+ export const GITHUB_WEBHOOK_PATH = '/github/webhooks';
496
+ /** Own the route; the platform owns the seal, the dedupe and the words.
497
+ * Failures after the acknowledgement never ride the status: a retry would
498
+ * only replay what already ran. */
499
+ export function registerWebhookRoute(routes, platform, _log = getLogger(['aivi', MODULE_ID])) {
500
+ return routes.register(GITHUB_WEBHOOK_PATH, async (request) => {
501
+ if (request.method !== 'POST')
502
+ return { status: 405, body: { error: 'Use POST' } };
503
+ return platform.deliver(request);
504
+ });
505
+ }
@@ -0,0 +1,102 @@
1
+ /** The parts of GitHub's delivery payloads the tracker reads — all of it
2
+ * **untrusted input**, parsed only after the seal verified. GitHub names the
3
+ * event in the `X-GitHub-Event` header, not in the body, and deduplicates
4
+ * redeliveries by `X-GitHub-Delivery`: the same id may arrive twice, and the
5
+ * second must land as nothing.
6
+ *
7
+ * What deliberately is **not** read: `ping` (the settings page's hello),
8
+ * `installation` events (noise on permission changes, live 2026-10-07),
9
+ * `projects_v2_item` `reordered` (one per board drag, no routing meaning),
10
+ * and `issues.assigned` — an **assignee** is a person's label on GitHub;
11
+ * the delegate lives in the board's `Agent` field (D5).
12
+ */
13
+ /** The delivery headers every GitHub webhook carries. Delivery ids exceed
14
+ * 2^53 (live, 2026-10-07) — they are strings here and nowhere a number. */
15
+ export interface GithubDeliveryHeaders {
16
+ event: string | undefined;
17
+ delivery: string | undefined;
18
+ signature: string | undefined;
19
+ }
20
+ export declare const GITHUB_EVENT_HEADER = "x-github-event";
21
+ export declare const GITHUB_DELIVERY_HEADER = "x-github-delivery";
22
+ /** Who comments may reach aivi (ruled 2026-10-07): `members` is the divider
23
+ * line for a public repository — GitHub's own association words decide it,
24
+ * read per delivery so a promotion or a removal takes effect at once.
25
+ * `everyone` opens the door to any GitHub user. */
26
+ export type GithubTrust = 'members' | 'everyone';
27
+ export declare const trustedAuthor: (association: string | undefined, trust: GithubTrust) => boolean;
28
+ /** The comment author as GitHub answers it: `Bot` is an app, and aivi's own
29
+ * posts — the status comment, closings, questions — are how a run speaks.
30
+ * Inbound, every `Bot` post is filtered: aivi never answers itself. */
31
+ export declare const isBotAuthor: (user: {
32
+ type?: string | null;
33
+ } | undefined | null) => boolean;
34
+ /** An `issue_comment` delivery: a person's message inside a ticket. */
35
+ export interface IssueCommentPayload {
36
+ action: string;
37
+ comment: {
38
+ id: number;
39
+ body: string | null;
40
+ user: {
41
+ login: string;
42
+ type?: string;
43
+ } | null;
44
+ author_association?: string;
45
+ };
46
+ issue: {
47
+ number: number;
48
+ };
49
+ repository: {
50
+ full_name: string;
51
+ };
52
+ }
53
+ /** An `issues` delivery: the ticket itself changed. The number lives inside
54
+ * `issue` — GitHub answers no top-level one (the live ledger says so). */
55
+ export interface IssuesPayload {
56
+ action: string;
57
+ issue?: {
58
+ number: number;
59
+ };
60
+ label?: {
61
+ name: string;
62
+ };
63
+ repository: {
64
+ full_name: string;
65
+ };
66
+ }
67
+ /** A `projects_v2_item` delivery: the board moved the ticket or changed a
68
+ * field on it. Both ids ride inside `projects_v2_item`: the board names
69
+ * itself by `project_node_id`, the ticket by flat `content_node_id` —
70
+ * GitHub posts no top-level board id and no nested content object (the
71
+ * live ledger says so). The ref behind the node id is one lookup away
72
+ * (`client.nodeRef`). */
73
+ export interface ProjectsV2ItemPayload {
74
+ action: string;
75
+ changes?: {
76
+ field_name?: string;
77
+ field_value?: {
78
+ from?: string;
79
+ to?: string;
80
+ field_name?: string;
81
+ };
82
+ };
83
+ projects_v2_item: {
84
+ project_node_id: string;
85
+ content_node_id: string | null;
86
+ } | null;
87
+ }
88
+ /** The field a `projects_v2_item` edit changed, however GitHub spelled the
89
+ * delivery that day: `changes.field_name` per the docs, and the probe
90
+ * (live, 2026-10-07) saw it under `changes.field_value.field_name` too.
91
+ * Both readings, or a silent miss on every lane move. */
92
+ export declare const editedFieldName: (changes: ProjectsV2ItemPayload['changes']) => string | undefined;
93
+ /** Redeliveries by delivery id, bounded. GitHub resends the same id when a
94
+ * reply was lost; the second sight must not run the handler twice. Oldest
95
+ * falls out first — a window of a thousand deliveries is days of traffic. */
96
+ export declare class DeliveryLedger {
97
+ private readonly seen;
98
+ private readonly capacity;
99
+ constructor(capacity?: number);
100
+ /** True the **first** time an id is named; every later sight says nothing. */
101
+ fresh(delivery: string): boolean;
102
+ }
@@ -0,0 +1,52 @@
1
+ /** The parts of GitHub's delivery payloads the tracker reads — all of it
2
+ * **untrusted input**, parsed only after the seal verified. GitHub names the
3
+ * event in the `X-GitHub-Event` header, not in the body, and deduplicates
4
+ * redeliveries by `X-GitHub-Delivery`: the same id may arrive twice, and the
5
+ * second must land as nothing.
6
+ *
7
+ * What deliberately is **not** read: `ping` (the settings page's hello),
8
+ * `installation` events (noise on permission changes, live 2026-10-07),
9
+ * `projects_v2_item` `reordered` (one per board drag, no routing meaning),
10
+ * and `issues.assigned` — an **assignee** is a person's label on GitHub;
11
+ * the delegate lives in the board's `Agent` field (D5).
12
+ */
13
+ export const GITHUB_EVENT_HEADER = 'x-github-event';
14
+ export const GITHUB_DELIVERY_HEADER = 'x-github-delivery';
15
+ /** GitHub's words for who the author is to the repository: OWNER (the org
16
+ * itself), MEMBER (invited to the org) and COLLABORATOR (outside, with
17
+ * repository access) may talk to aivi. CONTRIBUTOR — merely someone with a
18
+ * merged pull request — does not buy free access (ruled 2026-10-07), and
19
+ * neither does NONE. A missing association fails closed: GitHub always
20
+ * sends it, so its absence is a payload aivi does not know. */
21
+ const MEMBER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
22
+ export const trustedAuthor = (association, trust) => trust === 'everyone' || (association !== undefined && MEMBER_ASSOCIATIONS.has(association));
23
+ /** The comment author as GitHub answers it: `Bot` is an app, and aivi's own
24
+ * posts — the status comment, closings, questions — are how a run speaks.
25
+ * Inbound, every `Bot` post is filtered: aivi never answers itself. */
26
+ export const isBotAuthor = (user) => user?.type === 'Bot';
27
+ /** The field a `projects_v2_item` edit changed, however GitHub spelled the
28
+ * delivery that day: `changes.field_name` per the docs, and the probe
29
+ * (live, 2026-10-07) saw it under `changes.field_value.field_name` too.
30
+ * Both readings, or a silent miss on every lane move. */
31
+ export const editedFieldName = (changes) => changes?.field_name ?? changes?.field_value?.field_name;
32
+ /** Redeliveries by delivery id, bounded. GitHub resends the same id when a
33
+ * reply was lost; the second sight must not run the handler twice. Oldest
34
+ * falls out first — a window of a thousand deliveries is days of traffic. */
35
+ export class DeliveryLedger {
36
+ seen = new Set();
37
+ capacity;
38
+ constructor(capacity = 1024) {
39
+ this.capacity = capacity;
40
+ }
41
+ /** True the **first** time an id is named; every later sight says nothing. */
42
+ fresh(delivery) {
43
+ if (this.seen.has(delivery))
44
+ return false;
45
+ this.seen.add(delivery);
46
+ if (this.seen.size > this.capacity) {
47
+ const oldest = this.seen.values().next().value;
48
+ this.seen.delete(oldest);
49
+ }
50
+ return true;
51
+ }
52
+ }