@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,638 @@
1
+ /** The GitHub tracker's project-setup contributor, at its `./setupProject`
2
+ * subpath: it maps a new project to one projectsV2 board and the repositories
3
+ * whose issues feed it, and offers the project's lane array — the board's
4
+ * `Status` options in board order, each asked what kind of lane it is:
5
+ * Agent, Human or None — GitHub gives no state flag to guess from. It
6
+ * clones nothing (a tracker has no checkout to give): it hands back the
7
+ * `tracker-github`
8
+ * section core writes under the project, and the **core** `lanes` array core
9
+ * writes on the project. Everything GitHub-specific — the board, its fields,
10
+ * the human label — lives here; core only knows this plugin serves the
11
+ * `tracker` role.
12
+ *
13
+ * The board is never enumerated: listing an org's boards needs admin rights
14
+ * the installation does not have (and must not). The person pastes the number
15
+ * from the board's URL and the app proves it can read that board — a board it
16
+ * cannot read is said with the permission that unlocks it. The `Agent` field
17
+ * is aivi's own surface (D5: apps cannot be assignees, proven 403): when the
18
+ * board lacks one, creating it is **asked** — a person's board changes only on
19
+ * a yes — and the field is then appended to every view that does not show it,
20
+ * operators included. The Status options aivi never creates or renames (D4).
21
+ *
22
+ * A live aivi is asked as a choice, never presumed: silence offers "I started
23
+ * the server. Retry" or "Continue anyway", and continuing writes the mapping
24
+ * with the webhook's unproven state said. When one was there to watch, the
25
+ * wizard ends with the board probe: a test card pays the tracker's every
26
+ * mutation — issue, board add, Status move, Agent set, comment, reaction —
27
+ * the diary must echo each delivery back seal-verified (the reaction aside:
28
+ * paid and read back, no delivery subscribes), then cleanup says how the
29
+ * card rests in GitHub's real terms. */
30
+ import { readFile } from 'node:fs/promises';
31
+ import { errorMessage, getLogger } from '@aivi/core';
32
+ import { GITHUB_WEBHOOK_SECRET_ENV, githubApp } from '@aivi/github';
33
+ import { LANE_KIND, laneKindLines, PluginSetupCancelled, } from '@aivi/plugin';
34
+ import { GithubClient } from './client.js';
35
+ import { AGENT_FIELD, githubSchema, STATUS_FIELD } from './config.js';
36
+ import { aboutIssue, boardProbeVerdict, gateHost, hostIsUp, latestId, probeWindowMs, UNVERIFIED, waitForSignals, } from './probe.js';
37
+ import { GITHUB_WEBHOOK_PATH } from './tracker.js';
38
+ const log = getLogger(['aivi', 'projects']);
39
+ const BOARD_SETUP = /* GraphQL */ `
40
+ query BoardSetup($owner: String!, $number: Int!) {
41
+ organization(login: $owner) {
42
+ id
43
+ projectV2(number: $number) {
44
+ id
45
+ title
46
+ fields(first: 60) { nodes {
47
+ ... on ProjectV2SingleSelectField { id name options { id name } }
48
+ } }
49
+ }
50
+ }
51
+ }
52
+ `;
53
+ const CREATE_AGENT_FIELD = /* GraphQL */ `
54
+ mutation CreateAgentField($ownerId: ID!, $option: String!) {
55
+ createProjectV2Field(
56
+ input: { projectOwnerId: $ownerId, dataType: SINGLE_SELECT, name: "Agent", singleSelectOptions: [{ name: $option }] }
57
+ ) { projectV2Field { ... on ProjectV2SingleSelectField { id name } } }
58
+ }
59
+ `;
60
+ /** The views and the fields each shows: the `fields` connection of a view is
61
+ * its **visible** fields, and (live, 2026-10-07) its nodes carry the field's
62
+ * own id under `ProjectV2FieldCommon` — the same ids `visibleFieldIds`
63
+ * takes back. */
64
+ const BOARD_VIEWS = /* GraphQL */ `
65
+ query BoardViews($owner: String!, $number: Int!) {
66
+ organization(login: $owner) {
67
+ projectV2(number: $number) {
68
+ views(first: 20) { nodes {
69
+ id name
70
+ fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id } } }
71
+ } }
72
+ }
73
+ }
74
+ }
75
+ `;
76
+ /** `configuration` **replaces**, never appends: the visible list is read
77
+ * first and written back with the Agent field added (live, 2026-10-07 —
78
+ * `UpdateProjectV2ViewInput` takes no `projectId`). */
79
+ const SHOW_AGENT_FIELD = /* GraphQL */ `
80
+ mutation ShowAgentField($viewId: ID!, $visibleFieldIds: [ID!]!) {
81
+ updateProjectV2View(input: { id: $viewId, configuration: { visibleFieldIds: $visibleFieldIds } }) {
82
+ projectV2View { id }
83
+ }
84
+ }
85
+ `;
86
+ /** The probe pays the tracker's own mutations directly — not through the
87
+ * module's client, whose caches and resolutions are exactly what the probe
88
+ * is there to prove. */
89
+ const PROBE_ADD_ITEM = /* GraphQL */ `
90
+ mutation ProbeAddItem($projectId: ID!, $contentId: ID!) {
91
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
92
+ }
93
+ `;
94
+ const PROBE_SET_SELECT = /* GraphQL */ `
95
+ mutation ProbeSetSelect($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
96
+ updateProjectV2ItemFieldValue(
97
+ input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }
98
+ ) { projectV2Item { id } }
99
+ }
100
+ `;
101
+ const PROBE_CLEAR_SELECT = /* GraphQL */ `
102
+ mutation ProbeClearSelect($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
103
+ clearProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId }) {
104
+ projectV2Item { id }
105
+ }
106
+ }
107
+ `;
108
+ const PROBE_ARCHIVE_ITEM = /* GraphQL */ `
109
+ mutation ProbeArchiveItem($projectId: ID!, $itemId: ID!) {
110
+ archiveProjectV2Item(input: { projectId: $projectId, itemId: $itemId }) { item { id } }
111
+ }
112
+ `;
113
+ /** The production crossing: the home's app (D3 — the wizard and the module
114
+ * speak through one credential), read through the tracker's own client.
115
+ * Exported for the test that scripts GitHub's answers and proves the probe's
116
+ * own choices — which Status option it pays, the reaction it reads back, and
117
+ * whose close it reports. */
118
+ export class BoardSetupClient {
119
+ app;
120
+ tracker;
121
+ constructor(app) {
122
+ this.app = app;
123
+ this.tracker = new GithubClient(app);
124
+ }
125
+ get delegate() {
126
+ return this.app.appSlug;
127
+ }
128
+ async readBoard(boardNumber) {
129
+ const { organization } = await this.app.octokit.graphql(BOARD_SETUP, {
130
+ owner: this.app.accountLogin,
131
+ number: boardNumber,
132
+ });
133
+ if (!organization)
134
+ throw new Error(`${this.app.accountLogin} answers nothing for the app: the installation is not on that organization`);
135
+ const project = organization.projectV2;
136
+ if (!project)
137
+ throw new Error(`board ${boardNumber} is not visible to the app in ${this.app.accountLogin}: check the number at the end of the board URL, and that the installation has “Organization projects: Read & write”`);
138
+ let status;
139
+ let agent;
140
+ for (const field of project.fields?.nodes ?? [])
141
+ if (field?.name === STATUS_FIELD)
142
+ status = field;
143
+ else if (field?.name === AGENT_FIELD)
144
+ agent = field;
145
+ if (!status)
146
+ throw new Error(`board “${project.title}” has no “${STATUS_FIELD}” single-select field — lanes map onto its options and aivi never creates one (add the field on the board, or pick the board that has it)`);
147
+ return {
148
+ id: project.id,
149
+ number: boardNumber,
150
+ orgId: organization.id,
151
+ title: project.title,
152
+ statusOptions: (status.options ?? []).map(o => ({ id: o.id, name: o.name })),
153
+ statusFieldId: status.id,
154
+ ...(agent ? { agentFieldId: agent.id } : {}),
155
+ agentOptions: (agent?.options ?? []).map(o => ({ id: o.id, name: o.name })),
156
+ };
157
+ }
158
+ async createAgentField(board, delegate) {
159
+ await this.app.octokit.graphql(CREATE_AGENT_FIELD, { ownerId: board.orgId, option: delegate });
160
+ }
161
+ async showAgentOnViews(board, agentFieldId) {
162
+ const { organization } = await this.app.octokit.graphql(BOARD_VIEWS, {
163
+ owner: this.app.accountLogin,
164
+ number: board.number,
165
+ });
166
+ const shown = [];
167
+ for (const view of organization?.projectV2?.views?.nodes ?? []) {
168
+ if (!view)
169
+ continue;
170
+ const ids = (view.fields?.nodes ?? [])
171
+ .map(field => field?.id)
172
+ .filter((id) => typeof id === 'string');
173
+ if (ids.includes(agentFieldId))
174
+ continue; // already visible: idempotent, write nothing
175
+ await this.app.octokit.graphql(SHOW_AGENT_FIELD, { viewId: view.id, visibleFieldIds: [...ids, agentFieldId] });
176
+ shown.push(String(view.name ?? view.id));
177
+ }
178
+ return shown;
179
+ }
180
+ repositories() {
181
+ return this.app.repositories();
182
+ }
183
+ ensureLabel(repo, label) {
184
+ return this.tracker.ensureLabel(repo, label);
185
+ }
186
+ async openProbe(repo, board) {
187
+ if (!board.agentFieldId)
188
+ throw new Error('the board has no Agent field: the wizard creates one before the probe, never the probe');
189
+ const issue = await this.tracker.createIssue(repo, {
190
+ title: 'aivi projects add test — safe to ignore',
191
+ body: 'A test card from `aivi projects add`: it proves GitHub’s deliveries reach this aivi. Closed and archived when the test ends.',
192
+ });
193
+ const { addProjectV2ItemById } = await this.app.octokit.graphql(PROBE_ADD_ITEM, {
194
+ projectId: board.id,
195
+ contentId: issue.id,
196
+ });
197
+ const itemId = addProjectV2ItemById?.item?.id ?? '';
198
+ const asked = ['issue opened', 'card added', 'Agent set', 'comment posted'];
199
+ // Status: the next option in board order the card is not already on —
200
+ // there is no open/closed notion, GitHub has none. A mutation that
201
+ // changes nothing posts nothing, and a signal nobody paid for cannot
202
+ // land. A board's workflow may be wired to the
203
+ // picked option and close the test card — accepted: the probe reports
204
+ // what GitHub answers, not what aivi meant. A board with a single
205
+ // Status option cannot show a move; skipping is not failing.
206
+ const landing = await this.tracker.laneOn(issue.ref, board.number);
207
+ const target = board.statusOptions.find(option => option.name !== landing?.name);
208
+ let statusSkipped = true;
209
+ if (target) {
210
+ await this.app.octokit.graphql(PROBE_SET_SELECT, {
211
+ projectId: board.id,
212
+ itemId,
213
+ fieldId: board.statusFieldId,
214
+ optionId: target.id,
215
+ });
216
+ asked.push('Status moved');
217
+ statusSkipped = false;
218
+ }
219
+ const agentOption = board.agentOptions.find(option => option.name === this.delegate);
220
+ if (!agentOption)
221
+ throw new Error(`the “${AGENT_FIELD}” field on board “${board.title}” has no option “${this.delegate}” — the app cannot be delegated there: add the option (or recreate the field) and run this again`);
222
+ await this.app.octokit.graphql(PROBE_SET_SELECT, {
223
+ projectId: board.id,
224
+ itemId,
225
+ fieldId: board.agentFieldId,
226
+ optionId: agentOption.id,
227
+ });
228
+ const comment = await this.tracker.createComment(issue.ref, 'aivi projects add test — safe to ignore.');
229
+ // The reaction surface, paid and read back. It is **not a signal**:
230
+ // GitHub posts `reaction` events only to subscribers, and aivi
231
+ // subscribes to issue comment, issues and
232
+ // projects v2 item — there is no delivery to wait for. Paying and
233
+ // reading back proves the permission the tracker's status surface
234
+ // (eyes, rocket) needs; cleanup takes the reaction off.
235
+ await this.tracker.addReaction(issue.ref, 'eyes');
236
+ const slash = issue.ref.indexOf('/');
237
+ const hash = issue.ref.indexOf('#');
238
+ const { data: reactions } = await this.app.octokit.rest.reactions.listForIssue({
239
+ owner: issue.ref.slice(0, slash),
240
+ repo: issue.ref.slice(slash + 1, hash),
241
+ issue_number: issue.number,
242
+ content: 'eyes',
243
+ per_page: 100,
244
+ });
245
+ if (!reactions.some(reaction => reaction.user?.login === this.tracker.botLogin))
246
+ throw new Error(`the eyes reaction did not land on ${issue.ref}: the app cannot react — check its repository permissions, then run this again`);
247
+ return {
248
+ ref: issue.ref,
249
+ number: issue.number,
250
+ nodeId: issue.id,
251
+ itemId,
252
+ projectId: board.id,
253
+ agentFieldId: board.agentFieldId,
254
+ commentId: comment.id,
255
+ asked,
256
+ statusSkipped,
257
+ };
258
+ }
259
+ async closeProbe(handles) {
260
+ await this.app.octokit.graphql(PROBE_CLEAR_SELECT, {
261
+ projectId: handles.projectId,
262
+ itemId: handles.itemId,
263
+ fieldId: handles.agentFieldId,
264
+ });
265
+ await this.app.octokit.graphql(PROBE_ARCHIVE_ITEM, { projectId: handles.projectId, itemId: handles.itemId });
266
+ await this.tracker.removeOwnReaction(handles.ref, 'eyes');
267
+ const [owner = '', rest = ''] = handles.ref.split('/');
268
+ const repo = rest.split('#')[0] ?? '';
269
+ await this.tracker.deleteComment(handles.ref, handles.commentId);
270
+ // Report GitHub's answer, not aivi's intent: a workflow wired to the
271
+ // Status option the probe picked may have closed the card already —
272
+ // that is the operator's board speaking, and the probe says so instead
273
+ // of claiming its own close.
274
+ const { data: issue } = await this.app.octokit.rest.issues.get({ owner, repo, issue_number: handles.number });
275
+ if (issue.state === 'closed')
276
+ return `${handles.ref} rests closed by GitHub itself (${issue.state_reason ?? 'closed'}) — a workflow on the board closed the test card; aivi leaves it as GitHub left it`;
277
+ await this.app.octokit.rest.issues.update({
278
+ owner,
279
+ repo,
280
+ issue_number: handles.number,
281
+ state: 'closed',
282
+ state_reason: 'not_planned',
283
+ });
284
+ return `${handles.ref} is closed (not planned), its card archived, its comment deleted — GitHub has no API to delete an issue`;
285
+ }
286
+ }
287
+ const productionClient = async (setupLog) => new BoardSetupClient(await githubApp({ log: setupLog }));
288
+ /** The lanes the queue question may offer (ruled 2026-10-02, mirrored from
289
+ * Linear's wizard): a queue lane must feed a worker lane, so a lane whose next
290
+ * works nobody is no candidate, the last lane feeds nothing at all, and a lane
291
+ * that has an agent is never an option — a queue is where people wait for a
292
+ * worker, not a worker lane itself. A **human** lane is a legal queue: that is
293
+ * what "used as a queue" means in the answer's own words. */
294
+ export function queueCandidates(lanes) {
295
+ return lanes.filter(lane => lane.agent === undefined && lanes[lanes.indexOf(lane) + 1]?.agent !== undefined);
296
+ }
297
+ /** Mark the chosen queue lane. The queue answer is the wizard's later word, so
298
+ * a lane that just chose an agent gives it up — and says so out loud. The
299
+ * returned lines are what the caller logs. */
300
+ export function markQueue(lanes, chosen) {
301
+ const lane = lanes.find(l => l.name === chosen);
302
+ if (!lane)
303
+ return [];
304
+ const said = [];
305
+ if (lane.agent !== undefined) {
306
+ delete lane.agent;
307
+ said.push(`"${chosen}" works nobody then — it waits work instead`);
308
+ }
309
+ delete lane.worktree;
310
+ lane.queue = true;
311
+ return said;
312
+ }
313
+ /** The project name a board title suggests: GitHub's `My Board` becomes aivi's
314
+ * `my-board`, the shape a project directory takes (the forge's rule for repo
315
+ * names, turned on a title). Anything the id rule does not allow becomes a
316
+ * dash, and a name that no longer starts with a letter keeps one. */
317
+ export function boardSuggestion(title) {
318
+ const folded = title.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
319
+ return /^[a-z]/.test(folded) ? folded : `p-${folded}`;
320
+ }
321
+ /** One prompt per lane: who works it. "-- None --" leaves a human lane: the
322
+ * orchestrator's silence, exactly as configured. The worktree question is the
323
+ * forge's branch question, word for word, so both wizards read alike. */
324
+ async function askLanes(ctx, options, agentNames) {
325
+ const lanes = [];
326
+ let explained = false;
327
+ for (const option of options) {
328
+ // One log entry before the first question, never repeated per option.
329
+ // GitHub gives no state flag and no workflow rule, so the person
330
+ // decides — the note says why.
331
+ if (!explained) {
332
+ explained = true;
333
+ await ctx.prompts.log.message(laneKindLines('Aivi cannot tell what a board option means: GitHub keeps that in the board’s workflows, and their rules are not readable. You decide what each option is.').join('\n'));
334
+ }
335
+ const picked = await ctx.prompts.select({
336
+ message: `What kind of lane is the "${option.name}" option?`,
337
+ options: [LANE_KIND.agent, LANE_KIND.human, LANE_KIND.none],
338
+ });
339
+ if (ctx.prompts.isCancel(picked))
340
+ throw new PluginSetupCancelled('lane setup incomplete');
341
+ const kind = String(picked);
342
+ // "None": the option stays out of the array — aivi never reads it and
343
+ // never moves a ticket into it.
344
+ if (kind === LANE_KIND.none.value)
345
+ continue;
346
+ let agent = '';
347
+ let worktree = false;
348
+ if (kind === LANE_KIND.agent.value) {
349
+ const answer = await ctx.prompts.autocomplete({
350
+ message: `Which OpenCode agent works the "${option.name}" lane?`,
351
+ options: agentNames.map(name => ({ value: name, label: name })),
352
+ maxItems: 12,
353
+ });
354
+ if (ctx.prompts.isCancel(answer))
355
+ throw new PluginSetupCancelled('lane setup incomplete');
356
+ agent = String(answer).trim();
357
+ if (agent && ctx.forgeConfigured) {
358
+ const onBranch = await ctx.prompts.select({
359
+ message: `Does work in the "${option.name}" lane happen on the ticket's branch (git worktree)?`,
360
+ options: [
361
+ { value: 'yes', label: "yes — on the ticket's branch, in its own worktree" },
362
+ { value: 'no', label: 'no — in the project checkout itself' },
363
+ ],
364
+ });
365
+ if (ctx.prompts.isCancel(onBranch))
366
+ throw new PluginSetupCancelled('lane setup incomplete');
367
+ worktree = onBranch === 'yes';
368
+ }
369
+ }
370
+ // "Human": a bare lane. The orchestrator works nobody here; a finished
371
+ // run may still move a ticket in, and after that aivi never touches it.
372
+ lanes.push({ name: option.name, ...(agent ? { agent } : {}), ...(worktree ? { worktree } : {}) });
373
+ }
374
+ return lanes;
375
+ }
376
+ /** The queue: ONE question for the whole workflow (ruled 2026-10-02, after
377
+ * every lane is configured — never a per-lane ask). "-- None --" leaves the
378
+ * workflow without a queue lane, which is the old shape. */
379
+ async function askQueue(ctx, lanes) {
380
+ const candidates = queueCandidates(lanes);
381
+ if (candidates.length) {
382
+ const answer = await ctx.prompts.select({
383
+ message: 'Which lane waits with work while the working lanes are full (the queue)?',
384
+ options: [
385
+ ...candidates.map(lane => ({ value: lane.name, label: lane.name })),
386
+ { value: '', label: '-- None --' },
387
+ ],
388
+ });
389
+ if (ctx.prompts.isCancel(answer))
390
+ throw new PluginSetupCancelled('lane setup incomplete');
391
+ const chosen = String(answer).trim();
392
+ for (const line of chosen ? markQueue(lanes, chosen) : [])
393
+ await ctx.prompts.log.message(line);
394
+ }
395
+ else if (lanes.length)
396
+ await ctx.prompts.log.message('no lane sits before a working lane, so nothing can wait for a slot — a queue lane comes later with a worker lane');
397
+ }
398
+ /** The board the wizard reads before anything else: the number is typed (the
399
+ * pick-list would need admin rights the grant does not have), the read is the
400
+ * proof. A refusal ends the setup — a wizard that went on after a board it
401
+ * cannot read would be writing blind. */
402
+ async function askedBoard(ctx, client) {
403
+ const answer = await ctx.prompts.text({
404
+ message: 'The board number — the number at the end of the board URL (github.com/orgs/<org>/projects/1 is board 1)',
405
+ // Enter takes board 1. clack 1.x runs `validate` on the raw input
406
+ // **before** `finalize` applies `defaultValue`, so a validate that
407
+ // refuses '' makes the default dead code. The empty answer passes
408
+ // validation here, the default is folded in below where it takes
409
+ // effect, and the placeholder shows what Enter will take.
410
+ placeholder: '1',
411
+ defaultValue: '1',
412
+ validate: value => value === undefined || !value.trim() || /^[1-9]\d*$/.test(value.trim())
413
+ ? undefined
414
+ : 'the number at the end of the board URL',
415
+ });
416
+ if (ctx.prompts.isCancel(answer))
417
+ throw new PluginSetupCancelled('no board given');
418
+ const boardNumber = Number(String(answer).trim() || '1');
419
+ const spinner = ctx.prompts.spinner();
420
+ spinner.start(`Reading board ${boardNumber}`);
421
+ try {
422
+ const board = await client.readBoard(boardNumber);
423
+ spinner.stop(`Board “${board.title}” has ${board.statusOptions.length} Status options`);
424
+ return board;
425
+ }
426
+ catch (error) {
427
+ const message = error instanceof Error ? error.message : String(error);
428
+ spinner.error(message);
429
+ throw error;
430
+ }
431
+ }
432
+ /** The repository whose issues this project works. The board's deliveries
433
+ * name the board themselves; comments and labels — the ticket's own
434
+ * deliveries — name only the repository, so this list is what claims
435
+ * them for the project. When a forge settled one, that answer is offered
436
+ * first and picking it asks the app nothing; otherwise the choices are
437
+ * the real grant, never typing. */
438
+ async function pickRepos(ctx, client) {
439
+ if (ctx.forgeRepository) {
440
+ const answer = await ctx.prompts.select({
441
+ message: 'Which repository holds the issues?',
442
+ options: [
443
+ { value: 'same', label: `Same as forge: ${ctx.forgeRepository}` },
444
+ { value: 'other', label: 'choose a different repository' },
445
+ ],
446
+ });
447
+ if (ctx.prompts.isCancel(answer))
448
+ throw new PluginSetupCancelled('no answer about the repository');
449
+ if (answer === 'same')
450
+ return [ctx.forgeRepository];
451
+ }
452
+ const spinner = ctx.prompts.spinner();
453
+ spinner.start('Asking the app which repositories it can see');
454
+ let granted;
455
+ try {
456
+ granted = await client.repositories();
457
+ spinner.stop(`The app can see ${granted.length} repositor${granted.length === 1 ? 'y' : 'ies'}`);
458
+ }
459
+ catch (error) {
460
+ spinner.error(error instanceof Error ? error.message : String(error));
461
+ throw error;
462
+ }
463
+ if (!granted.length)
464
+ throw new Error('that installation holds no repositories: select the repositories you want to use aivi in on the installation page');
465
+ const picked = await ctx.prompts.multiselect({
466
+ message: 'Which repositories hold the issues?',
467
+ options: granted.map(repo => ({ value: repo.id, label: repo.id })),
468
+ required: true,
469
+ });
470
+ if (ctx.prompts.isCancel(picked))
471
+ throw new PluginSetupCancelled('no repositories picked');
472
+ return picked;
473
+ }
474
+ /** The delegate surface (D5): when the board has no `Agent` field, creating one
475
+ * is asked — a person's board changes only on a yes — and the no is a decision
476
+ * the tracker cannot work past. Answers the field's id, read back from the
477
+ * board rather than guessed: the probe pays real mutations and needs real ids. */
478
+ async function ensureAgentField(ctx, client, board) {
479
+ if (board.agentFieldId)
480
+ return board.agentFieldId;
481
+ const yes = await ctx.prompts.confirm({
482
+ message: `Create the “${AGENT_FIELD}” single-select field on board “${board.title}”, with the option “${client.delegate}”? Apps cannot be assignees on GitHub — this field is how aivi gets delegated.`,
483
+ });
484
+ if (ctx.prompts.isCancel(yes))
485
+ throw new PluginSetupCancelled('no answer about the Agent field');
486
+ if (!yes)
487
+ throw new Error(`board “${board.title}” has no “${AGENT_FIELD}” field and creating one was refused — the tracker cannot be delegated without it`);
488
+ await client.createAgentField(board, client.delegate);
489
+ Object.assign(board, await client.readBoard(board.number));
490
+ if (!board.agentFieldId)
491
+ throw new Error('the “Agent” field was created but the board still answers none — reload the board and run this again');
492
+ await ctx.prompts.log.message(`Created “${AGENT_FIELD}” on board “${board.title}” with the option “${client.delegate}”.`);
493
+ return board.agentFieldId;
494
+ }
495
+ /** The board probe (ruled 2026-10-07): one test card pays every mutation the
496
+ * tracker will ever make — open, board add, Status move to the next option
497
+ * in board order, Agent set, comment, reaction (paid and read back: no
498
+ * delivery subscribes) — and the diary must echo each delivery back,
499
+ * seal-verified. Then cleanup answers one line: how the card rests, in
500
+ * GitHub's real terms — a workflow's close is said as GitHub's, aivi's own
501
+ * close as aivi's, and the truth stands: GitHub has no API to delete an
502
+ * issue.
503
+ *
504
+ * A missing signal is a verdict, not a throw — the mapping on disk is right;
505
+ * what is missing is GitHub's reach, and the line names what to check. A bad
506
+ * seal throws: a secret that does not match GitHub's breaks every delivery,
507
+ * so the wizard stops and says the remedy. */
508
+ async function probeBoard(ctx, client, board, repo) {
509
+ const secret = process.env[GITHUB_WEBHOOK_SECRET_ENV];
510
+ if (!secret)
511
+ throw new Error(`no ${GITHUB_WEBHOOK_SECRET_ENV} in the home’s .env — run \`aivi add @aivi/tracker-github\` before mapping a project onto the board`);
512
+ const spinner = ctx.prompts.spinner();
513
+ spinner.start(`Board probe in ${repo}: one test card, then GitHub’s answer`);
514
+ // Past this line every throw must stop the spinner first: a spinner left
515
+ // turning when the runner prints its "Setup stopped" line looks like the
516
+ // run is still working. The card may exist before the failure (the probe
517
+ // pays its mutations one by one), so the failure names what to clean by
518
+ // hand when a handle exists, and says a card may stand when one does not.
519
+ let probe;
520
+ try {
521
+ // The diary mark is taken before the card exists: every delivery about
522
+ // it lands above the mark, and everything below is other people's
523
+ // history — never read, never parsed.
524
+ const mark = await ctx.withStore(store => latestId(store));
525
+ probe = await client.openProbe(repo, board);
526
+ if (probe.statusSkipped)
527
+ await ctx.prompts.log.message('the board has a single Status option — a move cannot be shown, so the Status signal is skipped, not failed');
528
+ await ctx.prompts.log.message('the reaction surface answered too: aivi’s eyes landed on the card and cleanup will clear it — reaction events are not subscribed, so there is no delivery to wait for');
529
+ spinner.message(`${probe.ref} is on the board — waiting for GitHub’s deliveries`);
530
+ const windowMs = probeWindowMs();
531
+ const { landed } = await ctx.withStore(store => waitForSignals(store, {
532
+ path: GITHUB_WEBHOOK_PATH,
533
+ secret,
534
+ windowMs,
535
+ asked: probe.asked,
536
+ about: aboutIssue(repo, probe.number, probe.nodeId),
537
+ }, mark, line => spinner.message(line)));
538
+ const verdict = boardProbeVerdict(landed, probe.asked, windowMs);
539
+ let rest = '';
540
+ try {
541
+ rest = await client.closeProbe(probe);
542
+ }
543
+ catch (error) {
544
+ rest = `the test card could not be cleaned up: ${errorMessage(error)} — close it by hand: ${probe.ref}`;
545
+ }
546
+ if (verdict.pass)
547
+ spinner.stop(`the board speaks — ${verdict.detail}`);
548
+ else
549
+ spinner.cancel(`board probe — ${verdict.detail}`);
550
+ await ctx.prompts.log.message(rest);
551
+ }
552
+ catch (error) {
553
+ spinner.error(probe
554
+ ? `the probe could not finish: ${errorMessage(error)} — the test card ${probe.ref} stands on the board; close it by hand`
555
+ : `the probe could not start: ${errorMessage(error)} — if a test card was already created it stands on the board; close it by hand`);
556
+ throw error;
557
+ }
558
+ }
559
+ /** The wizard, in its order: the health gate as a choice, the board, the
560
+ * delegate surface and its view visibility, the repositories, the project's
561
+ * name, what kind of lane each Status option is (agent, human, or none),
562
+ * the queue over the no-agent lanes,
563
+ * the human label in every mapped repository — and, when a live aivi was
564
+ * there to watch, the board probe last: it pays real mutations, so it comes
565
+ * after everything it tests. Each step is its own question and its own
566
+ * honest failure; the whole is the ruling. */
567
+ function makeContributor(makeClient = productionClient) {
568
+ return {
569
+ role: 'tracker',
570
+ async setup(ctx) {
571
+ const config = githubSchema.parse(ctx.config);
572
+ // The gate asks the live aivi, and its URL lives in config.json's host
573
+ // block — not in the plugin's block the context carries.
574
+ const verify = await gateHost(ctx.prompts, async () => {
575
+ try {
576
+ const raw = JSON.parse(await readFile(ctx.configPath, 'utf8'));
577
+ return hostIsUp(ctx.fetch, raw.host);
578
+ }
579
+ catch {
580
+ return false; // no readable config.json answers nothing: the gate says so as silence
581
+ }
582
+ });
583
+ const client = await makeClient(log);
584
+ const board = await askedBoard(ctx, client);
585
+ const agentFieldId = await ensureAgentField(ctx, client, board);
586
+ const shown = await client.showAgentOnViews(board, agentFieldId);
587
+ await ctx.prompts.log.message(shown.length
588
+ ? `“${AGENT_FIELD}” now shows on: ${shown.join(', ')}.`
589
+ : `“${AGENT_FIELD}” already shows on the board’s views.`);
590
+ const repos = await pickRepos(ctx, client);
591
+ // The name: a tracker offers the board's title, folded, unless an earlier
592
+ // role (a forge) already settled it. A defaultValue, not just a
593
+ // placeholder: Enter takes it *and says so*.
594
+ const suggested = (ctx.projectId ?? boardSuggestion(board.title)).toLowerCase();
595
+ const idAnswer = await ctx.prompts.text({
596
+ message: "Project id — aivi's name for it; GitHub never sees it.",
597
+ placeholder: suggested,
598
+ defaultValue: suggested,
599
+ });
600
+ if (ctx.prompts.isCancel(idAnswer))
601
+ throw new PluginSetupCancelled('no project id given');
602
+ const id = (String(idAnswer).trim() || suggested).toLowerCase();
603
+ // Lanes: core's ordered workflow (`projects.<id>.lanes`), and the board
604
+ // is the order — Status options arrive in board order already.
605
+ const agentNames = await ctx.agents(id);
606
+ if (!agentNames.length)
607
+ throw new Error('Unable to configure lanes: there appear to be no agents available.');
608
+ const lanes = await askLanes(ctx, board.statusOptions, agentNames);
609
+ await askQueue(ctx, lanes);
610
+ if (!lanes.length)
611
+ await ctx.prompts.log.message('no lanes were written — the board had no Status options, or every option was answered “None” — so aivi works no tickets until lanes exist');
612
+ // The human label: unlike Linear, where it must pre-exist, GitHub
613
+ // repositories get it here — a failed closing and a hand-delegation
614
+ // refusal mark with it, and it must be there before the first failure.
615
+ for (const repo of repos) {
616
+ try {
617
+ await client.ensureLabel(repo, config.humanLabel);
618
+ }
619
+ catch (error) {
620
+ throw new Error(`label “${config.humanLabel}” could not be created in ${repo}: ${error instanceof Error ? error.message : String(error)} — the app needs Issues: Read & write on that repository`);
621
+ }
622
+ }
623
+ await ctx.prompts.log.message(`“${config.humanLabel}” is ready in ${repos.join(', ')}.`);
624
+ // The probe last: it pays real mutations and needs the whole mapping in
625
+ // place to mean anything. Without a live aivi there is no diary to
626
+ // watch, and the unverified fact is said in the same words the `aivi
627
+ // add` installer uses.
628
+ if (verify)
629
+ await probeBoard(ctx, client, board, repos[0]);
630
+ else
631
+ await ctx.prompts.log.message(UNVERIFIED);
632
+ return { id, section: { board: board.number, repos }, ...(lanes.length ? { lanes } : {}), cloned: false };
633
+ },
634
+ };
635
+ }
636
+ export const contributor = makeContributor();
637
+ export { makeContributor };
638
+ export default contributor;
@@ -0,0 +1,3 @@
1
+ import { type PluginSetup } from '@aivi/plugin';
2
+ declare const setup: PluginSetup;
3
+ export default setup;