@ravshansbox/pi-next-issue 2.21.2 → 3.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/index.ts +84 -291
- package/package.json +1 -1
- package/src/answer.ts +89 -0
- package/src/clients.ts +14 -0
- package/src/errors.ts +3 -0
- package/src/exec.ts +3 -4
- package/src/protocol.ts +93 -0
- package/src/requests.ts +56 -0
- package/src/run.ts +51 -0
- package/src/spawn-worker.ts +32 -0
- package/src/status.ts +97 -0
- package/src/worker.ts +84 -162
package/index.ts
CHANGED
|
@@ -1,102 +1,41 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
1
|
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
5
4
|
import { Type } from 'typebox';
|
|
6
|
-
import {
|
|
5
|
+
import { answerAll, type Gh } from './src/answer.ts';
|
|
6
|
+
import { shared } from './src/clients.ts';
|
|
7
|
+
import { errorText } from './src/errors.ts';
|
|
8
|
+
import { must } from './src/exec.ts';
|
|
7
9
|
import { repoRoot } from './src/git.ts';
|
|
10
|
+
import type { Progress, Question } from './src/protocol.ts';
|
|
11
|
+
import { startRun, statusOf, toggle } from './src/run.ts';
|
|
8
12
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
type
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
cannotNote,
|
|
14
|
+
formatQuestions,
|
|
15
|
+
formatStatus,
|
|
16
|
+
handledNotes,
|
|
17
|
+
type Note,
|
|
18
|
+
questionNotes,
|
|
19
|
+
statusNote,
|
|
20
|
+
} from './src/status.ts';
|
|
21
|
+
import { spawnWorker, workerPaths } from './src/spawn-worker.ts';
|
|
22
|
+
import { WorkerClient, type WorkerEvent } from './src/worker.ts';
|
|
16
23
|
|
|
17
24
|
export type Options = {
|
|
18
25
|
client: (root: string) => WorkerClient;
|
|
19
26
|
root: (cwd: string) => Promise<string>;
|
|
20
|
-
gh:
|
|
27
|
+
gh: Gh;
|
|
21
28
|
};
|
|
22
29
|
|
|
23
|
-
const KEY = Symbol.for('pi-next-issue');
|
|
24
30
|
const STATUS_KEY = 'next-issue';
|
|
25
31
|
const QUESTIONS_KEY = 'next-issue-questions';
|
|
32
|
+
const RENDER_MS = 1000;
|
|
26
33
|
const QUESTIONS_MS = 60_000;
|
|
27
|
-
const RETRY = 'Retry';
|
|
28
|
-
const GUIDE = 'Retry with guidance';
|
|
29
|
-
const GIVE_UP = 'Give up';
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
function errorText(error: unknown): string {
|
|
36
|
-
return error instanceof Error ? error.message : String(error);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function shared(create: (root: string) => WorkerClient, root: string): WorkerClient {
|
|
40
|
-
const store = globalThis as Record<symbol, Map<string, WorkerClient> | undefined>;
|
|
41
|
-
const clients = (store[KEY] ??= new Map());
|
|
42
|
-
let client = clients.get(root);
|
|
43
|
-
if (client === undefined) {
|
|
44
|
-
client = create(root);
|
|
45
|
-
clients.set(root, client);
|
|
46
|
-
}
|
|
47
|
-
return client;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function duration(ms: number): string {
|
|
51
|
-
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
52
|
-
const minutes = Math.floor(seconds / 60);
|
|
53
|
-
return minutes > 0 ? `${minutes}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function outcomes(progress: Progress): string {
|
|
57
|
-
const done = progress.handled.filter((item) => item.outcome === 'done').length;
|
|
58
|
-
const human = progress.handled.filter((item) => item.outcome === 'needs-human').length;
|
|
59
|
-
const failed = progress.handled.filter((item) => item.outcome === 'error').length;
|
|
60
|
-
const parts = [`${done} done`];
|
|
61
|
-
if (human > 0) {
|
|
62
|
-
parts.push(`${human} needs a human`);
|
|
63
|
-
}
|
|
64
|
-
if (failed > 0) {
|
|
65
|
-
parts.push(`${failed} failed`);
|
|
66
|
-
}
|
|
67
|
-
return parts.join(', ');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const OUTCOME_NOTES = { done: 'done', 'needs-human': 'needs a human', error: 'failed' } as const;
|
|
71
|
-
|
|
72
|
-
export function formatStatus(progress: Progress, now: number): string {
|
|
73
|
-
const running = progress.issues ?? [];
|
|
74
|
-
switch (progress.status) {
|
|
75
|
-
case 'running': {
|
|
76
|
-
if (running.length === 0) {
|
|
77
|
-
return 'looking for issues';
|
|
78
|
-
}
|
|
79
|
-
const count = progress.open === undefined ? '' : `/${progress.open}`;
|
|
80
|
-
const lines = running.map((item) => {
|
|
81
|
-
const parts = [`#${item.issue}${count}`, item.stage ?? 'prepare', `${item.tools} tools`];
|
|
82
|
-
if (item.startedAt !== undefined) {
|
|
83
|
-
parts.push(duration(now - item.startedAt));
|
|
84
|
-
}
|
|
85
|
-
return parts.join(' · ');
|
|
86
|
-
});
|
|
87
|
-
return lines.join(' | ');
|
|
88
|
-
}
|
|
89
|
-
case 'stopping': {
|
|
90
|
-
const issues = running.map((item) => `#${item.issue}`);
|
|
91
|
-
return `${issues.length === 0 ? '#?' : issues.join(' ')} · stopping`;
|
|
92
|
-
}
|
|
93
|
-
case 'stopped':
|
|
94
|
-
return `stopped: ${outcomes(progress)}`;
|
|
95
|
-
case 'finished':
|
|
96
|
-
return `finished: ${outcomes(progress)}`;
|
|
97
|
-
case 'failed':
|
|
98
|
-
return `failed: ${progress.error ?? 'unknown error'}`;
|
|
99
|
-
}
|
|
35
|
+
function background(callback: () => void, ms: number): ReturnType<typeof setInterval> {
|
|
36
|
+
const timer = setInterval(callback, ms);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
return timer;
|
|
100
39
|
}
|
|
101
40
|
|
|
102
41
|
function packageVersion(): string {
|
|
@@ -128,15 +67,23 @@ export function createExtension(options: Options) {
|
|
|
128
67
|
let status: Progress['status'] | undefined;
|
|
129
68
|
let baseline = false;
|
|
130
69
|
|
|
70
|
+
const clientOf = (root: string) => shared(options.client, root);
|
|
71
|
+
|
|
131
72
|
const current = (): Progress | undefined =>
|
|
132
|
-
root === undefined ? undefined :
|
|
73
|
+
root === undefined ? undefined : clientOf(root).runs[root];
|
|
74
|
+
|
|
75
|
+
const remember = (progress: Progress | undefined) => {
|
|
76
|
+
handled = progress?.handled.length ?? 0;
|
|
77
|
+
status = progress?.status;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const dim = (text: string) => (coloured && ui !== undefined ? ui.theme.fg('dim', text) : text);
|
|
133
81
|
|
|
134
82
|
const render = () => {
|
|
135
83
|
const progress = current();
|
|
136
84
|
if (ui === undefined || progress === undefined) {
|
|
137
85
|
return;
|
|
138
86
|
}
|
|
139
|
-
const dim = (text: string) => (coloured ? ui!.theme.fg('dim', text) : text);
|
|
140
87
|
ui.setStatus(STATUS_KEY, dim(formatStatus(progress, Date.now())));
|
|
141
88
|
};
|
|
142
89
|
|
|
@@ -144,71 +91,71 @@ export function createExtension(options: Options) {
|
|
|
144
91
|
if (ui === undefined || waiting === undefined) {
|
|
145
92
|
return;
|
|
146
93
|
}
|
|
147
|
-
const text = formatQuestions(waiting.length);
|
|
148
94
|
ui.setStatus(
|
|
149
95
|
QUESTIONS_KEY,
|
|
150
|
-
waiting.length === 0 ? undefined :
|
|
96
|
+
waiting.length === 0 ? undefined : dim(formatQuestions(waiting.length)),
|
|
151
97
|
);
|
|
152
98
|
};
|
|
153
99
|
|
|
100
|
+
const tell = (note: Note | undefined) => {
|
|
101
|
+
if (note !== undefined) {
|
|
102
|
+
ui?.notify(note.text, note.level);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
154
106
|
const onQuestions = (questions: Question[]) => {
|
|
155
107
|
const known = waiting;
|
|
156
108
|
waiting = questions.map((question) => question.issue);
|
|
157
|
-
|
|
158
|
-
if (known !== undefined && !known.includes(question.issue)) {
|
|
159
|
-
ui?.notify(`next-issue: #${question.issue} has a question: ${question.title}`, 'warning');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
109
|
+
questionNotes(questions, known).forEach(tell);
|
|
162
110
|
paintQuestions();
|
|
163
111
|
};
|
|
164
112
|
|
|
165
113
|
const askQuestions = () => {
|
|
166
114
|
if (root !== undefined) {
|
|
167
|
-
|
|
115
|
+
clientOf(root).send({ type: 'questions', root });
|
|
168
116
|
}
|
|
169
117
|
};
|
|
170
118
|
|
|
171
|
-
const
|
|
172
|
-
if (progress ===
|
|
119
|
+
const announce = (progress: Progress) => {
|
|
120
|
+
if (progress.status === status) {
|
|
173
121
|
return;
|
|
174
122
|
}
|
|
123
|
+
status = progress.status;
|
|
124
|
+
tell(statusNote(progress));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const notifyHandled = (progress: Progress) => {
|
|
175
128
|
if (progress.handled.length < handled) {
|
|
176
129
|
handled = 0;
|
|
177
130
|
}
|
|
178
|
-
|
|
179
|
-
ui?.notify(
|
|
180
|
-
`next-issue: #${item.issue} ${OUTCOME_NOTES[item.outcome]}`,
|
|
181
|
-
item.outcome === 'done' ? 'info' : 'warning',
|
|
182
|
-
);
|
|
183
|
-
}
|
|
131
|
+
handledNotes(progress, handled).forEach(tell);
|
|
184
132
|
handled = progress.handled.length;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
ui?.notify('next-issue stopped', 'info');
|
|
191
|
-
} else if (status === 'failed') {
|
|
192
|
-
ui?.notify(`next-issue failed: ${progress.error ?? 'unknown error'}`, 'error');
|
|
193
|
-
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const onProgress = (progress: Progress | undefined) => {
|
|
136
|
+
if (progress === undefined) {
|
|
137
|
+
return;
|
|
194
138
|
}
|
|
139
|
+
notifyHandled(progress);
|
|
140
|
+
announce(progress);
|
|
195
141
|
render();
|
|
196
142
|
};
|
|
197
143
|
|
|
198
144
|
const onEvent = (event: WorkerEvent) => {
|
|
199
|
-
if (event.type === 'runs'
|
|
200
|
-
baseline = false;
|
|
145
|
+
if (event.type === 'runs') {
|
|
201
146
|
const progress = root === undefined ? undefined : event.runs[root];
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
147
|
+
if (baseline) {
|
|
148
|
+
baseline = false;
|
|
149
|
+
remember(progress);
|
|
150
|
+
render();
|
|
151
|
+
askQuestions();
|
|
152
|
+
} else {
|
|
153
|
+
onProgress(progress);
|
|
154
|
+
}
|
|
208
155
|
} else if (event.type === 'questions' && event.root === root) {
|
|
209
156
|
onQuestions(event.questions);
|
|
210
157
|
} else if (event.type === 'error' && event.root === root) {
|
|
211
|
-
|
|
158
|
+
tell(cannotNote('start', event.message));
|
|
212
159
|
}
|
|
213
160
|
};
|
|
214
161
|
|
|
@@ -217,10 +164,8 @@ export function createExtension(options: Options) {
|
|
|
217
164
|
if (root === undefined) {
|
|
218
165
|
return;
|
|
219
166
|
}
|
|
220
|
-
const client =
|
|
221
|
-
|
|
222
|
-
handled = progress?.handled.length ?? 0;
|
|
223
|
-
status = progress?.status;
|
|
167
|
+
const client = clientOf(root);
|
|
168
|
+
remember(current());
|
|
224
169
|
waiting = client.questions[root]?.map((question) => question.issue);
|
|
225
170
|
baseline = !client.connected;
|
|
226
171
|
unsubscribe = client.subscribe(onEvent);
|
|
@@ -234,10 +179,8 @@ export function createExtension(options: Options) {
|
|
|
234
179
|
const show = (ctx: ExtensionContext) => {
|
|
235
180
|
ui = ctx.ui;
|
|
236
181
|
coloured = ctx.mode === 'tui';
|
|
237
|
-
timer ??=
|
|
238
|
-
|
|
239
|
-
poll ??= setInterval(askQuestions, QUESTIONS_MS);
|
|
240
|
-
poll.unref?.();
|
|
182
|
+
timer ??= background(render, RENDER_MS);
|
|
183
|
+
poll ??= background(askQuestions, QUESTIONS_MS);
|
|
241
184
|
};
|
|
242
185
|
|
|
243
186
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -250,20 +193,16 @@ export function createExtension(options: Options) {
|
|
|
250
193
|
return;
|
|
251
194
|
}
|
|
252
195
|
follow();
|
|
253
|
-
await
|
|
196
|
+
await clientOf(root).attach();
|
|
254
197
|
});
|
|
255
198
|
|
|
256
199
|
pi.on('session_shutdown', async () => {
|
|
257
200
|
unsubscribe?.();
|
|
258
201
|
unsubscribe = undefined;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (poll !== undefined) {
|
|
264
|
-
clearInterval(poll);
|
|
265
|
-
poll = undefined;
|
|
266
|
-
}
|
|
202
|
+
clearInterval(timer);
|
|
203
|
+
timer = undefined;
|
|
204
|
+
clearInterval(poll);
|
|
205
|
+
poll = undefined;
|
|
267
206
|
ui = undefined;
|
|
268
207
|
});
|
|
269
208
|
|
|
@@ -273,42 +212,11 @@ export function createExtension(options: Options) {
|
|
|
273
212
|
show(ctx);
|
|
274
213
|
follow();
|
|
275
214
|
}
|
|
276
|
-
const client =
|
|
215
|
+
const client = clientOf(root);
|
|
277
216
|
await client.ensure();
|
|
278
217
|
return { client, root, progress: client.runs[root] };
|
|
279
218
|
};
|
|
280
219
|
|
|
281
|
-
const going = (progress: Progress | undefined): progress is Progress =>
|
|
282
|
-
progress?.status === 'running' || progress?.status === 'stopping';
|
|
283
|
-
|
|
284
|
-
const accepted = (client: WorkerClient, root: string, signal: AbortSignal | undefined) =>
|
|
285
|
-
new Promise<void>((resolve, reject) => {
|
|
286
|
-
const finish = (error?: Error) => {
|
|
287
|
-
stop();
|
|
288
|
-
signal?.removeEventListener('abort', abort);
|
|
289
|
-
if (error === undefined) {
|
|
290
|
-
resolve();
|
|
291
|
-
} else {
|
|
292
|
-
reject(error);
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
const abort = () => finish(new Error('next_issue_start was cancelled'));
|
|
296
|
-
const stop = client.subscribe((event) => {
|
|
297
|
-
if (event.type === 'error' && event.root === root) {
|
|
298
|
-
finish(new Error(event.message));
|
|
299
|
-
} else if (event.type === 'closed') {
|
|
300
|
-
finish(new Error('the worker closed the connection'));
|
|
301
|
-
} else if (event.type === 'runs' && going(event.runs[root])) {
|
|
302
|
-
finish();
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
if (signal?.aborted) {
|
|
306
|
-
abort();
|
|
307
|
-
} else {
|
|
308
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
|
|
312
220
|
pi.registerTool({
|
|
313
221
|
name: 'next_issue_start',
|
|
314
222
|
label: 'Start next-issue',
|
|
@@ -318,32 +226,10 @@ export function createExtension(options: Options) {
|
|
|
318
226
|
parameters: Type.Object({}),
|
|
319
227
|
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
|
|
320
228
|
const { client, root, progress } = await connect(ctx);
|
|
321
|
-
|
|
322
|
-
return {
|
|
323
|
-
content: [
|
|
324
|
-
{
|
|
325
|
-
type: 'text',
|
|
326
|
-
text: `A run of ${root} is going already, so nothing started: ${formatStatus(progress, Date.now())}. It picks up new issues by itself.`,
|
|
327
|
-
},
|
|
328
|
-
],
|
|
329
|
-
details: { started: false, root },
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
const accepting = accepted(client, root, signal);
|
|
333
|
-
client.send({ type: 'start', root, merge: true });
|
|
334
|
-
await accepting;
|
|
335
|
-
return {
|
|
336
|
-
content: [{ type: 'text', text: `Started a next-issue run of ${root}.` }],
|
|
337
|
-
details: { started: true, root },
|
|
338
|
-
};
|
|
229
|
+
return startRun(client, root, progress, signal);
|
|
339
230
|
},
|
|
340
231
|
});
|
|
341
232
|
|
|
342
|
-
const report = (text: string, root: string) => ({
|
|
343
|
-
content: [{ type: 'text' as const, text }],
|
|
344
|
-
details: { root },
|
|
345
|
-
});
|
|
346
|
-
|
|
347
233
|
pi.registerTool({
|
|
348
234
|
name: 'next_issue_status',
|
|
349
235
|
label: 'next-issue status',
|
|
@@ -353,120 +239,27 @@ export function createExtension(options: Options) {
|
|
|
353
239
|
parameters: Type.Object({}),
|
|
354
240
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
355
241
|
const root = await options.root(ctx.cwd);
|
|
356
|
-
|
|
357
|
-
await client.attach();
|
|
358
|
-
if (!client.connected) {
|
|
359
|
-
return report('no worker is running', root);
|
|
360
|
-
}
|
|
361
|
-
const progress = client.runs[root];
|
|
362
|
-
if (progress === undefined) {
|
|
363
|
-
return report(`no run of ${root}`, root);
|
|
364
|
-
}
|
|
365
|
-
const lines = [
|
|
366
|
-
formatStatus(progress, Date.now()),
|
|
367
|
-
...progress.handled.map((item) => `#${item.issue} ${OUTCOME_NOTES[item.outcome]}`),
|
|
368
|
-
];
|
|
369
|
-
return report(lines.join('\n'), root);
|
|
242
|
+
return statusOf(clientOf(root), root);
|
|
370
243
|
},
|
|
371
244
|
});
|
|
372
245
|
|
|
373
|
-
const fresh = (client: WorkerClient, root: string) =>
|
|
374
|
-
new Promise<Question[]>((resolve, reject) => {
|
|
375
|
-
const id = randomUUID();
|
|
376
|
-
const stop = client.subscribe((event) => {
|
|
377
|
-
if (event.type === 'questions' && event.root === root && event.id === id) {
|
|
378
|
-
stop();
|
|
379
|
-
resolve(event.questions);
|
|
380
|
-
} else if (event.type === 'questions-error' && event.root === root && event.id === id) {
|
|
381
|
-
stop();
|
|
382
|
-
reject(new Error(event.message));
|
|
383
|
-
} else if (event.type === 'closed') {
|
|
384
|
-
stop();
|
|
385
|
-
reject(new Error('the worker closed the connection'));
|
|
386
|
-
}
|
|
387
|
-
});
|
|
388
|
-
client.send({ type: 'questions', root, id });
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
const reply = async (root: string, question: Question, choice: string, guidance: string) => {
|
|
392
|
-
const threads: Array<[string, number]> = [['issue', question.issue]];
|
|
393
|
-
if (question.pr !== undefined) {
|
|
394
|
-
threads.push(['pr', question.pr]);
|
|
395
|
-
}
|
|
396
|
-
const repo = ['--repo', question.repo];
|
|
397
|
-
if (choice === GUIDE) {
|
|
398
|
-
const [kind, number] = threads.at(-1)!;
|
|
399
|
-
await options.gh([kind, 'comment', String(number), ...repo, '--body-file', '-'], {
|
|
400
|
-
cwd: root,
|
|
401
|
-
input: guidance,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
const labels = ['--remove-label', question.labels.needsHuman];
|
|
405
|
-
if (choice === GIVE_UP) {
|
|
406
|
-
labels.push('--add-label', question.labels.skip);
|
|
407
|
-
}
|
|
408
|
-
for (const [kind, number] of threads) {
|
|
409
|
-
await options.gh([kind, 'edit', String(number), ...repo, ...labels], { cwd: root });
|
|
410
|
-
}
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const answer = async (ctx: ExtensionContext) => {
|
|
414
|
-
const { client, root } = await connect(ctx);
|
|
415
|
-
const passed = new Set<number>();
|
|
416
|
-
for (;;) {
|
|
417
|
-
const question = (await fresh(client, root)).find((item) => !passed.has(item.issue));
|
|
418
|
-
if (question === undefined) {
|
|
419
|
-
if (passed.size === 0) {
|
|
420
|
-
ctx.ui.notify('next-issue: no questions waiting', 'info');
|
|
421
|
-
}
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
passed.add(question.issue);
|
|
425
|
-
const choice = await ctx.ui.select(
|
|
426
|
-
`#${question.issue} ${question.title}\n\n${question.reason || 'No reason given.'}`,
|
|
427
|
-
[RETRY, GUIDE, GIVE_UP],
|
|
428
|
-
);
|
|
429
|
-
if (choice === undefined) {
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
let guidance = '';
|
|
433
|
-
if (choice === GUIDE) {
|
|
434
|
-
guidance = (await ctx.ui.editor(`Guidance for #${question.issue}`))?.trim() ?? '';
|
|
435
|
-
if (guidance === '') {
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
await reply(root, question, choice, guidance);
|
|
440
|
-
ctx.ui.notify(
|
|
441
|
-
`next-issue: #${question.issue} ${choice === GIVE_UP ? 'given up' : 'will be retried'}`,
|
|
442
|
-
'info',
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
};
|
|
446
|
-
|
|
447
246
|
pi.registerCommand('next-issue', {
|
|
448
247
|
description:
|
|
449
248
|
'Start or stop working through the GitHub issues of this repository, or answer the waiting questions with "answer"',
|
|
450
249
|
getArgumentCompletions: (prefix) =>
|
|
451
250
|
'answer'.startsWith(prefix) ? [{ value: 'answer', label: 'answer' }] : null,
|
|
452
251
|
handler: async (args, ctx) => {
|
|
453
|
-
|
|
454
|
-
try {
|
|
455
|
-
await answer(ctx);
|
|
456
|
-
} catch (error) {
|
|
457
|
-
ctx.ui.notify(`next-issue cannot answer: ${errorText(error)}`, 'error');
|
|
458
|
-
}
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
252
|
+
const answering = args.trim() === 'answer';
|
|
461
253
|
try {
|
|
462
254
|
const { client, root, progress } = await connect(ctx);
|
|
463
|
-
if (
|
|
464
|
-
|
|
255
|
+
if (answering) {
|
|
256
|
+
await answerAll(ctx.ui, options.gh, client, root);
|
|
465
257
|
} else {
|
|
466
|
-
client
|
|
258
|
+
toggle(client, root, progress);
|
|
467
259
|
}
|
|
468
260
|
} catch (error) {
|
|
469
|
-
|
|
261
|
+
const note = cannotNote(answering ? 'answer' : 'start', errorText(error));
|
|
262
|
+
ctx.ui.notify(note.text, note.level);
|
|
470
263
|
}
|
|
471
264
|
},
|
|
472
265
|
});
|
package/package.json
CHANGED
package/src/answer.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { RunOptions } from './exec.ts';
|
|
3
|
+
import type { Question } from './protocol.ts';
|
|
4
|
+
import { fresh } from './requests.ts';
|
|
5
|
+
import type { WorkerClient } from './worker.ts';
|
|
6
|
+
|
|
7
|
+
export type Gh = (args: string[], options: RunOptions) => Promise<string>;
|
|
8
|
+
|
|
9
|
+
const RETRY = 'Retry';
|
|
10
|
+
const GUIDE = 'Retry with guidance';
|
|
11
|
+
const GIVE_UP = 'Give up';
|
|
12
|
+
|
|
13
|
+
type Answer = { choice: string; guidance: string };
|
|
14
|
+
|
|
15
|
+
function threadsOf(question: Question): Array<[string, number]> {
|
|
16
|
+
const issue: [string, number] = ['issue', question.issue];
|
|
17
|
+
return question.pr === undefined ? [issue] : [issue, ['pr', question.pr]];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function promptOf(question: Question): string {
|
|
21
|
+
return `#${question.issue} ${question.title}\n\n${question.reason || 'No reason given.'}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function answeredOf(question: Question, choice: string): string {
|
|
25
|
+
return `next-issue: #${question.issue} ${choice === GIVE_UP ? 'given up' : 'will be retried'}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function labelsOf(question: Question, choice: string): string[] {
|
|
29
|
+
const remove = ['--remove-label', question.labels.needsHuman];
|
|
30
|
+
return choice === GIVE_UP ? [...remove, '--add-label', question.labels.skip] : remove;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function reply(
|
|
34
|
+
gh: Gh,
|
|
35
|
+
root: string,
|
|
36
|
+
question: Question,
|
|
37
|
+
{ choice, guidance }: Answer,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const threads = threadsOf(question);
|
|
40
|
+
const repo = ['--repo', question.repo];
|
|
41
|
+
if (choice === GUIDE) {
|
|
42
|
+
const [kind, number] = threads.at(-1)!;
|
|
43
|
+
await gh([kind, 'comment', String(number), ...repo, '--body-file', '-'], {
|
|
44
|
+
cwd: root,
|
|
45
|
+
input: guidance,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const labels = labelsOf(question, choice);
|
|
49
|
+
for (const [kind, number] of threads) {
|
|
50
|
+
await gh([kind, 'edit', String(number), ...repo, ...labels], { cwd: root });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ask(ui: ExtensionContext['ui'], question: Question): Promise<Answer | undefined> {
|
|
55
|
+
const choice = await ui.select(promptOf(question), [RETRY, GUIDE, GIVE_UP]);
|
|
56
|
+
if (choice === undefined) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (choice !== GUIDE) {
|
|
60
|
+
return { choice, guidance: '' };
|
|
61
|
+
}
|
|
62
|
+
const guidance = (await ui.editor(`Guidance for #${question.issue}`))?.trim() ?? '';
|
|
63
|
+
return guidance === '' ? undefined : { choice, guidance };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function answerAll(
|
|
67
|
+
ui: ExtensionContext['ui'],
|
|
68
|
+
gh: Gh,
|
|
69
|
+
client: WorkerClient,
|
|
70
|
+
root: string,
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const passed = new Set<number>();
|
|
73
|
+
for (;;) {
|
|
74
|
+
const question = (await fresh(client, root)).find((item) => !passed.has(item.issue));
|
|
75
|
+
if (question === undefined) {
|
|
76
|
+
if (passed.size === 0) {
|
|
77
|
+
ui.notify('next-issue: no questions waiting', 'info');
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
passed.add(question.issue);
|
|
82
|
+
const answer = await ask(ui, question);
|
|
83
|
+
if (answer === undefined) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await reply(gh, root, question, answer);
|
|
87
|
+
ui.notify(answeredOf(question, answer.choice), 'info');
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/clients.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { WorkerClient } from './worker.ts';
|
|
2
|
+
|
|
3
|
+
const KEY = Symbol.for('pi-next-issue');
|
|
4
|
+
|
|
5
|
+
export function shared(create: (root: string) => WorkerClient, root: string): WorkerClient {
|
|
6
|
+
const store = globalThis as Record<symbol, Map<string, WorkerClient> | undefined>;
|
|
7
|
+
const clients = (store[KEY] ??= new Map());
|
|
8
|
+
let client = clients.get(root);
|
|
9
|
+
if (client === undefined) {
|
|
10
|
+
client = create(root);
|
|
11
|
+
clients.set(root, client);
|
|
12
|
+
}
|
|
13
|
+
return client;
|
|
14
|
+
}
|
package/src/errors.ts
ADDED
package/src/exec.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
type Result = { code: number; stdout: string; stderr: string };
|
|
4
4
|
|
|
5
|
-
export type RunOptions = { cwd: string; input?: string
|
|
5
|
+
export type RunOptions = { cwd: string; input?: string };
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
function run(command: string, args: string[], options: RunOptions): Promise<Result> {
|
|
8
8
|
return new Promise((resolve, reject) => {
|
|
9
9
|
const child = spawn(command, args, {
|
|
10
10
|
cwd: options.cwd,
|
|
11
11
|
stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
12
|
-
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
13
12
|
});
|
|
14
13
|
let stdout = '';
|
|
15
14
|
let stderr = '';
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export type RunStatus = 'running' | 'stopping' | 'finished' | 'stopped' | 'failed';
|
|
2
|
+
|
|
3
|
+
export type Handled = { issue: number; outcome: 'done' | 'needs-human' | 'error' };
|
|
4
|
+
|
|
5
|
+
export type IssueProgress = {
|
|
6
|
+
issue: number;
|
|
7
|
+
title?: string;
|
|
8
|
+
stage?: string;
|
|
9
|
+
tools: number;
|
|
10
|
+
lastTool?: string;
|
|
11
|
+
startedAt?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Progress = {
|
|
15
|
+
status: RunStatus;
|
|
16
|
+
issues: IssueProgress[];
|
|
17
|
+
handled: Handled[];
|
|
18
|
+
open?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type Question = {
|
|
23
|
+
issue: number;
|
|
24
|
+
title: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
url: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
pr?: number;
|
|
29
|
+
labels: { needsHuman: string; skip: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ClientMessage =
|
|
33
|
+
| { type: 'hello'; version: string }
|
|
34
|
+
| { type: 'start'; root: string; merge: boolean }
|
|
35
|
+
| { type: 'stop'; root: string }
|
|
36
|
+
| { type: 'questions'; root: string; id?: string };
|
|
37
|
+
|
|
38
|
+
export type WorkerMessage =
|
|
39
|
+
| { type: 'hello'; version: string }
|
|
40
|
+
| { type: 'runs'; runs: Record<string, Progress> }
|
|
41
|
+
| { type: 'questions'; root: string; questions: Question[]; id?: string }
|
|
42
|
+
| { type: 'questions-error'; root: string; message: string; id?: string }
|
|
43
|
+
| { type: 'error'; root: string; message: string };
|
|
44
|
+
|
|
45
|
+
export function going(progress: Progress | undefined): progress is Progress {
|
|
46
|
+
return progress?.status === 'running' || progress?.status === 'stopping';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isNewer(candidate: string, current: string): boolean {
|
|
50
|
+
const { core: left, prerelease: leftPrerelease } = parseVersion(candidate);
|
|
51
|
+
const { core: right, prerelease: rightPrerelease } = parseVersion(current);
|
|
52
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
53
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
54
|
+
if (difference !== 0) {
|
|
55
|
+
return difference > 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return isNewerPrerelease(leftPrerelease, rightPrerelease);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isNewerPrerelease(left: string[], right: string[]): boolean {
|
|
62
|
+
if (left.length === 0 || right.length === 0) {
|
|
63
|
+
return left.length === 0 && right.length > 0;
|
|
64
|
+
}
|
|
65
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
66
|
+
const leftPart = left[index];
|
|
67
|
+
const rightPart = right[index];
|
|
68
|
+
if (leftPart === undefined || rightPart === undefined) {
|
|
69
|
+
return rightPart === undefined;
|
|
70
|
+
}
|
|
71
|
+
if (leftPart === rightPart) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const leftNumeric = /^\d+$/.test(leftPart);
|
|
75
|
+
const rightNumeric = /^\d+$/.test(rightPart);
|
|
76
|
+
if (leftNumeric && rightNumeric) {
|
|
77
|
+
return Number(leftPart) > Number(rightPart);
|
|
78
|
+
}
|
|
79
|
+
if (leftNumeric || rightNumeric) {
|
|
80
|
+
return rightNumeric;
|
|
81
|
+
}
|
|
82
|
+
return leftPart > rightPart;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseVersion(version: string): { core: number[]; prerelease: string[] } {
|
|
88
|
+
const [core = '', ...rest] = version.replace(/\+.*$/, '').split('-');
|
|
89
|
+
return {
|
|
90
|
+
core: core.split('.').map(Number),
|
|
91
|
+
prerelease: rest.length > 0 ? rest.join('-').split('.') : [],
|
|
92
|
+
};
|
|
93
|
+
}
|
package/src/requests.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { going, type Question } from './protocol.ts';
|
|
3
|
+
import { CLOSED, type WorkerClient } from './worker.ts';
|
|
4
|
+
|
|
5
|
+
export function accepted(
|
|
6
|
+
client: WorkerClient,
|
|
7
|
+
root: string,
|
|
8
|
+
signal: AbortSignal | undefined,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
return new Promise<void>((resolve, reject) => {
|
|
11
|
+
const finish = (error?: Error) => {
|
|
12
|
+
stop();
|
|
13
|
+
signal?.removeEventListener('abort', abort);
|
|
14
|
+
if (error === undefined) {
|
|
15
|
+
resolve();
|
|
16
|
+
} else {
|
|
17
|
+
reject(error);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const abort = () => finish(new Error('next_issue_start was cancelled'));
|
|
21
|
+
const stop = client.subscribe((event) => {
|
|
22
|
+
if (event.type === 'error' && event.root === root) {
|
|
23
|
+
finish(new Error(event.message));
|
|
24
|
+
} else if (event.type === 'closed') {
|
|
25
|
+
finish(new Error(CLOSED));
|
|
26
|
+
} else if (event.type === 'runs' && going(event.runs[root])) {
|
|
27
|
+
finish();
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
if (signal?.aborted) {
|
|
31
|
+
abort();
|
|
32
|
+
} else {
|
|
33
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function fresh(client: WorkerClient, root: string): Promise<Question[]> {
|
|
39
|
+
return new Promise<Question[]>((resolve, reject) => {
|
|
40
|
+
const id = randomUUID();
|
|
41
|
+
const ours = (event: { root: string; id?: string }) => event.root === root && event.id === id;
|
|
42
|
+
const stop = client.subscribe((event) => {
|
|
43
|
+
if (event.type === 'closed') {
|
|
44
|
+
stop();
|
|
45
|
+
reject(new Error(CLOSED));
|
|
46
|
+
} else if (event.type === 'questions' && ours(event)) {
|
|
47
|
+
stop();
|
|
48
|
+
resolve(event.questions);
|
|
49
|
+
} else if (event.type === 'questions-error' && ours(event)) {
|
|
50
|
+
stop();
|
|
51
|
+
reject(new Error(event.message));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
client.send({ type: 'questions', root, id });
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { going, type Progress } from './protocol.ts';
|
|
2
|
+
import { accepted } from './requests.ts';
|
|
3
|
+
import { formatStatus, handledNote } from './status.ts';
|
|
4
|
+
import type { WorkerClient } from './worker.ts';
|
|
5
|
+
|
|
6
|
+
function report<Details>(text: string, details: Details) {
|
|
7
|
+
return { content: [{ type: 'text' as const, text }], details };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function statusOf(client: WorkerClient, root: string) {
|
|
11
|
+
await client.attach();
|
|
12
|
+
if (!client.connected) {
|
|
13
|
+
return report('no worker is running', { root });
|
|
14
|
+
}
|
|
15
|
+
const progress = client.runs[root];
|
|
16
|
+
if (progress === undefined) {
|
|
17
|
+
return report(`no run of ${root}`, { root });
|
|
18
|
+
}
|
|
19
|
+
const lines = [formatStatus(progress, Date.now()), ...progress.handled.map(handledNote)];
|
|
20
|
+
return report(lines.join('\n'), { root });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sendStart(client: WorkerClient, root: string): void {
|
|
24
|
+
client.send({ type: 'start', root, merge: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function startRun(
|
|
28
|
+
client: WorkerClient,
|
|
29
|
+
root: string,
|
|
30
|
+
progress: Progress | undefined,
|
|
31
|
+
signal: AbortSignal | undefined,
|
|
32
|
+
) {
|
|
33
|
+
if (going(progress)) {
|
|
34
|
+
return report(
|
|
35
|
+
`A run of ${root} is going already, so nothing started: ${formatStatus(progress, Date.now())}. It picks up new issues by itself.`,
|
|
36
|
+
{ started: false, root },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const accepting = accepted(client, root, signal);
|
|
40
|
+
sendStart(client, root);
|
|
41
|
+
await accepting;
|
|
42
|
+
return report(`Started a next-issue run of ${root}.`, { started: true, root });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function toggle(client: WorkerClient, root: string, progress: Progress | undefined): void {
|
|
46
|
+
if (going(progress)) {
|
|
47
|
+
client.send({ type: 'stop', root });
|
|
48
|
+
} else {
|
|
49
|
+
sendStart(client, root);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function workerArgs(version: string): string[] {
|
|
6
|
+
return ['-y', `next-issue@${version === '0.0.0' ? 'latest' : version}`, 'serve'];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function workerPaths(root: string): { socketPath: string; logPath: string } {
|
|
10
|
+
const stateDir = join(root, '.next-issue');
|
|
11
|
+
return {
|
|
12
|
+
socketPath: join(stateDir, 'worker.sock'),
|
|
13
|
+
logPath: join(stateDir, 'worker.log'),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function spawnWorker(root: string, version: string): void {
|
|
18
|
+
const { logPath } = workerPaths(root);
|
|
19
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
20
|
+
const log = openSync(logPath, 'a');
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn('npx', workerArgs(version), {
|
|
23
|
+
cwd: root,
|
|
24
|
+
detached: true,
|
|
25
|
+
stdio: ['ignore', log, log],
|
|
26
|
+
});
|
|
27
|
+
child.on('error', () => {});
|
|
28
|
+
child.unref();
|
|
29
|
+
} finally {
|
|
30
|
+
closeSync(log);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Handled, IssueProgress, Progress, Question } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
function duration(ms: number): string {
|
|
4
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
5
|
+
const minutes = Math.floor(seconds / 60);
|
|
6
|
+
return minutes > 0 ? `${minutes}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const OUTCOME_NOTES = { done: 'done', 'needs-human': 'needs a human', error: 'failed' } as const;
|
|
10
|
+
|
|
11
|
+
type Outcome = keyof typeof OUTCOME_NOTES;
|
|
12
|
+
|
|
13
|
+
function outcomes(progress: Progress): string {
|
|
14
|
+
const count = (outcome: Outcome): number =>
|
|
15
|
+
progress.handled.filter((item) => item.outcome === outcome).length;
|
|
16
|
+
return (Object.keys(OUTCOME_NOTES) as Outcome[])
|
|
17
|
+
.filter((outcome) => outcome === 'done' || count(outcome) > 0)
|
|
18
|
+
.map((outcome) => `${count(outcome)} ${OUTCOME_NOTES[outcome]}`)
|
|
19
|
+
.join(', ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function failedNote(progress: Progress): string {
|
|
23
|
+
return `failed: ${progress.error ?? 'unknown error'}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function handledNote(item: Handled): string {
|
|
27
|
+
return `#${item.issue} ${OUTCOME_NOTES[item.outcome]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type Note = { text: string; level: 'info' | 'warning' | 'error' };
|
|
31
|
+
|
|
32
|
+
export function statusNote(progress: Progress): Note | undefined {
|
|
33
|
+
if (progress.status === 'finished' || progress.status === 'stopped') {
|
|
34
|
+
return { text: `next-issue ${progress.status}`, level: 'info' };
|
|
35
|
+
}
|
|
36
|
+
if (progress.status === 'failed') {
|
|
37
|
+
return { text: `next-issue ${failedNote(progress)}`, level: 'error' };
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cannotNote(action: 'start' | 'answer', message: string): Note {
|
|
43
|
+
return { text: `next-issue cannot ${action}: ${message}`, level: 'error' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function handledNotes(progress: Progress, from: number): Note[] {
|
|
47
|
+
return progress.handled.slice(from).map((item) => ({
|
|
48
|
+
text: `next-issue: ${handledNote(item)}`,
|
|
49
|
+
level: item.outcome === 'done' ? 'info' : 'warning',
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function questionNotes(questions: Question[], known: number[] | undefined): Note[] {
|
|
54
|
+
if (known === undefined) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return questions
|
|
58
|
+
.filter((question) => !known.includes(question.issue))
|
|
59
|
+
.map((question) => ({
|
|
60
|
+
text: `next-issue: #${question.issue} has a question: ${question.title}`,
|
|
61
|
+
level: 'warning',
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatQuestions(count: number): string {
|
|
66
|
+
return `${count} ${count === 1 ? 'question' : 'questions'} waiting`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function issueLine(item: IssueProgress, count: string, now: number): string {
|
|
70
|
+
const parts = [`#${item.issue}${count}`, item.stage ?? 'prepare', `${item.tools} tools`];
|
|
71
|
+
if (item.startedAt !== undefined) {
|
|
72
|
+
parts.push(duration(now - item.startedAt));
|
|
73
|
+
}
|
|
74
|
+
return parts.join(' · ');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatStatus(progress: Progress, now: number): string {
|
|
78
|
+
const running = progress.issues ?? [];
|
|
79
|
+
switch (progress.status) {
|
|
80
|
+
case 'running': {
|
|
81
|
+
if (running.length === 0) {
|
|
82
|
+
return 'looking for issues';
|
|
83
|
+
}
|
|
84
|
+
const count = progress.open === undefined ? '' : `/${progress.open}`;
|
|
85
|
+
return running.map((item) => issueLine(item, count, now)).join(' | ');
|
|
86
|
+
}
|
|
87
|
+
case 'stopping': {
|
|
88
|
+
const issues = running.map((item) => `#${item.issue}`);
|
|
89
|
+
return `${issues.length === 0 ? '#?' : issues.join(' ')} · stopping`;
|
|
90
|
+
}
|
|
91
|
+
case 'stopped':
|
|
92
|
+
case 'finished':
|
|
93
|
+
return `${progress.status}: ${outcomes(progress)}`;
|
|
94
|
+
case 'failed':
|
|
95
|
+
return failedNote(progress);
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -1,58 +1,15 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { closeSync, mkdirSync, openSync } from 'node:fs';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
1
|
import WebSocket from 'ws';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
startedAt?: number;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export type Progress = {
|
|
20
|
-
status: RunStatus;
|
|
21
|
-
issues: IssueProgress[];
|
|
22
|
-
handled: Handled[];
|
|
23
|
-
open?: number;
|
|
24
|
-
error?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type Question = {
|
|
28
|
-
issue: number;
|
|
29
|
-
title: string;
|
|
30
|
-
reason: string;
|
|
31
|
-
url: string;
|
|
32
|
-
repo: string;
|
|
33
|
-
pr?: number;
|
|
34
|
-
labels: { needsHuman: string; skip: string };
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type ClientMessage =
|
|
38
|
-
| { type: 'hello'; version: string }
|
|
39
|
-
| { type: 'start'; root: string; merge: boolean }
|
|
40
|
-
| { type: 'stop'; root: string }
|
|
41
|
-
| { type: 'questions'; root: string; id?: string };
|
|
42
|
-
|
|
43
|
-
export type WorkerMessage =
|
|
44
|
-
| { type: 'hello'; version: string }
|
|
45
|
-
| { type: 'runs'; runs: Record<string, Progress> }
|
|
46
|
-
| { type: 'questions'; root: string; questions: Question[]; id?: string }
|
|
47
|
-
| { type: 'questions-error'; root: string; message: string; id?: string }
|
|
48
|
-
| { type: 'error'; root: string; message: string };
|
|
49
|
-
|
|
50
|
-
export type WorkerEvent =
|
|
51
|
-
| { type: 'runs'; runs: Record<string, Progress> }
|
|
52
|
-
| { type: 'questions'; root: string; questions: Question[]; id?: string }
|
|
53
|
-
| { type: 'questions-error'; root: string; message: string; id?: string }
|
|
54
|
-
| { type: 'error'; root: string; message: string }
|
|
55
|
-
| { type: 'closed' };
|
|
2
|
+
import { errorText } from './errors.ts';
|
|
3
|
+
import {
|
|
4
|
+
type ClientMessage,
|
|
5
|
+
going,
|
|
6
|
+
isNewer,
|
|
7
|
+
type Progress,
|
|
8
|
+
type Question,
|
|
9
|
+
type WorkerMessage,
|
|
10
|
+
} from './protocol.ts';
|
|
11
|
+
|
|
12
|
+
export type WorkerEvent = Exclude<WorkerMessage, { type: 'hello' }> | { type: 'closed' };
|
|
56
13
|
|
|
57
14
|
export type WorkerOptions = {
|
|
58
15
|
socketPath: string;
|
|
@@ -66,56 +23,9 @@ const START_TIMEOUT_MS = 120_000;
|
|
|
66
23
|
const RECONNECT_MS = 5_000;
|
|
67
24
|
const POLL_MS = 200;
|
|
68
25
|
|
|
69
|
-
export
|
|
70
|
-
const [leftCore = '', ...leftRest] = candidate.replace(/\+.*$/, '').split('-');
|
|
71
|
-
const [rightCore = '', ...rightRest] = current.replace(/\+.*$/, '').split('-');
|
|
72
|
-
const left = leftCore.split('.').map(Number);
|
|
73
|
-
const right = rightCore.split('.').map(Number);
|
|
74
|
-
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
75
|
-
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
76
|
-
if (difference !== 0) {
|
|
77
|
-
return difference > 0;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const leftPrerelease = leftRest.length > 0 ? leftRest.join('-').split('.') : [];
|
|
81
|
-
const rightPrerelease = rightRest.length > 0 ? rightRest.join('-').split('.') : [];
|
|
82
|
-
if (leftPrerelease.length === 0 || rightPrerelease.length === 0) {
|
|
83
|
-
return leftPrerelease.length === 0 && rightPrerelease.length > 0;
|
|
84
|
-
}
|
|
85
|
-
for (let index = 0; index < Math.max(leftPrerelease.length, rightPrerelease.length); index += 1) {
|
|
86
|
-
const leftPart = leftPrerelease[index];
|
|
87
|
-
const rightPart = rightPrerelease[index];
|
|
88
|
-
if (leftPart === undefined || rightPart === undefined) {
|
|
89
|
-
return rightPart === undefined;
|
|
90
|
-
}
|
|
91
|
-
if (leftPart === rightPart) {
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const leftNumeric = /^\d+$/.test(leftPart);
|
|
95
|
-
const rightNumeric = /^\d+$/.test(rightPart);
|
|
96
|
-
if (leftNumeric && rightNumeric) {
|
|
97
|
-
return Number(leftPart) > Number(rightPart);
|
|
98
|
-
}
|
|
99
|
-
if (leftNumeric || rightNumeric) {
|
|
100
|
-
return rightNumeric;
|
|
101
|
-
}
|
|
102
|
-
return leftPart > rightPart;
|
|
103
|
-
}
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function workerArgs(version: string): string[] {
|
|
108
|
-
return ['-y', `next-issue@${version === '0.0.0' ? 'latest' : version}`, 'serve'];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function workerPaths(root: string): { socketPath: string; logPath: string } {
|
|
112
|
-
return {
|
|
113
|
-
socketPath: join(root, '.next-issue', 'worker.sock'),
|
|
114
|
-
logPath: join(root, '.next-issue', 'worker.log'),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
26
|
+
export const CLOSED = 'the worker closed the connection';
|
|
117
27
|
|
|
118
|
-
|
|
28
|
+
function checkSocketPath(path: string, platform = process.platform): void {
|
|
119
29
|
const size = platform === 'linux' ? 108 : 104;
|
|
120
30
|
const length = Buffer.byteLength(path);
|
|
121
31
|
if (length >= size) {
|
|
@@ -125,20 +35,11 @@ export function checkSocketPath(path: string, platform = process.platform): void
|
|
|
125
35
|
}
|
|
126
36
|
}
|
|
127
37
|
|
|
128
|
-
|
|
129
|
-
const { logPath } = workerPaths(root);
|
|
130
|
-
mkdirSync(dirname(logPath), { recursive: true });
|
|
131
|
-
const log = openSync(logPath, 'a');
|
|
38
|
+
function parseMessage(text: string): WorkerMessage | undefined {
|
|
132
39
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
stdio: ['ignore', log, log],
|
|
137
|
-
});
|
|
138
|
-
child.on('error', () => {});
|
|
139
|
-
child.unref();
|
|
140
|
-
} finally {
|
|
141
|
-
closeSync(log);
|
|
40
|
+
return JSON.parse(text) as WorkerMessage;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
142
43
|
}
|
|
143
44
|
}
|
|
144
45
|
|
|
@@ -160,6 +61,22 @@ export class WorkerClient {
|
|
|
160
61
|
this.options = options;
|
|
161
62
|
}
|
|
162
63
|
|
|
64
|
+
private get startTimeoutMs(): number {
|
|
65
|
+
return this.options.startTimeoutMs ?? START_TIMEOUT_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private get reconnectMs(): number {
|
|
69
|
+
return this.options.reconnectMs ?? RECONNECT_MS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private get busy(): boolean {
|
|
73
|
+
return Object.values(this.runs).some(going);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private get outdated(): boolean {
|
|
77
|
+
return this.workerVersion !== undefined && isNewer(this.options.version, this.workerVersion);
|
|
78
|
+
}
|
|
79
|
+
|
|
163
80
|
get connected(): boolean {
|
|
164
81
|
return this.socket?.readyState === WebSocket.OPEN;
|
|
165
82
|
}
|
|
@@ -182,22 +99,11 @@ export class WorkerClient {
|
|
|
182
99
|
|
|
183
100
|
async ensure(): Promise<void> {
|
|
184
101
|
if (!this.connected) {
|
|
185
|
-
|
|
186
|
-
this.connecting = undefined;
|
|
187
|
-
});
|
|
188
|
-
await this.connecting;
|
|
102
|
+
await this.connect();
|
|
189
103
|
}
|
|
190
|
-
if (this.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
);
|
|
194
|
-
if (!busy) {
|
|
195
|
-
await this.waitForClose();
|
|
196
|
-
this.connecting ??= this.start().finally(() => {
|
|
197
|
-
this.connecting = undefined;
|
|
198
|
-
});
|
|
199
|
-
await this.connecting;
|
|
200
|
-
}
|
|
104
|
+
if (this.outdated && !this.busy) {
|
|
105
|
+
await this.waitForClose();
|
|
106
|
+
await this.connect();
|
|
201
107
|
}
|
|
202
108
|
}
|
|
203
109
|
|
|
@@ -210,6 +116,13 @@ export class WorkerClient {
|
|
|
210
116
|
socket?.terminate();
|
|
211
117
|
}
|
|
212
118
|
|
|
119
|
+
private connect(): Promise<void> {
|
|
120
|
+
this.connecting ??= this.start().finally(() => {
|
|
121
|
+
this.connecting = undefined;
|
|
122
|
+
});
|
|
123
|
+
return this.connecting;
|
|
124
|
+
}
|
|
125
|
+
|
|
213
126
|
private async start(): Promise<void> {
|
|
214
127
|
checkSocketPath(this.options.socketPath);
|
|
215
128
|
try {
|
|
@@ -218,7 +131,11 @@ export class WorkerClient {
|
|
|
218
131
|
} catch {
|
|
219
132
|
this.options.spawnWorker(this.options.version);
|
|
220
133
|
}
|
|
221
|
-
|
|
134
|
+
await this.waitForStart();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async waitForStart(): Promise<void> {
|
|
138
|
+
const deadline = Date.now() + this.startTimeoutMs;
|
|
222
139
|
for (;;) {
|
|
223
140
|
await delay(POLL_MS);
|
|
224
141
|
try {
|
|
@@ -226,9 +143,7 @@ export class WorkerClient {
|
|
|
226
143
|
return;
|
|
227
144
|
} catch (error) {
|
|
228
145
|
if (Date.now() >= deadline) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
`the next-issue worker did not start: ${error instanceof Error ? error.message : String(error)}`,
|
|
231
|
-
);
|
|
146
|
+
throw new Error(`the next-issue worker did not start: ${errorText(error)}`);
|
|
232
147
|
}
|
|
233
148
|
}
|
|
234
149
|
}
|
|
@@ -244,52 +159,59 @@ export class WorkerClient {
|
|
|
244
159
|
}
|
|
245
160
|
});
|
|
246
161
|
socket.on('message', (data) => {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
message = JSON.parse(String(data)) as WorkerMessage;
|
|
250
|
-
} catch {
|
|
162
|
+
const message = parseMessage(String(data));
|
|
163
|
+
if (message === undefined) {
|
|
251
164
|
return;
|
|
252
165
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
this.emit({ type: 'runs', runs: message.runs });
|
|
258
|
-
if (!hello) {
|
|
259
|
-
hello = true;
|
|
260
|
-
resolve();
|
|
261
|
-
}
|
|
262
|
-
} else if (message.type === 'questions') {
|
|
263
|
-
this.questions[message.root] = message.questions;
|
|
264
|
-
this.emit(message);
|
|
265
|
-
} else if (message.type === 'error' || message.type === 'questions-error') {
|
|
266
|
-
this.emit(message);
|
|
166
|
+
this.receive(message);
|
|
167
|
+
if (message.type === 'runs' && !hello) {
|
|
168
|
+
hello = true;
|
|
169
|
+
resolve();
|
|
267
170
|
}
|
|
268
171
|
});
|
|
269
172
|
socket.once('open', () => {
|
|
270
173
|
this.socket = socket;
|
|
271
|
-
|
|
174
|
+
this.send({ type: 'hello', version: this.options.version });
|
|
272
175
|
});
|
|
273
176
|
socket.once('close', () => {
|
|
274
|
-
|
|
275
|
-
this.socket = undefined;
|
|
276
|
-
this.emit({ type: 'closed' });
|
|
277
|
-
this.scheduleReconnect();
|
|
278
|
-
}
|
|
177
|
+
this.release(socket);
|
|
279
178
|
if (!hello) {
|
|
280
|
-
reject(new Error(
|
|
179
|
+
reject(new Error(CLOSED));
|
|
281
180
|
}
|
|
282
181
|
});
|
|
283
182
|
});
|
|
284
183
|
}
|
|
285
184
|
|
|
185
|
+
private release(socket: WebSocket): void {
|
|
186
|
+
if (this.socket !== socket) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.socket = undefined;
|
|
190
|
+
this.emit({ type: 'closed' });
|
|
191
|
+
this.scheduleReconnect();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private receive(message: WorkerMessage): void {
|
|
195
|
+
if (message.type === 'hello') {
|
|
196
|
+
this.workerVersion = message.version;
|
|
197
|
+
} else if (message.type === 'runs') {
|
|
198
|
+
this.runs = message.runs;
|
|
199
|
+
this.emit(message);
|
|
200
|
+
} else if (message.type === 'questions') {
|
|
201
|
+
this.questions[message.root] = message.questions;
|
|
202
|
+
this.emit(message);
|
|
203
|
+
} else if (message.type === 'error' || message.type === 'questions-error') {
|
|
204
|
+
this.emit(message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
286
208
|
private waitForClose(): Promise<void> {
|
|
287
209
|
const socket = this.socket;
|
|
288
210
|
if (socket === undefined || socket.readyState === WebSocket.CLOSED) {
|
|
289
211
|
return Promise.resolve();
|
|
290
212
|
}
|
|
291
213
|
return new Promise((resolve) => {
|
|
292
|
-
const timer = setTimeout(resolve, this.
|
|
214
|
+
const timer = setTimeout(resolve, this.startTimeoutMs);
|
|
293
215
|
socket.once('close', () => {
|
|
294
216
|
clearTimeout(timer);
|
|
295
217
|
resolve();
|
|
@@ -306,7 +228,7 @@ export class WorkerClient {
|
|
|
306
228
|
if (!this.connected && this.connecting === undefined) {
|
|
307
229
|
void this.attach();
|
|
308
230
|
}
|
|
309
|
-
}, this.
|
|
231
|
+
}, this.reconnectMs);
|
|
310
232
|
this.reconnectTimer.unref?.();
|
|
311
233
|
}
|
|
312
234
|
|