@ravshansbox/pi-next-issue 2.11.0 → 2.13.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 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 { type Progress, spawnWorker, WorkerClient, type WorkerEvent } from './src/worker.ts';
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: 'Start or stop working through the GitHub issues of this repository',
267
- handler: async (_args, ctx) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-next-issue",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "description": "Pi extension that works through GitHub issues with the next-issue worker",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/worker.ts CHANGED
@@ -19,23 +19,33 @@ export type Progress = {
19
19
  error?: string;
20
20
  };
21
21
 
22
- export type Question = { issue: number; title: string; reason: string; url: string };
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
+ };
23
31
 
24
32
  export type ClientMessage =
25
33
  | { type: 'hello'; version: string }
26
34
  | { type: 'start'; root: string; merge: boolean }
27
35
  | { type: 'stop'; root: string }
28
- | { type: 'questions'; root: string };
36
+ | { type: 'questions'; root: string; id?: string };
29
37
 
30
38
  export type WorkerMessage =
31
39
  | { type: 'hello'; version: string }
32
40
  | { type: 'runs'; runs: Record<string, Progress> }
33
- | { type: 'questions'; root: string; questions: Question[] }
34
- | { type: 'questions-error'; root: string; message: string }
41
+ | { type: 'questions'; root: string; questions: Question[]; id?: string }
42
+ | { type: 'questions-error'; root: string; message: string; id?: string }
35
43
  | { type: 'error'; root: string; message: string };
36
44
 
37
45
  export type WorkerEvent =
38
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 }
39
49
  | { type: 'error'; root: string; message: string }
40
50
  | { type: 'closed' };
41
51
 
@@ -113,6 +123,7 @@ function delay(ms: number): Promise<void> {
113
123
 
114
124
  export class WorkerClient {
115
125
  runs: Record<string, Progress> = {};
126
+ questions: Record<string, Question[]> = {};
116
127
  private socket: WebSocket | undefined;
117
128
  private workerVersion: string | undefined;
118
129
  private connecting: Promise<void> | undefined;
@@ -222,7 +233,10 @@ export class WorkerClient {
222
233
  hello = true;
223
234
  resolve();
224
235
  }
225
- } else if (message.type === 'error') {
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') {
226
240
  this.emit(message);
227
241
  }
228
242
  });