@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.
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +247 -0
- package/dist/client.js +528 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +99 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +67 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +10 -0
- package/dist/links.d.ts +56 -0
- package/dist/links.js +94 -0
- package/dist/module.d.ts +36 -0
- package/dist/module.js +817 -0
- package/dist/probe.d.ts +104 -0
- package/dist/probe.js +312 -0
- package/dist/projects.d.ts +41 -0
- package/dist/projects.js +117 -0
- package/dist/runprogress.d.ts +62 -0
- package/dist/runprogress.js +122 -0
- package/dist/setup-project.d.ts +125 -0
- package/dist/setup-project.js +638 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +262 -0
- package/dist/tracker.d.ts +174 -0
- package/dist/tracker.js +505 -0
- package/dist/webhook.d.ts +102 -0
- package/dist/webhook.js +52 -0
- package/dist/work.d.ts +60 -0
- package/dist/work.js +83 -0
- package/docs/github-tracker.md +122 -0
- package/package.json +59 -4
- package/README.md +0 -3
|
@@ -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;
|
package/dist/setup.d.ts
ADDED