@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/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/setup.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The setup `aivi add @aivi/tracker-github` runs: what the GitHub tracker
|
|
3
|
+
* specifically lives on lives in this file.
|
|
4
|
+
*
|
|
5
|
+
* The app's identity is the home's (D3), and the guide and its two questions
|
|
6
|
+
* are `@aivi/github`'s (`appIdentity`), shared with the forge's installer:
|
|
7
|
+
* when `<home>/.env` already holds the id and key — the forge was installed
|
|
8
|
+
* first, or this installer wrote them — nothing is asked, because the forge
|
|
9
|
+
* and the tracker are **one app**.
|
|
10
|
+
*
|
|
11
|
+
* Before anything is written, the truth is read at GitHub with the app's own
|
|
12
|
+
* JWT: that the grant allows Issues and Projects (the desk and the board),
|
|
13
|
+
* that the webhook points at this machine and subscribes to `issue_comment`,
|
|
14
|
+
* `issues` and `projects_v2_item`, and what the last delivery was answered.
|
|
15
|
+
* Then the webhook secret — the home's when it holds one, else pasted — is
|
|
16
|
+
* proved by one wait: a delivery must land at this aivi's route and verify
|
|
17
|
+
* against it. The trigger is a replay of the history's newest `installation`
|
|
18
|
+
* delivery — GitHub posts that event when a person installs or approves the
|
|
19
|
+
* app. When the history holds none — the app was installed before this
|
|
20
|
+
* webhook pointed here — or GitHub refuses the replay, **no wait runs**: a
|
|
21
|
+
* trigger that cannot come is never sat out (ruled 2026-10-20). The person
|
|
22
|
+
* reinstalls the app and retries, or skips the wait, and UNVERIFIED is said.
|
|
23
|
+
*
|
|
24
|
+
* The health gate is a choice, not a stop: no live aivi, the person picks
|
|
25
|
+
* "I started the server. Retry" or "Continue anyway". Continuing writes
|
|
26
|
+
* everything with UNVERIFIED said in the summary — the unverified fact is
|
|
27
|
+
* precisely "GitHub can reach this machine". This installer **writes nothing
|
|
28
|
+
* at GitHub**; the board probe belongs to `aivi projects add`, where the board
|
|
29
|
+
* id is in hand. Performing a restart never belongs to this flow — the CLI
|
|
30
|
+
* owns that, and reports it as it happens.
|
|
31
|
+
*/
|
|
32
|
+
import { configSchema, errorMessage, getLogger } from '@aivi/core';
|
|
33
|
+
import { appIdentity, GITHUB_WEBHOOK_SECRET_ENV, GitHubApp } from '@aivi/github';
|
|
34
|
+
import { settled } from '@aivi/plugin';
|
|
35
|
+
import { MODULE_ID } from './config.js';
|
|
36
|
+
import { gateHost, hostIsUp, INSTALLATION_EVENTS, latestId, probeWindowMs, waitDiary } from './probe.js';
|
|
37
|
+
import { GITHUB_WEBHOOK_PATH } from './tracker.js';
|
|
38
|
+
/** What the app's webhook must subscribe to, each with what the tracker would
|
|
39
|
+
* never hear again without it. */
|
|
40
|
+
const REQUIRED_EVENTS = {
|
|
41
|
+
issue_comment: 'so a person’s comment can reach aivi',
|
|
42
|
+
issues: 'so aivi can hear about ticket changes',
|
|
43
|
+
projects_v2_item: 'so aivi can hear about board moves',
|
|
44
|
+
};
|
|
45
|
+
/** How far back the replay hunt looks for an installation delivery: one cheap
|
|
46
|
+
* request, and the grace an app installed a while ago deserves — the newest
|
|
47
|
+
* 10 were often all traffic since the install, and the install itself had
|
|
48
|
+
* scrolled out (ruled 2026-10-20). */
|
|
49
|
+
const WEBHOOK_HISTORY = 25;
|
|
50
|
+
/** Two webhook URLs compared as what they point at: GitHub stores the string
|
|
51
|
+
* a person typed, and a trailing slash is nobody's intent. */
|
|
52
|
+
const trimSlash = (url) => url.replace(/\/+$/, '');
|
|
53
|
+
const setup = async (ctx) => {
|
|
54
|
+
const log = getLogger(['aivi', MODULE_ID]);
|
|
55
|
+
const config = configSchema.parse(ctx.config);
|
|
56
|
+
const existingBlock = ctx.config.plugins?.[MODULE_ID];
|
|
57
|
+
// 1. The live aivi, asked as a choice. The waits below watch what a running
|
|
58
|
+
// aivi records; with none running the person retries or continues
|
|
59
|
+
// unverified — the installer's own configuration still gets written.
|
|
60
|
+
const verify = await gateHost(ctx.prompts, () => hostIsUp(ctx.fetch, config.host));
|
|
61
|
+
// 2. The URL GitHub can reach this aivi at, asked once and saved.
|
|
62
|
+
let base = config.host.public;
|
|
63
|
+
if (!base) {
|
|
64
|
+
ctx.prompts.note([
|
|
65
|
+
'GitHub posts webhooks to this aivi, so it needs a URL GitHub can reach.',
|
|
66
|
+
'A tunnel is the usual answer — a Tailscale Funnel or similar pointed at aivi.',
|
|
67
|
+
'aivi cannot discover this URL itself, so the setup asks once and saves it;',
|
|
68
|
+
'later runs will not ask.',
|
|
69
|
+
].join('\n'), 'GitHub reaches aivi');
|
|
70
|
+
base = settled(ctx, await ctx.prompts.text({
|
|
71
|
+
message: 'The public URL GitHub can reach this aivi at',
|
|
72
|
+
placeholder: 'e.g. https://my-machine.ts.net',
|
|
73
|
+
validate: value => value !== undefined && /^https?:\/\//.test(value)
|
|
74
|
+
? value.endsWith('/')
|
|
75
|
+
? 'no trailing /'
|
|
76
|
+
: undefined
|
|
77
|
+
: 'an http(s) URL, like https://my-machine.ts.net',
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
const webhookUrl = `${base}${GITHUB_WEBHOOK_PATH}`;
|
|
81
|
+
// 3. The app's identity — the home's, shared with the forge (D3). The guide
|
|
82
|
+
// and its two questions are @aivi/github's, so one app is asked for once
|
|
83
|
+
// however the two plugins were installed; when the home already holds the
|
|
84
|
+
// identity, nothing is asked.
|
|
85
|
+
ctx.prompts.note([
|
|
86
|
+
'aivi works on GitHub as its own app, never as you: it comments,',
|
|
87
|
+
'moves board items, and labels as the bot, so the work in front of',
|
|
88
|
+
'a reviewer is aivi’s and the credit is yours.',
|
|
89
|
+
].join('\n'), 'The GitHub app aivi works as');
|
|
90
|
+
const credentials = await appIdentity(ctx, {
|
|
91
|
+
webhook: { url: webhookUrl, events: 'Issue comment, Issues, and Projects v2 item' },
|
|
92
|
+
});
|
|
93
|
+
// The proof below runs in this process, which read `.env` before any key was
|
|
94
|
+
// in it: hand the credential to the connection directly, so what is tested
|
|
95
|
+
// here is the bytes that were just written.
|
|
96
|
+
const app = await GitHubApp.connect({ log, credentials });
|
|
97
|
+
await ctx.prompts.log.message(`The key is app ${app.appSlug}’s, installed on ${app.accountLogin} as installation #${app.installationId}.`);
|
|
98
|
+
// 4. What the grant says it allows, read with the app's own JWT. A tracker
|
|
99
|
+
// that went on past a missing permission would fail at the first lane
|
|
100
|
+
// move with someone else's error message.
|
|
101
|
+
const grant = await app.grantFacts();
|
|
102
|
+
const lacking = [];
|
|
103
|
+
if (grant.permissions.issues !== 'write')
|
|
104
|
+
lacking.push(`Issues: ${grant.permissions.issues ?? 'no access'} (aivi needs Read and write)`);
|
|
105
|
+
if (grant.permissions.organization_projects !== 'write')
|
|
106
|
+
lacking.push(`Projects: ${grant.permissions.organization_projects ?? 'no access'} (aivi needs Read and write)`);
|
|
107
|
+
if (lacking.length)
|
|
108
|
+
throw new Error(`installation #${app.installationId} cannot run the tracker: ${lacking.join('; ')}. ` +
|
|
109
|
+
'GitHub sends an email asking you to approve new permissions; you can also open the organization’s Settings, choose "GitHub Apps" under "Third-party Access", press Configure, and approve there. Saving new permissions on the app page alone keeps the old grant.');
|
|
110
|
+
const unheard = Object.keys(REQUIRED_EVENTS).filter(event => !grant.events.includes(event) && !grant.events.includes('*'));
|
|
111
|
+
if (unheard.length)
|
|
112
|
+
throw new Error(`the app’s webhook does not subscribe ${unheard.map(event => `${event} (${REQUIRED_EVENTS[event]})`).join('; ')}. ` +
|
|
113
|
+
'Tick them under "Subscribe to events" on the app’s settings page.');
|
|
114
|
+
// 5. Where the hook points — also the app's own JWT. A URL that points at
|
|
115
|
+
// another machine is said now, not waited out there.
|
|
116
|
+
const hook = await app.webhookConfig();
|
|
117
|
+
if (!hook.url)
|
|
118
|
+
throw new Error(`the app posts nowhere: put ${webhookUrl} in the "Webhook URL" on the app’s settings page, with the events above, and run this again.`);
|
|
119
|
+
if (trimSlash(hook.url) !== trimSlash(webhookUrl))
|
|
120
|
+
throw new Error(`the app posts to ${hook.url}, not ${webhookUrl}. Deliveries to a different address are not aivi’s to read: point the Webhook URL here and run this again.`);
|
|
121
|
+
if (hook.insecureSsl)
|
|
122
|
+
await ctx.prompts.log.message('GitHub downgraded the webhook to SSL — deliveries skip full TLS verification. Check the certificate on the public URL.');
|
|
123
|
+
const history = await app.webhookDeliveries(WEBHOOK_HISTORY);
|
|
124
|
+
const last = history[0];
|
|
125
|
+
if (!last)
|
|
126
|
+
await ctx.prompts.log.message('The webhook has delivered nothing yet — the wait below is its first.');
|
|
127
|
+
else if (last.statusCode >= 200 && last.statusCode < 300)
|
|
128
|
+
await ctx.prompts.log.message(`The last delivery (${last.event}) was answered ${last.statusCode}.`);
|
|
129
|
+
else
|
|
130
|
+
await ctx.prompts.log.message(`The last delivery (${last.event}) was answered ${last.statusCode} — before a restart the route is not listening yet, so this is expected; after one it is the thing to chase.`);
|
|
131
|
+
// 6. The secret — the home's when it holds one (D3's rule again: the module
|
|
132
|
+
// reads this variable from the environment anyway, so asking again would
|
|
133
|
+
// only invite a paste that disagrees with the bytes the server verifies
|
|
134
|
+
// with). The wait below proves it either way, pasted or held.
|
|
135
|
+
const heldSecret = process.env[GITHUB_WEBHOOK_SECRET_ENV];
|
|
136
|
+
let webhookSecret;
|
|
137
|
+
if (heldSecret) {
|
|
138
|
+
webhookSecret = heldSecret;
|
|
139
|
+
await ctx.prompts.log.message('The home already holds the webhook’s secret — nothing is asked, and the wait below proves it.');
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
webhookSecret = settled(ctx, await ctx.prompts.password({
|
|
143
|
+
message: 'The webhook’s Secret, from the app’s settings page',
|
|
144
|
+
validate: value => (value ? undefined : 'Paste the Secret from the webhook section'),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
let probe;
|
|
148
|
+
if (verify)
|
|
149
|
+
probe = await probeInstallation(ctx, app, webhookUrl, webhookSecret, history);
|
|
150
|
+
// 7. Only now is anything written: `host.public` if the installer asked for
|
|
151
|
+
// it, the webhook secret when the home did not hold one, and the
|
|
152
|
+
// `tracker-github` block — empty by design, its fields all have defaults;
|
|
153
|
+
// the byte only marks the tracker as configured. Nothing was written at
|
|
154
|
+
// GitHub: the board probe lives in `aivi projects add`, where the board
|
|
155
|
+
// id is in hand.
|
|
156
|
+
if (!config.host.public)
|
|
157
|
+
await ctx.writeConfigBlock(['host', 'public'], base);
|
|
158
|
+
if (!heldSecret)
|
|
159
|
+
await ctx.writeSecret(GITHUB_WEBHOOK_SECRET_ENV, webhookSecret);
|
|
160
|
+
await ctx.writeConfigBlock(['plugins', MODULE_ID], { ...existingBlock });
|
|
161
|
+
const outcome = probe
|
|
162
|
+
? probe.pass
|
|
163
|
+
? `${probe.detail}. Restart aivi to load the tracker`
|
|
164
|
+
: `FAILED — ${probe.detail}. Fix that and run the install again`
|
|
165
|
+
: `UNVERIFIED — ${verify ? 'the webhook wait was skipped' : 'no live aivi watched'}, so “GitHub can reach this machine” was not proved. Restart aivi to load the tracker`;
|
|
166
|
+
return {
|
|
167
|
+
module: MODULE_ID,
|
|
168
|
+
summary: `The GitHub tracker is configured: ${outcome}.`,
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
/** The fork when no trigger can come: the person reinstalls the app (the
|
|
172
|
+
* install posts `installation`; lighter, toggling a repository on the
|
|
173
|
+
* installation page posts `installation_repositories`) and retries, or skips
|
|
174
|
+
* the wait — the configuration is still written, with UNVERIFIED said in the
|
|
175
|
+
* summary. Cancelled is no answer: the flow stops, as at the health gate. */
|
|
176
|
+
async function forkWebhook(ctx, message) {
|
|
177
|
+
const choice = await ctx.prompts.select({
|
|
178
|
+
message,
|
|
179
|
+
options: [
|
|
180
|
+
{ value: 'retry', label: 'I reinstalled the app. Try again.' },
|
|
181
|
+
{ value: 'skip', label: 'Skip webhook verification' },
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
if (ctx.prompts.isCancel(choice))
|
|
185
|
+
throw new Error('the setup was cancelled at the webhook fork');
|
|
186
|
+
return choice === 'skip' ? 'skip' : 'retry';
|
|
187
|
+
}
|
|
188
|
+
/** The one wait: a delivery must land at this aivi's route and verify against
|
|
189
|
+
* the secret aivi holds. The trigger is a replay of the history's newest
|
|
190
|
+
* installation delivery (the replay carries the app's current secret, so a
|
|
191
|
+
* replay that verifies proves the held secret). When the history holds none,
|
|
192
|
+
* or GitHub refuses the replay, **no wait starts**: an app installed before
|
|
193
|
+
* this webhook pointed here would sit the window on a delivery that can
|
|
194
|
+
* never land (ruled 2026-10-20) — the person forks instead. A delivery that
|
|
195
|
+
* arrives about anything else while the wait runs still proves the URL and
|
|
196
|
+
* the secret, and is said as the verdict too — silence is the only failure
|
|
197
|
+
* that is waited out. `undefined` is the skip: no verdict, because nothing
|
|
198
|
+
* was watched. */
|
|
199
|
+
async function probeInstallation(ctx, app, webhookUrl, webhookSecret, history) {
|
|
200
|
+
const windowMs = probeWindowMs();
|
|
201
|
+
let deliveries = history;
|
|
202
|
+
for (;;) {
|
|
203
|
+
const replay = deliveries.find(delivery => INSTALLATION_EVENTS.has(delivery.event));
|
|
204
|
+
if (!replay) {
|
|
205
|
+
const choice = await forkWebhook(ctx, 'The GitHub app was not installed recently.');
|
|
206
|
+
if (choice === 'skip')
|
|
207
|
+
return undefined;
|
|
208
|
+
// The reinstall posted its own delivery — the hunt re-pulls the history.
|
|
209
|
+
deliveries = await app.webhookDeliveries(WEBHOOK_HISTORY);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const spinner = ctx.prompts.spinner();
|
|
213
|
+
spinner.start('webhooks: waiting for a delivery to land…');
|
|
214
|
+
// `undefined`: GitHub refused the replay, so no wait started — the fork
|
|
215
|
+
// below decides, because waiting out the window on a refused replay only
|
|
216
|
+
// re-asks a question GitHub already answered.
|
|
217
|
+
let refused;
|
|
218
|
+
const posted = await ctx.withStore(async (store) => {
|
|
219
|
+
// The cursor is taken **before** the trigger: a replay that lands in the
|
|
220
|
+
// seconds before the store opens must still be ahead of the mark, and
|
|
221
|
+
// whatever the diary already held is nobody's business.
|
|
222
|
+
const cursor = latestId(store);
|
|
223
|
+
try {
|
|
224
|
+
await app.redeliverWebhook(replay.id);
|
|
225
|
+
spinner.message('Re-asked GitHub for its own installation delivery — waiting for the replay to land…');
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
refused = errorMessage(error);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return waitDiary(store, {
|
|
232
|
+
path: GITHUB_WEBHOOK_PATH,
|
|
233
|
+
secret: webhookSecret,
|
|
234
|
+
windowMs,
|
|
235
|
+
accepts: (event, payload) => INSTALLATION_EVENTS.has(event) && payload?.action !== 'deleted',
|
|
236
|
+
about: () => true, // every delivery about this URL proves reach and secret; names are for the live line
|
|
237
|
+
}, cursor, line => spinner.message(line));
|
|
238
|
+
});
|
|
239
|
+
if (posted === undefined) {
|
|
240
|
+
spinner.cancel(`webhooks — the replay was refused (${refused})`);
|
|
241
|
+
const choice = await forkWebhook(ctx, `GitHub refused to replay its installation delivery (${refused}).`);
|
|
242
|
+
if (choice === 'skip')
|
|
243
|
+
return undefined;
|
|
244
|
+
deliveries = await app.webhookDeliveries(WEBHOOK_HISTORY);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (posted.mine) {
|
|
248
|
+
const detail = 'the installation delivery arrived and verified';
|
|
249
|
+
spinner.stop(`webhooks — ${detail}`);
|
|
250
|
+
return { name: 'webhooks', pass: true, detail };
|
|
251
|
+
}
|
|
252
|
+
if (posted.others) {
|
|
253
|
+
const detail = 'deliveries arrived and verified — the URL and the secret are proved, though no installation event among them';
|
|
254
|
+
spinner.stop(`webhooks — ${detail}`);
|
|
255
|
+
return { name: 'webhooks', pass: true, detail };
|
|
256
|
+
}
|
|
257
|
+
const detail = `nothing arrived in ${windowMs / 1000} s — the Webhook URL on the app is not ${webhookUrl}, the events are off, or GitHub cannot reach this machine`;
|
|
258
|
+
spinner.cancel(`webhooks — ${detail}`);
|
|
259
|
+
return { name: 'webhooks', pass: false, detail };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
export default setup;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** GitHub's **platform**: the `Platform` contract of `@aivi/plugin/tracker`
|
|
2
|
+
* spelled in GitHub's words. It owns everything GitHub-shaped below the
|
|
3
|
+
* neutral surface — the one app and its credential (through `@aivi/github`),
|
|
4
|
+
* the webhook route and its seal, the board reads and the `Agent` field, the
|
|
5
|
+
* status comment and its hidden state (D6, D7) — and translates at the
|
|
6
|
+
* border: deliveries become `TrackerEvent`s, neutral updates become GitHub
|
|
7
|
+
* mutations. The module's decision code imports **no** GitHub types through
|
|
8
|
+
* this file.
|
|
9
|
+
*
|
|
10
|
+
* **Conversations** are the adapter's own affair, and GitHub's are the
|
|
11
|
+
* ticket and its status comment (no agent session exists for an app — D5):
|
|
12
|
+
*
|
|
13
|
+
* - `github` — the app's own feed: desk calls and notices, no session.
|
|
14
|
+
* - `github:owner/repo#47` — a person brought the ticket: the **assistant's**
|
|
15
|
+
* conversation, the ticket itself.
|
|
16
|
+
* - `github:owner/repo#47:c123` — a **run's** conversation: the status
|
|
17
|
+
* comment `startSession` created (D6). Its body carries the machine state
|
|
18
|
+
* as a hidden JSON comment (D7); visible words, plan checklist, and the
|
|
19
|
+
* hidden line are all one edited comment.
|
|
20
|
+
*/
|
|
21
|
+
import { type LoadedConfig, type Logger } from '@aivi/core';
|
|
22
|
+
import type { PublicRequest, PublicRoutes } from '@aivi/plugin';
|
|
23
|
+
import type { RunPlan, RunQuestion } from '@aivi/plugin/run';
|
|
24
|
+
import type { Platform, TrackerChange, TrackerComment, TrackerCommentKind, TrackerEvent, TrackerIssue, TrackerProgressLine, TrackerState, TrackerUpdate } from '@aivi/plugin/tracker';
|
|
25
|
+
import type { GithubClient } from './client.ts';
|
|
26
|
+
import { type GithubTrust } from './webhook.ts';
|
|
27
|
+
/** The app's own feed: desk calls and notices speak here — there is no
|
|
28
|
+
* session to name, and one app speaks for the home. */
|
|
29
|
+
export declare const GITHUB_FEED = "github";
|
|
30
|
+
export interface StatusBody {
|
|
31
|
+
head: string;
|
|
32
|
+
planLines: string[];
|
|
33
|
+
meta: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/** Read a status comment's body into its parts. A body with no hidden line
|
|
36
|
+
* (a person edited it away) still reads: the state defaults to a standing
|
|
37
|
+
* status, and the words are whatever stands. */
|
|
38
|
+
export declare function parseStatus(body: string): StatusBody;
|
|
39
|
+
/** The whole body from its parts: head line, plan section when there is
|
|
40
|
+
* one, and the hidden line last. */
|
|
41
|
+
export declare function renderStatus(head: string, planLines: string[], meta: Record<string, unknown>): string;
|
|
42
|
+
export declare class GithubPlatform implements Platform {
|
|
43
|
+
readonly id = "tracker-github";
|
|
44
|
+
private readonly loaded;
|
|
45
|
+
readonly client: GithubClient;
|
|
46
|
+
private readonly webhookSecret;
|
|
47
|
+
private readonly log;
|
|
48
|
+
private readonly deliveries;
|
|
49
|
+
private readonly sinks;
|
|
50
|
+
/** The divider line for comments (ruled 2026-10-07): who GitHub says may
|
|
51
|
+
* talk. Read per delivery from `author_association`; an untrusted comment
|
|
52
|
+
* is nothing — no event, no reply, no log line. */
|
|
53
|
+
private readonly trust;
|
|
54
|
+
/** Which board node ids aivi speaks, by board number — warmed at start.
|
|
55
|
+
* A `projects_v2_item` delivery names its board by node id and nothing
|
|
56
|
+
* else; a board nobody configured is noise and says nothing. */
|
|
57
|
+
private readonly boardNodes;
|
|
58
|
+
constructor(loaded: LoadedConfig, client: GithubClient, webhookSecret: string, log?: Logger, trust?: GithubTrust);
|
|
59
|
+
/** Resolve every configured board's fields once, at module start: it says
|
|
60
|
+
* of a missing `Status` or `Agent` field before any walk asks, and it
|
|
61
|
+
* teaches the delivery filter which boards are ours. */
|
|
62
|
+
warm(): Promise<void>;
|
|
63
|
+
/** Which board speaks for a repository's issues, and for a ticket by way
|
|
64
|
+
* of its repository. */
|
|
65
|
+
private boardOfRepo;
|
|
66
|
+
private boardOf;
|
|
67
|
+
laneStates(_conversation: string, teamId: string): Promise<TrackerState[]>;
|
|
68
|
+
/** `createStates` is deliberately absent (D4): lanes map onto the board's
|
|
69
|
+
* own Status options, and aivi never creates or renames one — the human's
|
|
70
|
+
* board is not aivi's to edit. */
|
|
71
|
+
apply(_conversation: string, issueId: string, update: TrackerUpdate): Promise<void>;
|
|
72
|
+
issue(_conversation: string, issueId: string): Promise<TrackerIssue>;
|
|
73
|
+
assign(_conversation: string, issueId: string, userId: string): Promise<void>;
|
|
74
|
+
unassign(_conversation: string, issueId: string): Promise<void>;
|
|
75
|
+
/** Open the run's conversation (D6): one status comment, the first word
|
|
76
|
+
* visible and the machine state hidden under it. Its id, with the ticket
|
|
77
|
+
* on it, is the conversation handle the module records. */
|
|
78
|
+
startSession(_conversation: string, issueId: string): Promise<string | null>;
|
|
79
|
+
idFor(sessionId: string): string;
|
|
80
|
+
parts(conversation: string): {
|
|
81
|
+
app: string;
|
|
82
|
+
session: string;
|
|
83
|
+
};
|
|
84
|
+
orgOf(_conversation: string): Promise<string>;
|
|
85
|
+
/** Who aivi is on a ticket: the `Agent` field's option named for the app
|
|
86
|
+
* (D5) — the same word on every board; per-board option ids are the
|
|
87
|
+
* client's resolution detail. */
|
|
88
|
+
ownerOf(_conversation: string): string;
|
|
89
|
+
/** One message into a conversation, and the conversation decides what a
|
|
90
|
+
* transient word **is**: inside a run it edits the status comment (no
|
|
91
|
+
* second comment may linger — transient by contract); on a ticket it is
|
|
92
|
+
* the assistant's placeholder, a real comment the engine may edit by id
|
|
93
|
+
* and delete when the answer lands. */
|
|
94
|
+
comment(conversation: string, text: string, kind: TrackerCommentKind): Promise<string | undefined>;
|
|
95
|
+
/** Replace the status comment's line — the only edit a progress word may
|
|
96
|
+
* make. A frozen comment (the closing landed) is left as it stands: the
|
|
97
|
+
* closing is the last word, and a trailing in-flight edit never speaks
|
|
98
|
+
* after it. */
|
|
99
|
+
progress(conversation: string, line: TrackerProgressLine): Promise<void>;
|
|
100
|
+
/** A run that died before it had a conversation still speaks on the
|
|
101
|
+
* ticket: silence is not an answer (ruled 2026-10-02). */
|
|
102
|
+
notify(_conversation: string, issueId: string, text: string): Promise<void>;
|
|
103
|
+
/** Has this run's result been rendered? GitHub keeps no session to ask:
|
|
104
|
+
* the answer is a comment on the ticket whose hidden line says
|
|
105
|
+
* `kind: closing` **and names this run's status comment** — so a catch-up
|
|
106
|
+
* says each run's result once, and a second run on the same ticket is a
|
|
107
|
+
* different conversation with its own result. */
|
|
108
|
+
resultShown(conversation: string): Promise<boolean>;
|
|
109
|
+
/** Put the worker's question to the people (required of every tracker):
|
|
110
|
+
* GitHub Issues renders a numbered comment; the answer arrives as an
|
|
111
|
+
* ordinary comment and the open OpenCode form tells it from chatter. */
|
|
112
|
+
ask(conversation: string, question: RunQuestion): Promise<void>;
|
|
113
|
+
/** Show the worker's plan: the whole checklist, replacing what stood, in
|
|
114
|
+
* the status comment's own plan section. */
|
|
115
|
+
plan(conversation: string, plan: RunPlan): Promise<void>;
|
|
116
|
+
ticketComments(_conversation: string, issueId: string): Promise<TrackerComment[]>;
|
|
117
|
+
addComment(_conversation: string, issueId: string, text: string): Promise<void>;
|
|
118
|
+
editIssue(_conversation: string, issueId: string, changes: {
|
|
119
|
+
title?: string;
|
|
120
|
+
description?: string;
|
|
121
|
+
}): Promise<TrackerIssue>;
|
|
122
|
+
createIssue(_conversation: string, input: {
|
|
123
|
+
teamId: string;
|
|
124
|
+
title: string;
|
|
125
|
+
description?: string;
|
|
126
|
+
lane?: string;
|
|
127
|
+
}): Promise<TrackerIssue>;
|
|
128
|
+
teamLabels(_conversation: string, teamId: string): Promise<string[]>;
|
|
129
|
+
events(sink: (event: TrackerEvent) => Promise<void>): () => void;
|
|
130
|
+
/** The run's final word: a comment of its own, tied to its status comment
|
|
131
|
+
* so `resultShown` can swear each run said its result once. */
|
|
132
|
+
markClosing(conversation: string, text: string, success: boolean): Promise<void>;
|
|
133
|
+
/** Freeze the status comment: the working line goes, the terminal word
|
|
134
|
+
* stands. Idempotent — a retry lands on an already-frozen comment as
|
|
135
|
+
* nothing. */
|
|
136
|
+
freezeStatus(conversation: string, head: string): Promise<void>;
|
|
137
|
+
/** One delivery, whole: the seal first (bytes, not re-serialized JSON),
|
|
138
|
+
* the redelivery filter second, then the acknowledgement — GitHub retries
|
|
139
|
+
* anything but a 200, and a handler that fails later must not invite a
|
|
140
|
+
* replay of what already ran. */
|
|
141
|
+
deliver(request: PublicRequest): Promise<{
|
|
142
|
+
status: number;
|
|
143
|
+
body: unknown;
|
|
144
|
+
}>;
|
|
145
|
+
/** Translate one delivery into normalized events — or into nothing.
|
|
146
|
+
* `ping`, `installation`, board reorderings, bot comments (aivi answering
|
|
147
|
+
* itself), and every action outside the routing set are noise (live,
|
|
148
|
+
* 2026-10-07). */
|
|
149
|
+
private dispatch;
|
|
150
|
+
private emit;
|
|
151
|
+
/** A GitHub issue in neutral words. The lane is the ticket's Status on the
|
|
152
|
+
* board its repository feeds; a repository outside every project has no
|
|
153
|
+
* lane and no delegate, and routing says so rather than inventing one. */
|
|
154
|
+
private neutralIssue;
|
|
155
|
+
/** The ticket and status-comment id behind a run conversation, or the
|
|
156
|
+
* plain error that this conversation is not one. */
|
|
157
|
+
runOf(conversation: string): {
|
|
158
|
+
ref: string;
|
|
159
|
+
commentId: number;
|
|
160
|
+
};
|
|
161
|
+
/** A ticket conversation's ref, if the conversation is one. */
|
|
162
|
+
ticketOf(conversation: string): string | undefined;
|
|
163
|
+
}
|
|
164
|
+
/** The neutral changes an `issues` action carries, or none. `assigned` and
|
|
165
|
+
* `unassigned` are **assignees** — a person's word on GitHub, not the
|
|
166
|
+
* delegate, which lives in the board's `Agent` field (D5). */
|
|
167
|
+
export declare function issueChanges(action: string): TrackerChange[];
|
|
168
|
+
/** The public route GitHub is pointed at — no `/app` tail: the seal and the
|
|
169
|
+
* one app make a second path meaningless. */
|
|
170
|
+
export declare const GITHUB_WEBHOOK_PATH = "/github/webhooks";
|
|
171
|
+
/** Own the route; the platform owns the seal, the dedupe and the words.
|
|
172
|
+
* Failures after the acknowledgement never ride the status: a retry would
|
|
173
|
+
* only replay what already ran. */
|
|
174
|
+
export declare function registerWebhookRoute(routes: PublicRoutes, platform: GithubPlatform, _log?: Logger): () => void;
|