@aivi/tracker-github 0.0.0-stage → 1.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/client.js ADDED
@@ -0,0 +1,528 @@
1
+ /** Everything GitHub-shaped that the tracker asks of the API: the board
2
+ * reads and writes (Status and the `Agent` field), the issue facts, the
3
+ * comment trail, labels and reactions. Authentication is `@aivi/github`'s —
4
+ * one app, one installation, octokit's cached installation token — this file
5
+ * is only the queries and what they answer.
6
+ *
7
+ * The issue id in aivi's hands is `owner/repo#47`, the words a person
8
+ * types; the GraphQL node ids live here and nowhere above. Reads that lag
9
+ * their writes (live, 2026-10-07: a re-read right after a mutation answered
10
+ * the old value) are not trusted — the mutation's own answer is the truth,
11
+ * and the board's own webhook is the catch-up.
12
+ */
13
+ import { GITHUB_WEBHOOK_SECRET_ENV, githubApp } from '@aivi/github';
14
+ import { ConfigurationError } from '@aivi/plugin';
15
+ import { AGENT_FIELD, STATUS_FIELD } from './config.js';
16
+ const REPO_ISSUE = /^([^/]+)\/([^/#]+)#(\d+)$/;
17
+ export function issueRef(id) {
18
+ const found = REPO_ISSUE.exec(id.trim());
19
+ if (!found)
20
+ throw new Error(`"${id}" is not a GitHub issue id (owner/repo#number)`);
21
+ return { owner: found[1], name: found[2], number: Number(found[3]) };
22
+ }
23
+ export const repoIssueId = (owner, name, number) => `${owner}/${name}#${number}`;
24
+ /** What the board mutations need to name a value: the board, its item, and
25
+ * the field. Absent board item is said, never guessed at. */
26
+ const itemOn = (issue, boardNumber) => issue.projectItems.find(i => i.boardNumber === boardNumber);
27
+ const optionNamed = (options, name) => options.find(o => o.name === name)?.id;
28
+ /** The dependency verdict in GitHub's words: closed is closed, whatever the
29
+ * reason — a `not_planned` blocker holds nothing back any more than a
30
+ * `completed` one does. */
31
+ export const dependencyCompleted = (state) => state === 'closed';
32
+ /** Whether GitHub says the thing **does not exist** rather than "try
33
+ * again": a deleted issue answers its comments, its board item and its own
34
+ * node with 404 (REST) or `Could not resolve to a Node` (GraphQL). A
35
+ * closing owed to such a surface is dropped, not retried — the Linear
36
+ * tracker learned that on a grave (live, 2026-10-06). Anything else —
37
+ * a 502, a rate limit — is a retry. */
38
+ export const isGoneError = (error) => {
39
+ const e = error;
40
+ if (e?.status === 404 || e?.status === 410)
41
+ return true;
42
+ return typeof e?.message === 'string' && e.message.includes('Could not resolve to a Node');
43
+ };
44
+ const ISSUE_FACTS = /* GraphQL */ `
45
+ query IssueFacts($owner: String!, $repo: String!, $number: Int!) {
46
+ repository(owner: $owner, name: $repo) {
47
+ issue(number: $number) {
48
+ id databaseId number title body url state stateReason
49
+ labels(first: 50) { nodes { id name } }
50
+ assignees(first: 10) { nodes { id login } }
51
+ projectItems(first: 50, includeArchived: false) { nodes {
52
+ id
53
+ project { number }
54
+ fieldValues(first: 30) { nodes {
55
+ ... on ProjectV2ItemFieldSingleSelectValue {
56
+ name optionId
57
+ field { ... on ProjectV2SingleSelectField { name } }
58
+ }
59
+ } }
60
+ } }
61
+ blockedBy(first: 20) { nodes {
62
+ number state stateReason
63
+ repository { owner { login } name }
64
+ } }
65
+ }
66
+ }
67
+ }
68
+ `;
69
+ const OPEN_ISSUES = /* GraphQL */ `
70
+ query OpenIssues($owner: String!, $repo: String!, $first: Int!) {
71
+ repository(owner: $owner, name: $repo) {
72
+ issues(states: [OPEN], first: $first, orderBy: { field: CREATED_AT, direction: DESC }) {
73
+ nodes {
74
+ id databaseId number title body url state stateReason
75
+ labels(first: 20) { nodes { id name } }
76
+ assignees(first: 10) { nodes { id login } }
77
+ projectItems(first: 20, includeArchived: false) { nodes {
78
+ id
79
+ project { number }
80
+ fieldValues(first: 30) { nodes {
81
+ ... on ProjectV2ItemFieldSingleSelectValue {
82
+ name optionId
83
+ field { ... on ProjectV2SingleSelectField { name } }
84
+ }
85
+ } }
86
+ } }
87
+ blockedBy(first: 10) { nodes {
88
+ number state stateReason
89
+ repository { owner { login } name }
90
+ } }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ `;
96
+ const BOARD_FIELDS = /* GraphQL */ `
97
+ query BoardFields($owner: String!, $number: Int!) {
98
+ organization(login: $owner) {
99
+ projectV2(number: $number) {
100
+ id
101
+ fields(first: 60) { nodes {
102
+ ... on ProjectV2Field { id name databaseId }
103
+ ... on ProjectV2SingleSelectField { id name options { id name } }
104
+ } }
105
+ }
106
+ }
107
+ }
108
+ `;
109
+ const ADD_ITEM = /* GraphQL */ `
110
+ mutation AddItem($projectId: ID!, $contentId: ID!) {
111
+ addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { item { id } }
112
+ }
113
+ `;
114
+ const NODE_REF = /* GraphQL */ `
115
+ query NodeRef($id: ID!) {
116
+ node(id: $id) {
117
+ ... on Issue {
118
+ number
119
+ repository { owner { login } name }
120
+ }
121
+ }
122
+ }
123
+ `;
124
+ const SET_SELECT = /* GraphQL */ `
125
+ mutation SetSelect($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
126
+ updateProjectV2ItemFieldValue(
127
+ input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }
128
+ ) { projectV2Item { id } }
129
+ }
130
+ `;
131
+ const CLEAR_SELECT = /* GraphQL */ `
132
+ mutation ClearSelect($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
133
+ clearProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId }) {
134
+ projectV2Item { id }
135
+ }
136
+ }
137
+ `;
138
+ /** Map one GraphQL issue node into the client's shape. The board values ride
139
+ * as `projectItems`; everything GitHub-flavored is translated to aivi words
140
+ * here and stops existing above this file. The `number` is the
141
+ * repository-local one — `databaseId` is a global row id (billions for a
142
+ * repo's 52nd issue) and appears in no id aivi builds. */
143
+ function mapIssue(owner, name, node) {
144
+ const repo = `${owner}/${name}`;
145
+ const ref = repoIssueId(owner, name, node.number);
146
+ return {
147
+ id: node.id,
148
+ ref,
149
+ repo,
150
+ number: node.number,
151
+ title: node.title,
152
+ body: node.body ?? null,
153
+ url: node.url,
154
+ state: node.state === 'CLOSED' ? 'closed' : 'open',
155
+ stateReason: node.stateReason ? String(node.stateReason).toLowerCase() : null,
156
+ labels: (node.labels?.nodes ?? []).map(l => ({ id: l.id, name: l.name })),
157
+ assignees: (node.assignees?.nodes ?? []).map(a => ({ id: a.id, name: a.login })),
158
+ projectItems: (node.projectItems?.nodes ?? []).map(item => ({
159
+ itemId: item?.id ?? '',
160
+ boardNumber: Number(item?.project?.number),
161
+ values: (item?.fieldValues?.nodes ?? [])
162
+ .filter((v) => Boolean(v?.field?.name))
163
+ .map(v => ({ field: v.field.name, name: v.name ?? '', optionId: v.optionId ?? '' })),
164
+ })),
165
+ blockedBy: (node.blockedBy?.nodes ?? []).map(d => ({
166
+ id: repoIssueId(d.repository.owner.login, d.repository.name, d.number),
167
+ completed: dependencyCompleted(d.state === 'CLOSED' ? 'closed' : 'open'),
168
+ })),
169
+ };
170
+ }
171
+ /** The tracker's GitHub client: the app's octokit (from `@aivi/github`) with
172
+ * the board's queries and the issue REST on it. One per process, shared
173
+ * with nothing else: the forge's pull-request facts never come through here. */
174
+ export class GithubClient {
175
+ app;
176
+ boards = new Map();
177
+ constructor(app) {
178
+ this.app = app;
179
+ }
180
+ /** Whose account made the grant (the org whose boards aivi reads). */
181
+ get org() {
182
+ return this.app.accountLogin;
183
+ }
184
+ /** The app's own login, GitHub's way: the bot suffix rides every post, and
185
+ * inbound filtering reads it back. */
186
+ get botLogin() {
187
+ return `${this.app.appSlug}[bot]`;
188
+ }
189
+ /** The `Agent` option aivi delegates to: the option named for the app
190
+ * itself (D5) — the same word on every board, resolved to a per-board
191
+ * option id on use. */
192
+ get delegateName() {
193
+ return this.app.appSlug;
194
+ }
195
+ /** Resolve a board's `Status` and `Agent` fields and their options, once.
196
+ * A board missing either is said by name — aivi never creates or renames
197
+ * a Status option (D4), and the `Agent` field is setup's job. */
198
+ async boardFields(boardNumber) {
199
+ const known = this.boards.get(boardNumber);
200
+ if (known)
201
+ return known;
202
+ const { organization } = await this.app.octokit.graphql(BOARD_FIELDS, {
203
+ owner: this.org,
204
+ number: boardNumber,
205
+ });
206
+ const project = organization?.projectV2;
207
+ if (!project)
208
+ throw new Error(`board ${boardNumber} is not visible to the app in ${this.org}`);
209
+ let status;
210
+ let agent;
211
+ for (const field of project.fields?.nodes ?? [])
212
+ if (field?.name === STATUS_FIELD)
213
+ status = field;
214
+ else if (field?.name === AGENT_FIELD)
215
+ agent = field;
216
+ if (!status)
217
+ throw new Error(`board ${boardNumber} of ${this.org} has no "${STATUS_FIELD}" single-select field; lanes map onto its options and aivi never creates them`);
218
+ if (!agent)
219
+ throw new Error(`board ${boardNumber} of ${this.org} has no "${AGENT_FIELD}" single-select field; run the project setup — the Agent field is aivi's delegate surface (apps cannot be assignees)`);
220
+ const fields = {
221
+ projectId: project.id,
222
+ number: boardNumber,
223
+ statusFieldId: status.id,
224
+ statusOptions: (status.options ?? []).map(o => ({ id: o.id, name: o.name })),
225
+ agentFieldId: agent.id,
226
+ agentOptions: (agent.options ?? []).map(o => ({ id: o.id, name: o.name })),
227
+ };
228
+ this.boards.set(boardNumber, fields);
229
+ return fields;
230
+ }
231
+ /** The board's Status options, in board order — the lane names a mapping
232
+ * is validated against. */
233
+ async laneOptions(boardNumber) {
234
+ return (await this.boardFields(boardNumber)).statusOptions;
235
+ }
236
+ async issue(id) {
237
+ const { owner, name, number } = issueRef(id);
238
+ const { repository } = await this.app.octokit.graphql(ISSUE_FACTS, {
239
+ owner,
240
+ repo: name,
241
+ number,
242
+ });
243
+ if (!repository?.issue)
244
+ throw new Error(`${id} answers nothing: the issue is gone or the app was not given it`);
245
+ return mapIssue(owner, name, repository.issue);
246
+ }
247
+ /** A repository's open issues, newest created first, each with its board
248
+ * facts: the walk's read (the plan's per-issue `projectItems`, paid in one
249
+ * round trip). Capped; a truncated read is said, never silently short. */
250
+ async openIssues(repo, cap = 100) {
251
+ const [owner, name] = repo.split('/');
252
+ const { repository } = await this.app.octokit.graphql(OPEN_ISSUES, {
253
+ owner,
254
+ repo: name,
255
+ first: cap,
256
+ });
257
+ const nodes = repository?.issues?.nodes ?? [];
258
+ return { issues: nodes.map(n => mapIssue(owner, name, n)), truncated: nodes.length >= cap };
259
+ }
260
+ /** The issue's lane on one board, read from its item: undefined when the
261
+ * issue is not on that board at all. */
262
+ async laneOn(id, boardNumber) {
263
+ const issue = await this.issue(id);
264
+ return this.laneOf(issue, boardNumber);
265
+ }
266
+ laneOf(issue, boardNumber) {
267
+ const item = itemOn(issue, boardNumber);
268
+ const status = item?.values.find(v => v.field === STATUS_FIELD);
269
+ return status?.name ? { name: status.name, optionId: status.optionId } : undefined;
270
+ }
271
+ /** The issue's `Agent` value on one board: the option's name, or undefined
272
+ * when nobody speaks for the ticket. */
273
+ agentOn(issue, boardNumber) {
274
+ const item = itemOn(issue, boardNumber);
275
+ const agent = item?.values.find(v => v.field === AGENT_FIELD);
276
+ return agent?.name || undefined;
277
+ }
278
+ /** Move an issue into a board's Status option: onto the board first (the
279
+ * add is idempotent — a re-drive lands as nothing), then the option. The
280
+ * mutation's own answer is the truth; a re-read right after lags. */
281
+ async setLane(id, boardNumber, lane) {
282
+ const fields = await this.boardFields(boardNumber);
283
+ const optionId = optionNamed(fields.statusOptions, lane);
284
+ if (!optionId)
285
+ throw new Error(`board ${boardNumber} of ${this.org} has no Status option "${lane}": map the lane to an option that exists — aivi never creates one`);
286
+ const issue = await this.issue(id);
287
+ let item = itemOn(issue, boardNumber);
288
+ if (!item) {
289
+ const { addProjectV2ItemById } = await this.app.octokit.graphql(ADD_ITEM, {
290
+ projectId: fields.projectId,
291
+ contentId: issue.id,
292
+ });
293
+ item = { itemId: addProjectV2ItemById?.item?.id ?? '', boardNumber, values: [] };
294
+ }
295
+ if (item.values.find(v => v.field === STATUS_FIELD)?.name === lane)
296
+ return; // already there
297
+ await this.app.octokit.graphql(SET_SELECT, {
298
+ projectId: fields.projectId,
299
+ itemId: item.itemId,
300
+ fieldId: fields.statusFieldId,
301
+ optionId,
302
+ });
303
+ }
304
+ /** Set (or with null, clear) the `Agent` field — the delegate in GitHub's
305
+ * honest words (D5: an app cannot be an assignee, proven 403). */
306
+ async setAgent(id, boardNumber, delegate) {
307
+ const fields = await this.boardFields(boardNumber);
308
+ const issue = await this.issue(id);
309
+ const item = itemOn(issue, boardNumber);
310
+ if (!item)
311
+ throw new Error(`${id} is not on board ${boardNumber}; it has no Agent value to ${delegate ? 'set' : 'clear'}`);
312
+ if (!delegate) {
313
+ if (!this.agentOn(issue, boardNumber))
314
+ return; // nothing standing to clear
315
+ await this.app.octokit.graphql(CLEAR_SELECT, {
316
+ projectId: fields.projectId,
317
+ itemId: item.itemId,
318
+ fieldId: fields.agentFieldId,
319
+ });
320
+ return;
321
+ }
322
+ const optionId = optionNamed(fields.agentOptions, delegate);
323
+ if (!optionId)
324
+ throw new Error(`board ${boardNumber} of ${this.org} has no Agent option "${delegate}": run the project setup to create it`);
325
+ if (this.agentOn(issue, boardNumber) === delegate)
326
+ return; // already speaks
327
+ await this.app.octokit.graphql(SET_SELECT, {
328
+ projectId: fields.projectId,
329
+ itemId: item.itemId,
330
+ fieldId: fields.agentFieldId,
331
+ optionId,
332
+ });
333
+ }
334
+ /** Open an issue in a repository — the desk's escape hatch. Labels are
335
+ * added by name (GitHub's own add is not create-on-demand, so each name
336
+ * is ensured first). Landing on the board in a lane is `setLane`'s. */
337
+ async createIssue(repo, input) {
338
+ const [owner, name] = repo.split('/');
339
+ const made = await this.app.octokit.rest.issues.create({
340
+ owner: owner,
341
+ repo: name,
342
+ title: input.title,
343
+ ...(input.body !== undefined ? { body: input.body } : {}),
344
+ });
345
+ const id = repoIssueId(owner, name, made.data.number);
346
+ for (const label of input.labels ?? []) {
347
+ await this.ensureLabel(repo, label);
348
+ await this.app.octokit.rest.issues.addLabels({
349
+ owner: owner,
350
+ repo: name,
351
+ issue_number: made.data.number,
352
+ labels: [label],
353
+ });
354
+ }
355
+ return this.issue(id);
356
+ }
357
+ /** Rewrite the issue's own words; only what is named is touched. */
358
+ async updateIssue(id, changes) {
359
+ const { owner, name, number } = issueRef(id);
360
+ await this.app.octokit.rest.issues.update({
361
+ owner,
362
+ repo: name,
363
+ issue_number: number,
364
+ ...(changes.title !== undefined ? { title: changes.title } : {}),
365
+ ...(changes.description !== undefined ? { body: changes.description } : {}),
366
+ });
367
+ return this.issue(id);
368
+ }
369
+ /** The comment trail, oldest first, with the author in GitHub's words —
370
+ * `authorIsBot` is how aivi's own posts are filtered inbound. */
371
+ async comments(id, cap = 300) {
372
+ const { owner, name, number } = issueRef(id);
373
+ const out = [];
374
+ for (let page = 1; out.length < cap; page += 1) {
375
+ const { data } = await this.app.octokit.rest.issues.listComments({
376
+ owner,
377
+ repo: name,
378
+ issue_number: number,
379
+ per_page: 100,
380
+ page,
381
+ sort: 'created',
382
+ direction: 'asc',
383
+ });
384
+ out.push(...data.map(c => ({
385
+ id: c.id,
386
+ author: c.user?.login ?? null,
387
+ authorIsBot: c.user?.type === 'Bot',
388
+ body: c.body ?? '',
389
+ at: c.created_at ?? c.updated_at ?? '',
390
+ })));
391
+ if (data.length < 100)
392
+ break;
393
+ }
394
+ return out;
395
+ }
396
+ /** Post a comment and answer with GitHub's own id — `startSession` hands
397
+ * that back as the run's conversation handle (D6). */
398
+ async createComment(id, body) {
399
+ const { owner, name, number } = issueRef(id);
400
+ const made = await this.app.octokit.rest.issues.createComment({ owner, repo: name, issue_number: number, body });
401
+ return { id: made.data.id, url: made.data.html_url };
402
+ }
403
+ async updateComment(id, commentId, body) {
404
+ const { owner, name } = issueRef(id);
405
+ await this.app.octokit.rest.issues.updateComment({ owner, repo: name, comment_id: commentId, body });
406
+ }
407
+ /** One comment's body by id — the status comment's own read, the only
408
+ * surface a progress edit may build on (never a remembered body). */
409
+ async getComment(id, commentId) {
410
+ const { owner, name } = issueRef(id);
411
+ try {
412
+ const { data } = await this.app.octokit.rest.issues.getComment({ owner, repo: name, comment_id: commentId });
413
+ return data.body ?? '';
414
+ }
415
+ catch (error) {
416
+ if (isGoneError(error))
417
+ return undefined;
418
+ throw error;
419
+ }
420
+ }
421
+ async deleteComment(id, commentId) {
422
+ const { owner, name } = issueRef(id);
423
+ await this.app.octokit.rest.issues.deleteComment({ owner, repo: name, comment_id: commentId });
424
+ }
425
+ /** The `owner/repo#n` behind a GraphQL node id — the `projects_v2_item`
426
+ * payload names its issue only by node, and the board's events are worth
427
+ * nothing until that resolves. The number is the repository-local one;
428
+ * the node's `databaseId` is a global row id and resolves to no issue.
429
+ * Non-issue nodes answer undefined. */
430
+ async nodeRef(nodeId) {
431
+ const { node } = await this.app.octokit.graphql(NODE_REF, { id: nodeId });
432
+ if (!node?.repository || node.number === undefined)
433
+ return undefined;
434
+ return repoIssueId(node.repository.owner.login, node.repository.name, node.number);
435
+ }
436
+ /** The repository's label catalogue — every name it knows. */
437
+ async repoLabels(repo, cap = 500) {
438
+ const [owner, name] = repo.split('/');
439
+ const out = [];
440
+ for (let page = 1; out.length < cap; page += 1) {
441
+ const { data } = await this.app.octokit.rest.issues.listLabelsForRepo({
442
+ owner: owner,
443
+ repo: name,
444
+ per_page: 100,
445
+ page,
446
+ });
447
+ out.push(...data.map(l => l.name));
448
+ if (data.length < 100)
449
+ break;
450
+ }
451
+ return out;
452
+ }
453
+ /** Put one label on the ticket, creating it in the repository when none
454
+ * carries the name (add only, as the tool promises). */
455
+ async addLabel(id, label) {
456
+ const { owner, name, number } = issueRef(id);
457
+ await this.ensureLabel(id.slice(0, id.indexOf('#')), label);
458
+ await this.app.octokit.rest.issues.addLabels({ owner, repo: name, issue_number: number, labels: [label] });
459
+ }
460
+ async removeLabel(id, label) {
461
+ const { owner, name, number } = issueRef(id);
462
+ try {
463
+ await this.app.octokit.rest.issues.removeLabel({ owner, repo: name, issue_number: number, name: label });
464
+ }
465
+ catch (error) {
466
+ if (error.status === 404)
467
+ return; // not on the ticket: already off, said as done
468
+ throw error;
469
+ }
470
+ }
471
+ /** Create a label in the repository when none carries the name. The human
472
+ * label does not exist on a GitHub repository until aivi makes it —
473
+ * unlike Linear, where it must pre-exist. */
474
+ async ensureLabel(repo, name) {
475
+ const [owner, repoName] = repo.split('/');
476
+ try {
477
+ await this.app.octokit.rest.issues.getLabel({ owner: owner, repo: repoName, name });
478
+ }
479
+ catch (error) {
480
+ if (error.status !== 404)
481
+ throw error;
482
+ await this.app.octokit.rest.issues.createLabel({ owner: owner, repo: repoName, name, color: 'ededed' });
483
+ }
484
+ }
485
+ /** The assistant's marks (D10): reactions on the issue itself — `eyes`
486
+ * while the assistant works, `rocket` when it answers. An assistive
487
+ * surface only: no run state rides a reaction. */
488
+ async addReaction(id, content) {
489
+ const { owner, name, number } = issueRef(id);
490
+ await this.app.octokit.rest.reactions.createForIssue({ owner, repo: name, issue_number: number, content });
491
+ }
492
+ /** Take the assistant's own `eyes` off (a person's reactions are nobody to
493
+ * touch: only reactions by aivi's bot are removed). */
494
+ async removeOwnReaction(id, content) {
495
+ const { owner, name, number } = issueRef(id);
496
+ for (let page = 1; page <= 3; page += 1) {
497
+ const { data } = await this.app.octokit.rest.reactions.listForIssue({
498
+ owner,
499
+ repo: name,
500
+ issue_number: number,
501
+ content,
502
+ per_page: 100,
503
+ page,
504
+ });
505
+ for (const reaction of data)
506
+ if (reaction.user?.login === this.botLogin)
507
+ await this.app.octokit.rest.reactions.deleteForIssue({
508
+ owner,
509
+ repo: name,
510
+ issue_number: number,
511
+ reaction_id: reaction.id,
512
+ });
513
+ if (data.length < 100)
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ /** The tracker's client over the home's one app: connect through
519
+ * `@aivi/github`, and the webhook secret is read where it lives. A missing
520
+ * secret is a ConfigurationError the operator fixes in `<home>/.env` — no
521
+ * delivery is verifiable without it, so the module refuses to start. */
522
+ export async function githubClient(log) {
523
+ const app = await githubApp({ ...(log ? { log } : {}) });
524
+ const webhookSecret = process.env[GITHUB_WEBHOOK_SECRET_ENV];
525
+ if (!webhookSecret)
526
+ throw new ConfigurationError(`tracker-github: ${GITHUB_WEBHOOK_SECRET_ENV} is not set. Put the webhook secret you pasted on the app's settings page in <home>/.env; without it every delivery is unverifiable.`);
527
+ return { client: new GithubClient(app), webhookSecret };
528
+ }
@@ -0,0 +1,60 @@
1
+ import type { AiviPlugin } from '@aivi/plugin';
2
+ import { z } from 'zod';
3
+ /** The module id: the key of this plugin's block in `config.json`
4
+ * (`plugins.tracker-github`), its `/status` id and its log category — the
5
+ * package's short name, ruled 2026-09-30. What deliberately keeps the
6
+ * shorter `github` is everything naming the **platform**: the SQLite table
7
+ * prefix, the conversation prefix (`github:…`), the webhook route
8
+ * (`/github/webhooks`) and the session origin. */
9
+ export declare const MODULE_ID = "tracker-github";
10
+ /** The board's own field names, as aivi created them (D5): the delegate
11
+ * lives in `Agent`, the lane lives in `Status`. One pair of names for every
12
+ * board; a board that lacks them fails setup with the remedy, not a guess. */
13
+ export declare const STATUS_FIELD = "Status";
14
+ export declare const AGENT_FIELD = "Agent";
15
+ export declare const githubSchema: z.ZodObject<{
16
+ agent: z.ZodOptional<z.ZodString>;
17
+ humanLabel: z.ZodDefault<z.ZodString>;
18
+ pool: z.ZodDefault<z.ZodString>;
19
+ progress: z.ZodDefault<z.ZodEnum<{
20
+ silent: "silent";
21
+ status: "status";
22
+ tools: "tools";
23
+ }>>;
24
+ trust: z.ZodDefault<z.ZodEnum<{
25
+ everyone: "everyone";
26
+ members: "members";
27
+ }>>;
28
+ }, z.core.$strict>;
29
+ export type GithubConfig = z.infer<typeof githubSchema>;
30
+ /** The plugin's section under a project entry (`projects.<id>.tracker-github`).
31
+ * Core reads none of it. */
32
+ export declare const githubProjectSchema: z.ZodObject<{
33
+ board: z.ZodNumber;
34
+ repos: z.ZodArray<z.ZodString>;
35
+ createLane: z.ZodOptional<z.ZodString>;
36
+ }, z.core.$strict>;
37
+ export type GithubProjectEntry = z.infer<typeof githubProjectSchema>;
38
+ /** The plugin's section under `projectDefaults`. */
39
+ export declare const githubProjectDefaultsSchema: z.ZodObject<{
40
+ createLane: z.ZodOptional<z.ZodString>;
41
+ }, z.core.$strict>;
42
+ export type GithubProjectDefaults = z.infer<typeof githubProjectDefaultsSchema>;
43
+ /** The project's GitHub routing as it takes effect: which board speaks for it
44
+ * and which repositories feed it. Lanes are not here — they are core's
45
+ * ordered workflow on the `Project` itself, mapped onto the board's Status
46
+ * options. */
47
+ export interface ProjectGithub {
48
+ board: number;
49
+ repos: string[];
50
+ createLane?: string;
51
+ }
52
+ /** The assistant's agent name: `tracker-github.agent`, else the one assistant
53
+ * everyone gets. */
54
+ export declare function assistantAgent(github: GithubConfig | undefined): string;
55
+ /** The registry entry: the block holds no paths (the credential is the
56
+ * environment's, the board is named by number), so `home` finds nothing to
57
+ * resolve here; the module code loads lazily. The project sections are
58
+ * contributed here — core passes them through and this plugin reads them
59
+ * back. */
60
+ export declare const plugin: AiviPlugin<GithubConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,99 @@
1
+ /** The GitHub tracker's registry declaration, at its `./config` subpath: the
2
+ * module id, the block's own schema, and the bridge to the module code. The
3
+ * CLI imports this for every command, so nothing here pulls octokit.
4
+ *
5
+ * One board is one project's whole board (ruled 2026-10-07): the block names
6
+ * the projectsV2 board by number, the repositories whose issues feed it, and
7
+ * nothing else. The **lanes are core's** (`projects.<id>.lanes`, ordered);
8
+ * they map onto the board's own `Status` options, and aivi never creates or
9
+ * renames one (D4) — a lane whose option is missing is said, never guessed
10
+ * past. The `Agent` single-select field is aivi's own (D5): apps cannot be
11
+ * assignees (proven 403), so the board carries the delegate in a field aivi
12
+ * created. The app's identity is the environment's — `GITHUB_APP_ID` and
13
+ * `GITHUB_APP_PRIVATE_KEY` in `<home>/.env`, shared with the forge through
14
+ * `@aivi/github`.
15
+ */
16
+ import { PROJECT_ID } from '@aivi/core';
17
+ import { z } from 'zod';
18
+ const id = z.string().regex(PROJECT_ID);
19
+ /** The module id: the key of this plugin's block in `config.json`
20
+ * (`plugins.tracker-github`), its `/status` id and its log category — the
21
+ * package's short name, ruled 2026-09-30. What deliberately keeps the
22
+ * shorter `github` is everything naming the **platform**: the SQLite table
23
+ * prefix, the conversation prefix (`github:…`), the webhook route
24
+ * (`/github/webhooks`) and the session origin. */
25
+ export const MODULE_ID = 'tracker-github';
26
+ /** The board's own field names, as aivi created them (D5): the delegate
27
+ * lives in `Agent`, the lane lives in `Status`. One pair of names for every
28
+ * board; a board that lacks them fails setup with the remedy, not a guess. */
29
+ export const STATUS_FIELD = 'Status';
30
+ export const AGENT_FIELD = 'Agent';
31
+ export const githubSchema = z.strictObject({
32
+ agent: z
33
+ .string()
34
+ .min(1)
35
+ .optional()
36
+ .describe('The OpenCode agent that answers people on GitHub — comment mentions no run is working: the assistant. Default: the aivi name.'),
37
+ humanLabel: z
38
+ .string()
39
+ .min(1)
40
+ .default('needs-human')
41
+ .describe('Issues carrying this label are never worked automatically; setup creates it in each mapped repository.'),
42
+ pool: id.default('default').describe('Dispatcher pool an assistant turn takes a slot in.'),
43
+ progress: z
44
+ .enum(['silent', 'status', 'tools'])
45
+ .default('tools')
46
+ .describe('What the status comment shows while a worker runs: silent, the one line, or the line with its tool.'),
47
+ trust: z
48
+ .enum(['members', 'everyone'])
49
+ .default('members')
50
+ .describe('Who comments may reach aivi — form answers, run steering, the assistant. `members`: GitHub’s OWNER, MEMBER and COLLABORATOR only; a CONTRIBUTOR or a stranger is heard as nothing, not a word and not a log line. `everyone`: any GitHub user.'),
51
+ });
52
+ /** The plugin's section under a project entry (`projects.<id>.tracker-github`).
53
+ * Core reads none of it. */
54
+ export const githubProjectSchema = z.strictObject({
55
+ board: z
56
+ .number()
57
+ .int()
58
+ .positive()
59
+ .describe('The projectsV2 board number — the number at the end of the board’s URL (`.../projects/<org>/1` is board 1). Its Status options are the lanes; aivi never edits them.'),
60
+ repos: z
61
+ .array(z.string().min(3))
62
+ .min(1)
63
+ .describe('Repositories whose issues belong to this project, as `owner/name`. A repository maps to at most one project.'),
64
+ createLane: z
65
+ .string()
66
+ .min(1)
67
+ .optional()
68
+ .describe('Where `aivi_ticket_create` lands a new ticket — a Status option name. Default: the project’s first configured lane; with none matching, the repository’s own default.'),
69
+ });
70
+ /** The plugin's section under `projectDefaults`. */
71
+ export const githubProjectDefaultsSchema = z.strictObject({
72
+ createLane: z
73
+ .string()
74
+ .min(1)
75
+ .optional()
76
+ .describe('Where `aivi_ticket_create` lands a new ticket in every project unless it names its own.'),
77
+ });
78
+ /** The assistant's agent name: `tracker-github.agent`, else the one assistant
79
+ * everyone gets. */
80
+ export function assistantAgent(github) {
81
+ return github?.agent ?? 'assistant';
82
+ }
83
+ /** The registry entry: the block holds no paths (the credential is the
84
+ * environment's, the board is named by number), so `home` finds nothing to
85
+ * resolve here; the module code loads lazily. The project sections are
86
+ * contributed here — core passes them through and this plugin reads them
87
+ * back. */
88
+ export const plugin = {
89
+ id: MODULE_ID,
90
+ configSchema: githubSchema,
91
+ projectSchema: githubProjectSchema,
92
+ projectDefaultsSchema: githubProjectDefaultsSchema,
93
+ // The lazy load is the design: the CLI reads this file's schema without
94
+ // pulling the host's world in, and the module arrives only when the host
95
+ // actually builds. The cycle back into this file (the module imports its
96
+ // own schema) is that design, not a tangle.
97
+ // fallow-ignore-next-line circular-dependency
98
+ createModule: config => import('./module.js').then(m => m.createGithubModule(config)),
99
+ };