@ravshansbox/pi-next-issue 2.10.0 → 2.12.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/README.md +12 -0
- package/index.ts +157 -7
- package/package.json +1 -1
- package/src/worker.ts +21 -2
package/README.md
CHANGED
|
@@ -17,6 +17,18 @@ Run this in pi, inside a git repository:
|
|
|
17
17
|
|
|
18
18
|
The footer shows the issue, the stage, the number of tool calls and the time spent on the issue, for example `next-issue #12 · review 1 · 37 tools · 4m12s`. After a run ends, it keeps the last summary. In the terminal, it uses the dim colour of the theme, like the rest of the footer. A note appears when an issue is done, needs a human or fails while pi is open. Pi does not repeat the notes of a run that ended before it opened.
|
|
19
19
|
|
|
20
|
+
## Answer questions
|
|
21
|
+
|
|
22
|
+
When an issue waits for a person, with the `needsHuman` label of the [config](../next-issue/README.md), the footer shows the count, for example `2 questions waiting`, and a note appears once for each new question. Neither interrupts what you do. Pi asks the worker for the list when it connects and every minute, and the worker also sends it after each issue of a run. A question answered on GitHub or in another pi leaves the count with the next list.
|
|
23
|
+
|
|
24
|
+
Run `/next-issue answer` to go through the waiting questions one at a time. Each shows the issue, the title and the reason of the hand-over. Choose:
|
|
25
|
+
|
|
26
|
+
- **Retry**: takes the `needsHuman` label off the issue and its pull request, so the next run resumes it.
|
|
27
|
+
- **Retry with guidance**: asks for text, posts it as a comment where the worker reads it, on the pull request of the issue or on the issue when it has none, then takes the `needsHuman` label off.
|
|
28
|
+
- **Give up**: takes the `needsHuman` label off the issue and its pull request and adds the `skip` label.
|
|
29
|
+
|
|
30
|
+
Press Escape, or leave the guidance empty, to stop. Pi reads the list again before each question, so it skips a question answered elsewhere in the meantime. The worker sends the repository, the pull request and the label names with each question, from `next-issue.config.json` and its `remote`. Pi uses `gh` for the comment and the labels.
|
|
31
|
+
|
|
20
32
|
## Create an issue
|
|
21
33
|
|
|
22
34
|
The package ships the `create-issue` skill. Ask pi to write an issue, or run `/skill:create-issue <problem>`. Pi then:
|
package/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { readFileSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import {
|
|
@@ -6,16 +7,37 @@ import {
|
|
|
6
7
|
getAgentDir,
|
|
7
8
|
} from '@earendil-works/pi-coding-agent';
|
|
8
9
|
import { Type } from 'typebox';
|
|
10
|
+
import { must, type RunOptions } from './src/exec.ts';
|
|
9
11
|
import { repoRoot } from './src/git.ts';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type Progress,
|
|
14
|
+
type Question,
|
|
15
|
+
spawnWorker,
|
|
16
|
+
WorkerClient,
|
|
17
|
+
type WorkerEvent,
|
|
18
|
+
} from './src/worker.ts';
|
|
11
19
|
|
|
12
20
|
export type Options = {
|
|
13
21
|
client: () => WorkerClient;
|
|
14
22
|
root: (cwd: string) => Promise<string>;
|
|
23
|
+
gh: (args: string[], options: RunOptions) => Promise<string>;
|
|
15
24
|
};
|
|
16
25
|
|
|
17
26
|
const KEY = Symbol.for('pi-next-issue');
|
|
18
27
|
const STATUS_KEY = 'next-issue';
|
|
28
|
+
const QUESTIONS_KEY = 'next-issue-questions';
|
|
29
|
+
const QUESTIONS_MS = 60_000;
|
|
30
|
+
const RETRY = 'Retry';
|
|
31
|
+
const GUIDE = 'Retry with guidance';
|
|
32
|
+
const GIVE_UP = 'Give up';
|
|
33
|
+
|
|
34
|
+
export function formatQuestions(count: number): string {
|
|
35
|
+
return `${count} ${count === 1 ? 'question' : 'questions'} waiting`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function errorText(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
19
41
|
|
|
20
42
|
function shared(create: () => WorkerClient): WorkerClient {
|
|
21
43
|
const store = globalThis as Record<symbol, WorkerClient | undefined>;
|
|
@@ -84,6 +106,7 @@ const fromMachine: Options = {
|
|
|
84
106
|
});
|
|
85
107
|
},
|
|
86
108
|
root: repoRoot,
|
|
109
|
+
gh: (args, options) => must('gh', args, options),
|
|
87
110
|
};
|
|
88
111
|
|
|
89
112
|
export function createExtension(options: Options) {
|
|
@@ -92,6 +115,8 @@ export function createExtension(options: Options) {
|
|
|
92
115
|
let coloured = false;
|
|
93
116
|
let root: string | undefined;
|
|
94
117
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
118
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
119
|
+
let waiting: number[] | undefined;
|
|
95
120
|
let unsubscribe: (() => void) | undefined;
|
|
96
121
|
let handled = 0;
|
|
97
122
|
let status: Progress['status'] | undefined;
|
|
@@ -109,6 +134,34 @@ export function createExtension(options: Options) {
|
|
|
109
134
|
ui.setStatus(STATUS_KEY, dim(formatStatus(progress, Date.now())));
|
|
110
135
|
};
|
|
111
136
|
|
|
137
|
+
const paintQuestions = () => {
|
|
138
|
+
if (ui === undefined || waiting === undefined) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const text = formatQuestions(waiting.length);
|
|
142
|
+
ui.setStatus(
|
|
143
|
+
QUESTIONS_KEY,
|
|
144
|
+
waiting.length === 0 ? undefined : coloured ? ui.theme.fg('dim', text) : text,
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onQuestions = (questions: Question[]) => {
|
|
149
|
+
const known = waiting;
|
|
150
|
+
waiting = questions.map((question) => question.issue);
|
|
151
|
+
for (const question of questions) {
|
|
152
|
+
if (known !== undefined && !known.includes(question.issue)) {
|
|
153
|
+
ui?.notify(`next-issue: #${question.issue} has a question: ${question.title}`, 'warning');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
paintQuestions();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const askQuestions = () => {
|
|
160
|
+
if (root !== undefined) {
|
|
161
|
+
shared(options.client).send({ type: 'questions', root });
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
112
165
|
const onProgress = (progress: Progress | undefined) => {
|
|
113
166
|
if (progress === undefined) {
|
|
114
167
|
return;
|
|
@@ -143,8 +196,11 @@ export function createExtension(options: Options) {
|
|
|
143
196
|
handled = progress?.handled.length ?? 0;
|
|
144
197
|
status = progress?.status;
|
|
145
198
|
render();
|
|
199
|
+
askQuestions();
|
|
146
200
|
} else if (event.type === 'runs') {
|
|
147
201
|
onProgress(root === undefined ? undefined : event.runs[root]);
|
|
202
|
+
} else if (event.type === 'questions' && event.root === root) {
|
|
203
|
+
onQuestions(event.questions);
|
|
148
204
|
} else if (event.type === 'error' && event.root === root) {
|
|
149
205
|
ui?.notify(`next-issue cannot start: ${event.message}`, 'error');
|
|
150
206
|
}
|
|
@@ -155,9 +211,17 @@ export function createExtension(options: Options) {
|
|
|
155
211
|
const progress = current();
|
|
156
212
|
handled = progress?.handled.length ?? 0;
|
|
157
213
|
status = progress?.status;
|
|
214
|
+
waiting =
|
|
215
|
+
root === undefined
|
|
216
|
+
? undefined
|
|
217
|
+
: shared(options.client).questions[root]?.map((question) => question.issue);
|
|
158
218
|
baseline = !shared(options.client).connected;
|
|
159
219
|
unsubscribe = shared(options.client).subscribe(onEvent);
|
|
160
220
|
render();
|
|
221
|
+
paintQuestions();
|
|
222
|
+
if (!baseline) {
|
|
223
|
+
askQuestions();
|
|
224
|
+
}
|
|
161
225
|
};
|
|
162
226
|
|
|
163
227
|
const show = (ctx: ExtensionContext) => {
|
|
@@ -165,6 +229,8 @@ export function createExtension(options: Options) {
|
|
|
165
229
|
coloured = ctx.mode === 'tui';
|
|
166
230
|
timer ??= setInterval(render, 1000);
|
|
167
231
|
timer.unref?.();
|
|
232
|
+
poll ??= setInterval(askQuestions, QUESTIONS_MS);
|
|
233
|
+
poll.unref?.();
|
|
168
234
|
};
|
|
169
235
|
|
|
170
236
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -187,6 +253,10 @@ export function createExtension(options: Options) {
|
|
|
187
253
|
clearInterval(timer);
|
|
188
254
|
timer = undefined;
|
|
189
255
|
}
|
|
256
|
+
if (poll !== undefined) {
|
|
257
|
+
clearInterval(poll);
|
|
258
|
+
poll = undefined;
|
|
259
|
+
}
|
|
190
260
|
ui = undefined;
|
|
191
261
|
});
|
|
192
262
|
|
|
@@ -262,9 +332,92 @@ export function createExtension(options: Options) {
|
|
|
262
332
|
},
|
|
263
333
|
});
|
|
264
334
|
|
|
335
|
+
const fresh = (client: WorkerClient, root: string) =>
|
|
336
|
+
new Promise<Question[]>((resolve, reject) => {
|
|
337
|
+
const id = randomUUID();
|
|
338
|
+
const stop = client.subscribe((event) => {
|
|
339
|
+
if (event.type === 'questions' && event.root === root && event.id === id) {
|
|
340
|
+
stop();
|
|
341
|
+
resolve(event.questions);
|
|
342
|
+
} else if (event.type === 'questions-error' && event.root === root && event.id === id) {
|
|
343
|
+
stop();
|
|
344
|
+
reject(new Error(event.message));
|
|
345
|
+
} else if (event.type === 'closed') {
|
|
346
|
+
stop();
|
|
347
|
+
reject(new Error('the worker closed the connection'));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
client.send({ type: 'questions', root, id });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const reply = async (root: string, question: Question, choice: string, guidance: string) => {
|
|
354
|
+
const threads: Array<[string, number]> = [['issue', question.issue]];
|
|
355
|
+
if (question.pr !== undefined) {
|
|
356
|
+
threads.push(['pr', question.pr]);
|
|
357
|
+
}
|
|
358
|
+
const repo = ['--repo', question.repo];
|
|
359
|
+
if (choice === GUIDE) {
|
|
360
|
+
const [kind, number] = threads.at(-1)!;
|
|
361
|
+
await options.gh([kind, 'comment', String(number), ...repo, '--body-file', '-'], {
|
|
362
|
+
cwd: root,
|
|
363
|
+
input: guidance,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const labels = ['--remove-label', question.labels.needsHuman];
|
|
367
|
+
if (choice === GIVE_UP) {
|
|
368
|
+
labels.push('--add-label', question.labels.skip);
|
|
369
|
+
}
|
|
370
|
+
for (const [kind, number] of threads) {
|
|
371
|
+
await options.gh([kind, 'edit', String(number), ...repo, ...labels], { cwd: root });
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const answer = async (ctx: ExtensionContext) => {
|
|
376
|
+
const { client, root } = await connect(ctx);
|
|
377
|
+
const passed = new Set<number>();
|
|
378
|
+
for (;;) {
|
|
379
|
+
const question = (await fresh(client, root)).find((item) => !passed.has(item.issue));
|
|
380
|
+
if (question === undefined) {
|
|
381
|
+
if (passed.size === 0) {
|
|
382
|
+
ctx.ui.notify('next-issue: no questions waiting', 'info');
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
passed.add(question.issue);
|
|
387
|
+
const choice = await ctx.ui.select(
|
|
388
|
+
`#${question.issue} ${question.title}\n\n${question.reason || 'No reason given.'}`,
|
|
389
|
+
[RETRY, GUIDE, GIVE_UP],
|
|
390
|
+
);
|
|
391
|
+
if (choice === undefined) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
let guidance = '';
|
|
395
|
+
if (choice === GUIDE) {
|
|
396
|
+
guidance = (await ctx.ui.editor(`Guidance for #${question.issue}`))?.trim() ?? '';
|
|
397
|
+
if (guidance === '') {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
await reply(root, question, choice, guidance);
|
|
402
|
+
ctx.ui.notify(
|
|
403
|
+
`next-issue: #${question.issue} ${choice === GIVE_UP ? 'given up' : 'will be retried'}`,
|
|
404
|
+
'info',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
265
409
|
pi.registerCommand('next-issue', {
|
|
266
|
-
description:
|
|
267
|
-
|
|
410
|
+
description:
|
|
411
|
+
'Start or stop working through the GitHub issues of this repository, or answer the waiting questions with "answer"',
|
|
412
|
+
handler: async (args, ctx) => {
|
|
413
|
+
if (args.trim() === 'answer') {
|
|
414
|
+
try {
|
|
415
|
+
await answer(ctx);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
ctx.ui.notify(`next-issue cannot answer: ${errorText(error)}`, 'error');
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
268
421
|
try {
|
|
269
422
|
const { client, root, progress } = await connect(ctx);
|
|
270
423
|
if (going(progress)) {
|
|
@@ -273,10 +426,7 @@ export function createExtension(options: Options) {
|
|
|
273
426
|
client.send({ type: 'start', root, merge: true });
|
|
274
427
|
}
|
|
275
428
|
} catch (error) {
|
|
276
|
-
ctx.ui.notify(
|
|
277
|
-
`next-issue cannot start: ${error instanceof Error ? error.message : String(error)}`,
|
|
278
|
-
'error',
|
|
279
|
-
);
|
|
429
|
+
ctx.ui.notify(`next-issue cannot start: ${errorText(error)}`, 'error');
|
|
280
430
|
}
|
|
281
431
|
},
|
|
282
432
|
});
|
package/package.json
CHANGED
package/src/worker.ts
CHANGED
|
@@ -19,18 +19,33 @@ export type Progress = {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
};
|
|
21
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
|
+
|
|
22
32
|
export type ClientMessage =
|
|
23
33
|
| { type: 'hello'; version: string }
|
|
24
34
|
| { type: 'start'; root: string; merge: boolean }
|
|
25
|
-
| { type: 'stop'; root: string }
|
|
35
|
+
| { type: 'stop'; root: string }
|
|
36
|
+
| { type: 'questions'; root: string; id?: string };
|
|
26
37
|
|
|
27
38
|
export type WorkerMessage =
|
|
28
39
|
| { type: 'hello'; version: string }
|
|
29
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 }
|
|
30
43
|
| { type: 'error'; root: string; message: string };
|
|
31
44
|
|
|
32
45
|
export type WorkerEvent =
|
|
33
46
|
| { type: 'runs'; runs: Record<string, Progress> }
|
|
47
|
+
| { type: 'questions'; root: string; questions: Question[]; id?: string }
|
|
48
|
+
| { type: 'questions-error'; root: string; message: string; id?: string }
|
|
34
49
|
| { type: 'error'; root: string; message: string }
|
|
35
50
|
| { type: 'closed' };
|
|
36
51
|
|
|
@@ -108,6 +123,7 @@ function delay(ms: number): Promise<void> {
|
|
|
108
123
|
|
|
109
124
|
export class WorkerClient {
|
|
110
125
|
runs: Record<string, Progress> = {};
|
|
126
|
+
questions: Record<string, Question[]> = {};
|
|
111
127
|
private socket: WebSocket | undefined;
|
|
112
128
|
private workerVersion: string | undefined;
|
|
113
129
|
private connecting: Promise<void> | undefined;
|
|
@@ -217,7 +233,10 @@ export class WorkerClient {
|
|
|
217
233
|
hello = true;
|
|
218
234
|
resolve();
|
|
219
235
|
}
|
|
220
|
-
} else if (message.type === '
|
|
236
|
+
} else if (message.type === 'questions') {
|
|
237
|
+
this.questions[message.root] = message.questions;
|
|
238
|
+
this.emit(message);
|
|
239
|
+
} else if (message.type === 'error' || message.type === 'questions-error') {
|
|
221
240
|
this.emit(message);
|
|
222
241
|
}
|
|
223
242
|
});
|