@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/probe.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { PluginSetupContext, Store } from '@aivi/plugin';
|
|
2
|
+
export declare const probeWindowMs: () => number;
|
|
3
|
+
/** The events GitHub posts when a person installs the app or approves its
|
|
4
|
+
* grant — the `aivi add` probe's signal, replayed from the delivery history
|
|
5
|
+
* (an older install posts nothing while the installer watches). */
|
|
6
|
+
export declare const INSTALLATION_EVENTS: Set<string>;
|
|
7
|
+
/** One payload as the live line says it, in GitHub's own words, naming the
|
|
8
|
+
* ticket it is about — during a test several issues can be in play. The
|
|
9
|
+
* status our side answered with is left out on purpose: with the module not
|
|
10
|
+
* yet loaded nothing can answer 2xx, and the delivery arriving is the point. */
|
|
11
|
+
/** A webhook payload as the probe reads it: like tracker.ts, payloads are
|
|
12
|
+
* `unknown` until narrowed — the probe only ever asks for these fields.
|
|
13
|
+
* Exported because the waits' signatures name it (fallow private-type-leak). */
|
|
14
|
+
export type ProbePayload = {
|
|
15
|
+
action?: string;
|
|
16
|
+
changes?: {
|
|
17
|
+
field_name?: string;
|
|
18
|
+
field_value?: {
|
|
19
|
+
field_name?: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
projects_v2_item?: {
|
|
23
|
+
content_node_id?: string;
|
|
24
|
+
};
|
|
25
|
+
repository?: {
|
|
26
|
+
full_name?: string;
|
|
27
|
+
};
|
|
28
|
+
issue?: {
|
|
29
|
+
number?: number;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/** Whether the live aivi answers its health endpoint — the same test Linear's
|
|
33
|
+
* installer and the CLI make: `GET /health` on the **listen** URL, whatever
|
|
34
|
+
* its version. A raw-read config.json can lack the schema defaults, so the
|
|
35
|
+
* defaults are filled here: bind 127.0.0.1, port 4100. Pure of prompts, and
|
|
36
|
+
* `up` is re-asked on every retry. */
|
|
37
|
+
export declare function hostIsUp(fetch: (url: string, init?: RequestInit) => Promise<Response>, host: {
|
|
38
|
+
bind?: string | undefined;
|
|
39
|
+
port?: number | undefined;
|
|
40
|
+
} | undefined): Promise<boolean>;
|
|
41
|
+
/** The health gate as a choice (ruled 2026-10-07): silence is not a stop, it
|
|
42
|
+
* is a fork. Retry re-asks; continue skips the waits and says so — the
|
|
43
|
+
* installer still writes its configuration, with UNVERIFIED in the summary. */
|
|
44
|
+
export declare function gateHost(prompts: PluginSetupContext['prompts'], up: () => Promise<boolean>): Promise<boolean>;
|
|
45
|
+
/** The newest diary row id right now. Whatever exists before a test starts is
|
|
46
|
+
* nobody's business; anything the test draws lands with a larger id. */
|
|
47
|
+
export declare const latestId: (store: Store) => number;
|
|
48
|
+
/** What one wait saw in the diary. */
|
|
49
|
+
export interface Diaried {
|
|
50
|
+
/** Verified deliveries this wait accepts — its proof. */
|
|
51
|
+
mine: number;
|
|
52
|
+
/** Verified deliveries about anything else: the URL and secret work; the
|
|
53
|
+
* events ticked on the webhook decide what arrives. */
|
|
54
|
+
others: number;
|
|
55
|
+
/** The diary cursor to hand to the next wait. */
|
|
56
|
+
last: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Watch the diary until one verified delivery `accepts` lands, `about` names
|
|
60
|
+
* the thing under test, or `ms` has passed. A wait that passes is not made to
|
|
61
|
+
* sit out its window; the window is what a failure needs. Every verified
|
|
62
|
+
* delivery is said the moment it lands, whatever it names.
|
|
63
|
+
*/
|
|
64
|
+
export declare function waitDiary(store: Store, watch: {
|
|
65
|
+
path: string;
|
|
66
|
+
secret: string;
|
|
67
|
+
windowMs: number;
|
|
68
|
+
accepts: (event: string, payload: ProbePayload) => boolean;
|
|
69
|
+
about: (event: string, payload: ProbePayload) => boolean;
|
|
70
|
+
}, cursor: number, say: (line: string) => void): Promise<Diaried>;
|
|
71
|
+
/** Watch for the board probe's signals on one issue until all `asked` have
|
|
72
|
+
* landed or the window passes. Each landing is said the moment it arrives —
|
|
73
|
+
* the live line names what just came in. A signal already landed is never
|
|
74
|
+
* counted twice; the window is what a missing signal needs. */
|
|
75
|
+
export declare function waitForSignals(store: Store, watch: {
|
|
76
|
+
path: string;
|
|
77
|
+
secret: string;
|
|
78
|
+
windowMs: number;
|
|
79
|
+
asked: string[];
|
|
80
|
+
about: (event: string, payload: ProbePayload) => boolean;
|
|
81
|
+
}, cursor: number, say: (line: string) => void): Promise<{
|
|
82
|
+
landed: Set<string>;
|
|
83
|
+
last: number;
|
|
84
|
+
}>;
|
|
85
|
+
/** Whether a delivery is about the probe issue. `issue_comment` names it by
|
|
86
|
+
* `issue.number`; an `issues` delivery carries its number inside `issue`
|
|
87
|
+
* too — GitHub answers no top-level one (the live ledger says so). A
|
|
88
|
+
* `projects_v2_item` delivery names neither repo nor number: its payload
|
|
89
|
+
* carries no `repository` at all (keys: action, changes, installation,
|
|
90
|
+
* organization, projects_v2_item, sender — the live ledger says so), so
|
|
91
|
+
* the content's globally unique node id is the whole referee. Requiring a
|
|
92
|
+
* repository there filtered every board delivery as foreign and the wait
|
|
93
|
+
* sat out its window on signals it already held. */
|
|
94
|
+
export declare const aboutIssue: (repoId: string, number: number, issueNodeId: string) => (event: string, payload: ProbePayload) => boolean;
|
|
95
|
+
/** The board probe's verdict, pure: which signals landed, which did not, and
|
|
96
|
+
* what the missing one means. A signal never paid for (a board with one open
|
|
97
|
+
* Status option cannot show a move) is said as skipped, not as a failure. */
|
|
98
|
+
export declare function boardProbeVerdict(landed: Set<string>, asked: string[], windowMs: number): {
|
|
99
|
+
pass: boolean;
|
|
100
|
+
detail: string;
|
|
101
|
+
};
|
|
102
|
+
/** The remedy line when the health gate's choice was made and the waits were
|
|
103
|
+
* skipped: named once, in the summary, in the same words both installers use. */
|
|
104
|
+
export declare const UNVERIFIED = "UNVERIFIED \u2014 no live aivi watched, so \u201CGitHub can reach this machine\u201D was not proved";
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/** The probe machinery both installers share: the health gate with its choice,
|
|
2
|
+
* and the diary waits. The installer is a second process; it proves a webhook
|
|
3
|
+
* by watching what the **live aivi** records in its request diary — so a live
|
|
4
|
+
* aivi is needed for the waits, and the waits are what the choice is about:
|
|
5
|
+
*
|
|
6
|
+
* ```
|
|
7
|
+
* ● I started the server. Retry
|
|
8
|
+
* ○ Continue anyway — do not verify the webhook
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Continuing writes everything and says UNVERIFIED: the unverified fact is
|
|
12
|
+
* precisely “GitHub can reach this machine”, and it is said that way, never
|
|
13
|
+
* as a claimed success. Every delivery is verified with the module's own seal
|
|
14
|
+
* check before it counts: GitHub signs the raw bytes, and the diary stores
|
|
15
|
+
* what arrived. A delivery that fails the check ends the wait at once — the
|
|
16
|
+
* secret would be wrong for every delivery, so waiting cannot help.
|
|
17
|
+
*/
|
|
18
|
+
import { hostUrl } from '@aivi/core';
|
|
19
|
+
import { verifyWebhookSignature } from '@aivi/github';
|
|
20
|
+
/** How long the diary is watched when a check cannot pass: silence is a
|
|
21
|
+
* result too, and only a waited-out silence proves it. Tests shorten the
|
|
22
|
+
* window (AIVI_PROBE_WINDOW_MS, shared with the Linear installer); people
|
|
23
|
+
* get the honest cap. */
|
|
24
|
+
const DEFAULT_WINDOW_MS = 90_000;
|
|
25
|
+
export const probeWindowMs = () => Number(process.env.AIVI_PROBE_WINDOW_MS ?? DEFAULT_WINDOW_MS);
|
|
26
|
+
/** The installer is a second process and file events do not report SQLite's WAL
|
|
27
|
+
* writes (macOS never mentions them, measured 2026-09-26), so the diary is
|
|
28
|
+
* re-read on this quiet interval — one query on an indexed column. */
|
|
29
|
+
const POLL_MS = 2_000;
|
|
30
|
+
/** The events GitHub posts when a person installs the app or approves its
|
|
31
|
+
* grant — the `aivi add` probe's signal, replayed from the delivery history
|
|
32
|
+
* (an older install posts nothing while the installer watches). */
|
|
33
|
+
export const INSTALLATION_EVENTS = new Set(['installation', 'installation_repositories']);
|
|
34
|
+
const describeDelivery = (event, payload) => {
|
|
35
|
+
const p = (payload ?? {});
|
|
36
|
+
const number = p.issue?.number;
|
|
37
|
+
const at = p.repository?.full_name && number !== undefined
|
|
38
|
+
? `${p.repository.full_name}#${number}`
|
|
39
|
+
: 'something the payload did not name';
|
|
40
|
+
const action = typeof p.action === 'string' ? ` ${p.action}` : '';
|
|
41
|
+
return `${event}${action} on ${at}`;
|
|
42
|
+
};
|
|
43
|
+
/** Whether the live aivi answers its health endpoint — the same test Linear's
|
|
44
|
+
* installer and the CLI make: `GET /health` on the **listen** URL, whatever
|
|
45
|
+
* its version. A raw-read config.json can lack the schema defaults, so the
|
|
46
|
+
* defaults are filled here: bind 127.0.0.1, port 4100. Pure of prompts, and
|
|
47
|
+
* `up` is re-asked on every retry. */
|
|
48
|
+
export async function hostIsUp(fetch, host) {
|
|
49
|
+
const listen = hostUrl({ bind: host?.bind ?? '127.0.0.1', port: host?.port ?? 4100 });
|
|
50
|
+
return fetch(`${listen}/health`, { signal: AbortSignal.timeout(2_000) })
|
|
51
|
+
.then(response => response.ok)
|
|
52
|
+
.catch(() => false);
|
|
53
|
+
}
|
|
54
|
+
/** The health gate as a choice (ruled 2026-10-07): silence is not a stop, it
|
|
55
|
+
* is a fork. Retry re-asks; continue skips the waits and says so — the
|
|
56
|
+
* installer still writes its configuration, with UNVERIFIED in the summary. */
|
|
57
|
+
export async function gateHost(prompts, up) {
|
|
58
|
+
for (;;) {
|
|
59
|
+
if (await up())
|
|
60
|
+
return true;
|
|
61
|
+
const choice = await prompts.select({
|
|
62
|
+
message: 'No live aivi answers its health endpoint — the probe watches what a running aivi records',
|
|
63
|
+
options: [
|
|
64
|
+
{ value: 'retry', label: 'I started the server. Retry' },
|
|
65
|
+
{ value: 'skip', label: 'Continue anyway — do not verify the webhook' },
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
if (prompts.isCancel(choice))
|
|
69
|
+
throw new Error('the setup was cancelled at the health gate');
|
|
70
|
+
if (choice === 'skip') {
|
|
71
|
+
await prompts.log.message('Continuing unverified: the configuration is written, but “GitHub can reach this aivi” stays unproved.');
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The newest diary row id right now. Whatever exists before a test starts is
|
|
77
|
+
* nobody's business; anything the test draws lands with a larger id. */
|
|
78
|
+
export const latestId = (store) => {
|
|
79
|
+
let last = 0;
|
|
80
|
+
let rows = store.requests({ sinceId: last });
|
|
81
|
+
while (rows.length) {
|
|
82
|
+
last = rows[rows.length - 1].id;
|
|
83
|
+
rows = store.requests({ sinceId: last });
|
|
84
|
+
}
|
|
85
|
+
return last;
|
|
86
|
+
};
|
|
87
|
+
/** The seal failure line — same words wherever the seal fails: the secret
|
|
88
|
+
* aivi holds is not the secret GitHub sends, and waiting on that changes
|
|
89
|
+
* nothing, so every wait ends at once. */
|
|
90
|
+
const badSeal = (row) => new Error(`a delivery arrived but failed the module's own check: bad signature (HTTP ${row.status}).` +
|
|
91
|
+
' The secret this aivi holds is not the one GitHub sends — compare <home>/.env with the Secret on the app’s settings page.');
|
|
92
|
+
const verifyRow = (secret, row) => verifyWebhookSignature(secret, Buffer.from(row.body ?? '', 'utf8'), row.headers['x-hub-signature-256'] ?? '');
|
|
93
|
+
/** The seal check every wait runs on every row before anything else. A
|
|
94
|
+
* delivery that fails it ends the wait; the payload is the caller's to
|
|
95
|
+
* parse. */
|
|
96
|
+
function verifiedRow(secret, row) {
|
|
97
|
+
if (!verifyRow(secret, row))
|
|
98
|
+
throw badSeal(row);
|
|
99
|
+
return JSON.parse(row.body ?? '{}');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Watch the diary until one verified delivery `accepts` lands, `about` names
|
|
103
|
+
* the thing under test, or `ms` has passed. A wait that passes is not made to
|
|
104
|
+
* sit out its window; the window is what a failure needs. Every verified
|
|
105
|
+
* delivery is said the moment it lands, whatever it names.
|
|
106
|
+
*/
|
|
107
|
+
export async function waitDiary(store, watch, cursor, say) {
|
|
108
|
+
const { path, secret, windowMs, accepts, about } = watch;
|
|
109
|
+
let mine = 0;
|
|
110
|
+
let others = 0;
|
|
111
|
+
let last = cursor;
|
|
112
|
+
await new Promise((settledPromise, failed) => {
|
|
113
|
+
let done = false;
|
|
114
|
+
let poll;
|
|
115
|
+
let expiry;
|
|
116
|
+
const read = () => {
|
|
117
|
+
// `aivi host clear-logs` can empty the diary and restart its ids below
|
|
118
|
+
// our cursor; when that happens, follow the fresh diary, not the mark.
|
|
119
|
+
if (latestId(store) < last)
|
|
120
|
+
last = 0;
|
|
121
|
+
for (const row of store.requests({ method: 'POST', path, sinceId: last })) {
|
|
122
|
+
last = row.id;
|
|
123
|
+
if (row.truncated)
|
|
124
|
+
throw new Error(`a delivery arrived but its body is not readable JSON — the diary cut it off (row ${row.id}) — verification needs the whole body.`);
|
|
125
|
+
const payload = verifiedRow(secret, row);
|
|
126
|
+
const event = row.headers['x-github-event'] ?? '';
|
|
127
|
+
say(`Heard: ${describeDelivery(event, payload)}`); // every delivery, the moment it lands
|
|
128
|
+
if (!about(event, payload)) {
|
|
129
|
+
others++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// The proof is in: a passed wait is nobody's waiting room, and the
|
|
133
|
+
// stop's own read finishes nothing this loop has left to say.
|
|
134
|
+
if (accepts(event, payload)) {
|
|
135
|
+
mine++;
|
|
136
|
+
stop();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const stop = (error) => {
|
|
142
|
+
if (done)
|
|
143
|
+
return;
|
|
144
|
+
done = true;
|
|
145
|
+
if (poll !== undefined)
|
|
146
|
+
clearTimeout(poll);
|
|
147
|
+
if (expiry !== undefined)
|
|
148
|
+
clearTimeout(expiry);
|
|
149
|
+
if (error === undefined) {
|
|
150
|
+
try {
|
|
151
|
+
read(); // the deadline read: complete even if a tick was late
|
|
152
|
+
}
|
|
153
|
+
catch (cause) {
|
|
154
|
+
failed(cause);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
settledPromise();
|
|
158
|
+
}
|
|
159
|
+
else
|
|
160
|
+
failed(error);
|
|
161
|
+
};
|
|
162
|
+
const tick = () => {
|
|
163
|
+
try {
|
|
164
|
+
read();
|
|
165
|
+
}
|
|
166
|
+
catch (cause) {
|
|
167
|
+
stop(cause);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Self-rescheduling: one pending timer, always, and a slow read
|
|
171
|
+
// cannot pile up behind itself. And a read that ended the wait is
|
|
172
|
+
// never re-armed: no timer outlives the verdict.
|
|
173
|
+
if (done)
|
|
174
|
+
return;
|
|
175
|
+
poll = setTimeout(tick, POLL_MS);
|
|
176
|
+
};
|
|
177
|
+
poll = setTimeout(tick, POLL_MS); // the reliable arrival signal across processes: a re-read
|
|
178
|
+
expiry = setTimeout(() => stop(), windowMs); // the window's end: a known instant
|
|
179
|
+
try {
|
|
180
|
+
read(); // whatever already arrived before the watching began
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
stop(cause);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return { mine, others, last };
|
|
187
|
+
}
|
|
188
|
+
/** The board probe's five signals: the mutations the wizard pays, and what
|
|
189
|
+
* each one's delivery looks like. Names are verdict words; the wizard says
|
|
190
|
+
* which landed and which did not. */
|
|
191
|
+
const BOARD_SIGNALS = {
|
|
192
|
+
'issue opened': (event, payload) => event === 'issues' && payload?.action === 'opened',
|
|
193
|
+
'card added': (event, payload) => event === 'projects_v2_item' && payload?.action === 'created',
|
|
194
|
+
'Status moved': (event, payload) => event === 'projects_v2_item' &&
|
|
195
|
+
payload?.action === 'edited' &&
|
|
196
|
+
(payload?.changes?.field_name ?? payload?.changes?.field_value?.field_name) === 'Status',
|
|
197
|
+
'Agent set': (event, payload) => event === 'projects_v2_item' &&
|
|
198
|
+
payload?.action === 'edited' &&
|
|
199
|
+
(payload?.changes?.field_name ?? payload?.changes?.field_value?.field_name) === 'Agent',
|
|
200
|
+
'comment posted': (event, payload) => event === 'issue_comment' && payload?.action === 'created',
|
|
201
|
+
};
|
|
202
|
+
/** Watch for the board probe's signals on one issue until all `asked` have
|
|
203
|
+
* landed or the window passes. Each landing is said the moment it arrives —
|
|
204
|
+
* the live line names what just came in. A signal already landed is never
|
|
205
|
+
* counted twice; the window is what a missing signal needs. */
|
|
206
|
+
export async function waitForSignals(store, watch, cursor, say) {
|
|
207
|
+
const { path, secret, windowMs, asked, about } = watch;
|
|
208
|
+
const landed = new Set();
|
|
209
|
+
let last = cursor;
|
|
210
|
+
await new Promise((settledPromise, failed) => {
|
|
211
|
+
let done = false;
|
|
212
|
+
let poll;
|
|
213
|
+
let expiry;
|
|
214
|
+
const read = () => {
|
|
215
|
+
if (latestId(store) < last)
|
|
216
|
+
last = 0;
|
|
217
|
+
const rows = store.requests({ method: 'POST', path, sinceId: last });
|
|
218
|
+
if (rows.length)
|
|
219
|
+
last = rows[rows.length - 1].id;
|
|
220
|
+
// Newest first: the probe's own deliveries are the freshest rows
|
|
221
|
+
// above the cursor. A truncated row is skipped before parsing — a
|
|
222
|
+
// cut body cannot be parsed, and its seal could never verify.
|
|
223
|
+
for (const row of rows.reverse()) {
|
|
224
|
+
if (row.truncated)
|
|
225
|
+
continue;
|
|
226
|
+
const payload = JSON.parse(row.body ?? '{}');
|
|
227
|
+
const event = row.headers['x-github-event'] ?? '';
|
|
228
|
+
if (!about(event, payload))
|
|
229
|
+
continue; // about another issue: this test is not about it
|
|
230
|
+
if (!verifyRow(secret, row))
|
|
231
|
+
throw badSeal(row);
|
|
232
|
+
say(`Heard: ${describeDelivery(event, payload)}`);
|
|
233
|
+
for (const name of asked) {
|
|
234
|
+
if (!landed.has(name) && BOARD_SIGNALS[name](event, payload)) {
|
|
235
|
+
landed.add(name);
|
|
236
|
+
if (landed.size === asked.length) {
|
|
237
|
+
stop();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const stop = (error) => {
|
|
245
|
+
if (done)
|
|
246
|
+
return;
|
|
247
|
+
done = true;
|
|
248
|
+
if (poll !== undefined)
|
|
249
|
+
clearTimeout(poll);
|
|
250
|
+
if (expiry !== undefined)
|
|
251
|
+
clearTimeout(expiry);
|
|
252
|
+
if (error === undefined) {
|
|
253
|
+
try {
|
|
254
|
+
read();
|
|
255
|
+
}
|
|
256
|
+
catch (cause) {
|
|
257
|
+
failed(cause);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
settledPromise();
|
|
261
|
+
}
|
|
262
|
+
else
|
|
263
|
+
failed(error);
|
|
264
|
+
};
|
|
265
|
+
const tick = () => {
|
|
266
|
+
try {
|
|
267
|
+
read();
|
|
268
|
+
}
|
|
269
|
+
catch (cause) {
|
|
270
|
+
stop(cause);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (done)
|
|
274
|
+
return;
|
|
275
|
+
poll = setTimeout(tick, POLL_MS);
|
|
276
|
+
};
|
|
277
|
+
poll = setTimeout(tick, POLL_MS);
|
|
278
|
+
expiry = setTimeout(() => stop(), windowMs);
|
|
279
|
+
try {
|
|
280
|
+
read();
|
|
281
|
+
}
|
|
282
|
+
catch (cause) {
|
|
283
|
+
stop(cause);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return { landed, last };
|
|
287
|
+
}
|
|
288
|
+
/** Whether a delivery is about the probe issue. `issue_comment` names it by
|
|
289
|
+
* `issue.number`; an `issues` delivery carries its number inside `issue`
|
|
290
|
+
* too — GitHub answers no top-level one (the live ledger says so). A
|
|
291
|
+
* `projects_v2_item` delivery names neither repo nor number: its payload
|
|
292
|
+
* carries no `repository` at all (keys: action, changes, installation,
|
|
293
|
+
* organization, projects_v2_item, sender — the live ledger says so), so
|
|
294
|
+
* the content's globally unique node id is the whole referee. Requiring a
|
|
295
|
+
* repository there filtered every board delivery as foreign and the wait
|
|
296
|
+
* sat out its window on signals it already held. */
|
|
297
|
+
export const aboutIssue = (repoId, number, issueNodeId) => (event, payload) => event === 'projects_v2_item'
|
|
298
|
+
? payload?.projects_v2_item?.content_node_id === issueNodeId
|
|
299
|
+
: payload?.repository?.full_name === repoId && payload?.issue?.number === number;
|
|
300
|
+
/** The board probe's verdict, pure: which signals landed, which did not, and
|
|
301
|
+
* what the missing one means. A signal never paid for (a board with one open
|
|
302
|
+
* Status option cannot show a move) is said as skipped, not as a failure. */
|
|
303
|
+
export function boardProbeVerdict(landed, asked, windowMs) {
|
|
304
|
+
const missing = asked.filter(name => !landed.has(name));
|
|
305
|
+
if (!missing.length)
|
|
306
|
+
return { pass: true, detail: `all ${asked.length} deliveries arrived and verified — every seal matched` };
|
|
307
|
+
const detail = `${missing.join(', ')} did not arrive in ${windowMs / 1000} s — the events ticked on the app decide what GitHub posts, and GitHub must reach this machine`;
|
|
308
|
+
return { pass: false, detail };
|
|
309
|
+
}
|
|
310
|
+
/** The remedy line when the health gate's choice was made and the waits were
|
|
311
|
+
* skipped: named once, in the summary, in the same words both installers use. */
|
|
312
|
+
export const UNVERIFIED = 'UNVERIFIED — no live aivi watched, so “GitHub can reach this machine” was not proved';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type LoadedConfig, type Project, type ProjectLaneInput } from '@aivi/core';
|
|
2
|
+
import type { ProjectGithub } from './config.ts';
|
|
3
|
+
/** The project's GitHub routing as it takes effect: which board speaks for it
|
|
4
|
+
* and which repositories feed it. */
|
|
5
|
+
export declare function projectGithub(loaded: LoadedConfig, projectId: string): ProjectGithub | undefined;
|
|
6
|
+
/** A checkout receives one issue stream, so a repository may feed exactly one
|
|
7
|
+
* project's board. Said for every collision, not just the first. */
|
|
8
|
+
export declare function githubRepoCollisions(loaded: LoadedConfig): string[];
|
|
9
|
+
/** A project checkout and the GitHub routing that claims issues for it. */
|
|
10
|
+
export interface RoutedProject {
|
|
11
|
+
project: Project;
|
|
12
|
+
github: ProjectGithub;
|
|
13
|
+
}
|
|
14
|
+
/** Which aivi project an issue belongs to: by its repository. The board is
|
|
15
|
+
* read off the project's own section, so a ticket on a board the project
|
|
16
|
+
* does not name is not this project's ticket — that question is the board's
|
|
17
|
+
* (`ticketLane`), not the repository's. */
|
|
18
|
+
export declare function projectForIssue(loaded: LoadedConfig, issue: {
|
|
19
|
+
repo: string;
|
|
20
|
+
}): RoutedProject | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Point a project at its board and repositories, and optionally (re)write its
|
|
23
|
+
* lanes: the section goes to `projects.<id>.tracker-github`, lanes to the
|
|
24
|
+
* project's **core** `lanes` array — the ordered workflow, which core
|
|
25
|
+
* validates and the orchestrator decides moves from. Nothing else in the file
|
|
26
|
+
* is touched; an existing `lanes` stays as it was when none is passed. The
|
|
27
|
+
* written file must hold: the core config must still load, the section must
|
|
28
|
+
* parse against this plugin's own schema, and no repository may feed two
|
|
29
|
+
* projects — if any check fails, the previous bytes are restored and the
|
|
30
|
+
* error stands.
|
|
31
|
+
*/
|
|
32
|
+
export declare function writeProjectGithub(configPath: string, id: string, options: {
|
|
33
|
+
board: number;
|
|
34
|
+
repos: string[];
|
|
35
|
+
lanes?: ProjectLaneInput[];
|
|
36
|
+
}): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
board: number;
|
|
39
|
+
repos: string[];
|
|
40
|
+
lanes?: ProjectLaneInput[];
|
|
41
|
+
}>;
|
package/dist/projects.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** The GitHub tracker's project sections: the write path the `aivi projects`
|
|
2
|
+
* wizard uses and the routing the module consults. Repositories are GitHub's
|
|
3
|
+
* own; the **lanes are core's** — this file writes them into the project's
|
|
4
|
+
* core `lanes` array and reads nothing back from it. One board per project,
|
|
5
|
+
* one tracker per project (D1): the orchestrator's registration guard is the
|
|
6
|
+
* fatal one; what is checked here is that a repository feeds one board only.
|
|
7
|
+
*/
|
|
8
|
+
import { renameSync, rmSync } from 'node:fs';
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { loadConfig, PROJECT_ID } from '@aivi/core';
|
|
11
|
+
import { githubProjectSchema } from './config.js';
|
|
12
|
+
/** The module id, which is also the key of this plugin's section under every
|
|
13
|
+
* project and under `projectDefaults`. */
|
|
14
|
+
const SECTION = 'tracker-github';
|
|
15
|
+
/** The `tracker-github` section of a project entry as written — typed, or
|
|
16
|
+
* undefined. Core passes the section through; reading it is this plugin's
|
|
17
|
+
* business. */
|
|
18
|
+
const writtenGithub = (loaded, projectId) => loaded.config.projects[projectId]?.[SECTION];
|
|
19
|
+
/** The `tracker-github` section of `projectDefaults` as written. */
|
|
20
|
+
const writtenDefaults = (loaded) => loaded.config.projectDefaults[SECTION];
|
|
21
|
+
/** The project's GitHub routing as it takes effect: which board speaks for it
|
|
22
|
+
* and which repositories feed it. */
|
|
23
|
+
export function projectGithub(loaded, projectId) {
|
|
24
|
+
const entry = writtenGithub(loaded, projectId);
|
|
25
|
+
if (!entry)
|
|
26
|
+
return undefined;
|
|
27
|
+
const createLane = entry.createLane ?? writtenDefaults(loaded)?.createLane;
|
|
28
|
+
return { board: entry.board, repos: entry.repos, ...(createLane ? { createLane } : {}) };
|
|
29
|
+
}
|
|
30
|
+
/** A checkout receives one issue stream, so a repository may feed exactly one
|
|
31
|
+
* project's board. Said for every collision, not just the first. */
|
|
32
|
+
export function githubRepoCollisions(loaded) {
|
|
33
|
+
const owners = new Map();
|
|
34
|
+
const collisions = [];
|
|
35
|
+
for (const project of Object.keys(loaded.config.projects))
|
|
36
|
+
for (const repo of writtenGithub(loaded, project)?.repos ?? []) {
|
|
37
|
+
const owner = owners.get(repo);
|
|
38
|
+
if (owner && owner !== project)
|
|
39
|
+
collisions.push(`projects.${project}.${SECTION}.repos: ${repo} already feeds project ${owner}; a repository feeds one board`);
|
|
40
|
+
owners.set(repo, project);
|
|
41
|
+
}
|
|
42
|
+
return collisions;
|
|
43
|
+
}
|
|
44
|
+
/** Which aivi project an issue belongs to: by its repository. The board is
|
|
45
|
+
* read off the project's own section, so a ticket on a board the project
|
|
46
|
+
* does not name is not this project's ticket — that question is the board's
|
|
47
|
+
* (`ticketLane`), not the repository's. */
|
|
48
|
+
export function projectForIssue(loaded, issue) {
|
|
49
|
+
for (const project of loaded.projects) {
|
|
50
|
+
if (project.removed)
|
|
51
|
+
continue;
|
|
52
|
+
const github = projectGithub(loaded, project.id);
|
|
53
|
+
if (github?.repos.includes(issue.repo))
|
|
54
|
+
return { project, github };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Point a project at its board and repositories, and optionally (re)write its
|
|
60
|
+
* lanes: the section goes to `projects.<id>.tracker-github`, lanes to the
|
|
61
|
+
* project's **core** `lanes` array — the ordered workflow, which core
|
|
62
|
+
* validates and the orchestrator decides moves from. Nothing else in the file
|
|
63
|
+
* is touched; an existing `lanes` stays as it was when none is passed. The
|
|
64
|
+
* written file must hold: the core config must still load, the section must
|
|
65
|
+
* parse against this plugin's own schema, and no repository may feed two
|
|
66
|
+
* projects — if any check fails, the previous bytes are restored and the
|
|
67
|
+
* error stands.
|
|
68
|
+
*/
|
|
69
|
+
export async function writeProjectGithub(configPath, id, options) {
|
|
70
|
+
if (!PROJECT_ID.test(id))
|
|
71
|
+
throw new Error(`Project id "${id}" must match ${PROJECT_ID}`);
|
|
72
|
+
if (!options.repos.length)
|
|
73
|
+
throw new Error('Set at least one repository');
|
|
74
|
+
const before = await readFile(configPath, 'utf8');
|
|
75
|
+
const raw = JSON.parse(before);
|
|
76
|
+
const projects = (raw.projects ?? {});
|
|
77
|
+
raw.projects = projects;
|
|
78
|
+
const entry = (projects[id] ?? {});
|
|
79
|
+
projects[id] = entry;
|
|
80
|
+
const github = (entry[SECTION] ?? {});
|
|
81
|
+
entry[SECTION] = github;
|
|
82
|
+
github.board = options.board;
|
|
83
|
+
github.repos = options.repos;
|
|
84
|
+
if (options.lanes)
|
|
85
|
+
entry.lanes = options.lanes;
|
|
86
|
+
await writeWhole(configPath, `${JSON.stringify(raw, null, 2)}\n`);
|
|
87
|
+
try {
|
|
88
|
+
const loaded = await loadConfig(configPath);
|
|
89
|
+
githubProjectSchema.parse(github);
|
|
90
|
+
// A checkout receives one issue stream: the repositories just written may
|
|
91
|
+
// not feed another project's board too. Collisions between other projects
|
|
92
|
+
// are not this write's doing; the module's start check says those.
|
|
93
|
+
const mine = new Set(github.repos);
|
|
94
|
+
for (const other of Object.keys(loaded.config.projects)) {
|
|
95
|
+
if (other === id)
|
|
96
|
+
continue;
|
|
97
|
+
const shared = (writtenGithub(loaded, other)?.repos ?? []).filter(repo => mine.has(repo));
|
|
98
|
+
if (shared.length)
|
|
99
|
+
throw new Error(`Repository ${shared.join(', ')} already feeds project ${other}; a repository feeds one board`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
await writeWhole(configPath, before);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const lanes = entry.lanes;
|
|
107
|
+
return { id, board: options.board, repos: options.repos, ...(lanes ? { lanes } : {}) };
|
|
108
|
+
}
|
|
109
|
+
/** Bytes land whole or not at all: temp next door, rename over the live
|
|
110
|
+
* path. The restore-on-refusal pattern protects against a bad write; the
|
|
111
|
+
* temp-then-rename protects against a half one. */
|
|
112
|
+
async function writeWhole(path, contents) {
|
|
113
|
+
const temp = `${path}.aivi-tmp`;
|
|
114
|
+
rmSync(temp, { force: true });
|
|
115
|
+
await writeFile(temp, contents);
|
|
116
|
+
renameSync(temp, path);
|
|
117
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker run's **progress stream**: while a run works, its OpenCode
|
|
3
|
+
* session is mirrored into the ticket through the platform's transient
|
|
4
|
+
* progress surface (`Platform.progress`) — GitHub edits the run's status
|
|
5
|
+
* comment in place, the one line that says what the worker is doing right
|
|
6
|
+
* now (ruled 2026-10-02: ephemeral by contract — a replaced line must not
|
|
7
|
+
* linger, and GitHub has no ephemeral activity, so one comment edited is the
|
|
8
|
+
* honest equivalent). The reducer and the renderer are the host's own
|
|
9
|
+
* channel machinery, so the worker's line and a chat placeholder say the
|
|
10
|
+
* same thing in the same words.
|
|
11
|
+
*
|
|
12
|
+
* No polling and no interval: besides the throttle's trailing edge, the
|
|
13
|
+
* only timer waits for the next instant the rendered line changes on its
|
|
14
|
+
* own (the long-turn suffix, the idle notice, an elapsed refresh). The
|
|
15
|
+
* follower pauses while a question awaits a person — nothing happens then —
|
|
16
|
+
* and resumes when the answer lands.
|
|
17
|
+
*/
|
|
18
|
+
import type { Logger } from '@aivi/core';
|
|
19
|
+
import { type ProgressClock } from '@aivi/host';
|
|
20
|
+
import type { SessionEvents } from '@aivi/plugin/module';
|
|
21
|
+
import type { Platform } from '@aivi/plugin/tracker';
|
|
22
|
+
export interface RunProgressOptions {
|
|
23
|
+
/** `status` shows the one line; `tools` lets a running tool speak in the line. */
|
|
24
|
+
mode: 'status' | 'tools';
|
|
25
|
+
events: SessionEvents;
|
|
26
|
+
log: Logger;
|
|
27
|
+
/** At most one edit per window: the first at once, later ones coalesced. */
|
|
28
|
+
throttleMs?: number;
|
|
29
|
+
clock?: Partial<ProgressClock>;
|
|
30
|
+
}
|
|
31
|
+
export declare class RunProgress {
|
|
32
|
+
private readonly platform;
|
|
33
|
+
private readonly conversation;
|
|
34
|
+
private readonly sessionId;
|
|
35
|
+
private readonly mode;
|
|
36
|
+
private readonly events;
|
|
37
|
+
private readonly log;
|
|
38
|
+
private readonly throttleMs;
|
|
39
|
+
private readonly clock;
|
|
40
|
+
private state;
|
|
41
|
+
private shown;
|
|
42
|
+
private lastPostAt;
|
|
43
|
+
private unwatch;
|
|
44
|
+
private trailing;
|
|
45
|
+
private change;
|
|
46
|
+
private inflight;
|
|
47
|
+
private following;
|
|
48
|
+
constructor(platform: Platform, conversation: string, sessionId: string, options: RunProgressOptions);
|
|
49
|
+
/** Follow the session: watch and arm. Idempotent; a stopped follower
|
|
50
|
+
* resumes with its history — the elapsed line keeps counting, because
|
|
51
|
+
* the worker did. */
|
|
52
|
+
start(): void;
|
|
53
|
+
/** Say nothing more: unwatch, disarm, let the in-flight edit land. */
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
/** Wait for the next instant the line changes by itself. */
|
|
56
|
+
private arm;
|
|
57
|
+
private schedule;
|
|
58
|
+
/** One edit per visible change — and only when the text actually
|
|
59
|
+
* changed, so the stream says each moment once. A running tool is
|
|
60
|
+
* offered as a tool; a `status`-mode follower keeps it inside the line. */
|
|
61
|
+
private flush;
|
|
62
|
+
}
|