@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
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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
};
|