next-issue 2.10.0 → 2.11.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
@@ -461,12 +461,25 @@ to it. The worker:
461
461
  file. It works on macOS and Linux;
462
462
  - holds `next-issue.lock` in the same folder, with its process id, so only one
463
463
  worker runs. A lock whose process is no longer alive is taken over;
464
+ - keeps the repositories it knows in `next-issue.roots.json` in the same
465
+ folder, so it still knows them after a restart;
464
466
  - runs at most one run per repository, and runs in several repositories at
465
467
  once. Each run is like `next-issue` without `--watch`, and the client says
466
468
  whether it merges;
467
469
  - sends every client the progress of every run when it connects and on every
468
470
  change: the issue, the step, the tool count, the last tool and the handled
469
471
  issues;
472
+ - sends every client a `questions` message for each repository: the open
473
+ issues with the `needsHuman` label, each with its number, title, the reason
474
+ of the hand-over and the url of the comment that holds it. It reads them
475
+ from GitHub, so the list is the same after a restart. It sends them when a
476
+ client connects, for each repository it knows, after each issue of a run
477
+ ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
478
+ An issue that no longer has the label leaves the list. An issue with the
479
+ label but no hand-over comment is in the list with an empty reason and url.
480
+ The issues are read one at a time. A client never gets an older list after
481
+ a newer one. When the list cannot be read, the client gets
482
+ `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
470
483
  - stops a run, when a client asks, like a second `Ctrl-C`: after the current
471
484
  round, with the state kept;
472
485
  - stays when all clients close;
package/dist/github.mjs CHANGED
@@ -80,7 +80,7 @@ export async function threadComments(repo, number) {
80
80
  '--paginate',
81
81
  `repos/${slug(repo)}/issues/${number}/comments`,
82
82
  '--jq',
83
- '.[] | [.user.login, .body] | @json',
83
+ '.[] | [.user.login, .body, .html_url] | @json',
84
84
  ], { cwd: repo.root });
85
85
  return raw
86
86
  .split('\n')
@@ -93,8 +93,8 @@ export async function threadComments(repo, number) {
93
93
  catch {
94
94
  throw new Error(`gh gave a comment of #${number} that is not valid JSON: ${line}`);
95
95
  }
96
- const [author, body] = parsed;
97
- return { author, body: body ?? '' };
96
+ const [author, body, url] = parsed;
97
+ return url === undefined ? { author, body: body ?? '' } : { author, body: body ?? '', url };
98
98
  });
99
99
  }
100
100
  export async function canWrite(repo, login) {
package/dist/handover.mjs CHANGED
@@ -1,24 +1,47 @@
1
1
  import { setStatus } from './job.mjs';
2
2
  import { message } from './observe.mjs';
3
- import { dropState, writeState } from './state.mjs';
3
+ import { dropState, readState, writeState } from './state.mjs';
4
4
  export const HAND_OVER = 'next-issue needs help:';
5
- export async function handOverReply(context, issue, pr) {
6
- const { repo, ports } = context;
7
- const comments = await ports.threadComments(repo, pr ?? issue);
5
+ function writers(repo, ports) {
8
6
  const access = new Map();
9
- const writer = async (author) => {
7
+ return (author) => {
10
8
  if (!access.has(author)) {
11
- access.set(author, await ports.canWrite(repo, author));
9
+ access.set(author, ports.canWrite(repo, author));
12
10
  }
13
- return access.get(author) === true;
11
+ return access.get(author);
14
12
  };
15
- let last = -1;
16
- for (let index = comments.length - 1; index >= 0 && last === -1; index -= 1) {
13
+ }
14
+ async function lastHandOver(comments, writer) {
15
+ for (let index = comments.length - 1; index >= 0; index -= 1) {
17
16
  const comment = comments[index];
18
17
  if (comment.body.startsWith(HAND_OVER) && (await writer(comment.author))) {
19
- last = index;
18
+ return index;
20
19
  }
21
20
  }
21
+ return -1;
22
+ }
23
+ export async function waitingQuestions(repo, config, ports) {
24
+ const writer = writers(repo, ports);
25
+ const issues = await ports.openIssues(repo, config.issueLimit, [config.labels.needsHuman]);
26
+ const questions = [];
27
+ for (const issue of issues) {
28
+ const pr = (await readState(repo, issue.number))?.pr;
29
+ const comments = await ports.threadComments(repo, pr ?? issue.number);
30
+ const comment = comments[await lastHandOver(comments, writer)];
31
+ questions.push({
32
+ issue: issue.number,
33
+ title: issue.title,
34
+ reason: comment?.body.slice(HAND_OVER.length).trim() ?? '',
35
+ url: comment?.url ?? '',
36
+ });
37
+ }
38
+ return questions;
39
+ }
40
+ export async function handOverReply(context, issue, pr) {
41
+ const { repo, ports } = context;
42
+ const comments = await ports.threadComments(repo, pr ?? issue);
43
+ const writer = writers(repo, ports);
44
+ const last = await lastHandOver(comments, writer);
22
45
  if (last === -1) {
23
46
  return [];
24
47
  }
package/dist/serve.mjs CHANGED
@@ -5,16 +5,21 @@ import { join } from 'node:path';
5
5
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
6
  import { WebSocketServer } from 'ws';
7
7
  import { parseArgs } from './args.mjs';
8
+ import { loadConfig } from './config.mjs';
8
9
  import { stopChildren } from './exec.mjs';
10
+ import { detectRepo } from './git.mjs';
11
+ import { waitingQuestions } from './handover.mjs';
9
12
  import { message } from './observe.mjs';
10
13
  import { applyEvent, startProgress } from './progress.mjs';
11
- import { isNewer } from './protocol.mjs';
14
+ import { PORTS } from './ports.mjs';
15
+ import { isNewer, } from './protocol.mjs';
12
16
  import { runRepo } from './run.mjs';
13
17
  import { packageVersion } from './version.mjs';
14
18
  export function socketPaths(agentDir = getAgentDir()) {
15
19
  return {
16
20
  socketPath: join(agentDir, 'next-issue.sock'),
17
21
  lockPath: join(agentDir, 'next-issue.lock'),
22
+ rootsPath: join(agentDir, 'next-issue.roots.json'),
18
23
  };
19
24
  }
20
25
  function alive(pid) {
@@ -55,12 +60,39 @@ async function lock(path) {
55
60
  }
56
61
  return false;
57
62
  }
63
+ async function readRoots(path) {
64
+ try {
65
+ const value = JSON.parse(await readFile(path, 'utf8'));
66
+ return new Set(Array.isArray(value) ? value.filter((root) => typeof root === 'string') : []);
67
+ }
68
+ catch {
69
+ return new Set();
70
+ }
71
+ }
72
+ export async function listQuestions(root) {
73
+ const config = await loadConfig(root);
74
+ return waitingQuestions(await detectRepo(root, config.remote), config, PORTS);
75
+ }
58
76
  export async function startWorker(options) {
59
77
  if (!(await lock(options.lockPath))) {
60
78
  return undefined;
61
79
  }
62
80
  await rm(options.socketPath, { force: true });
63
81
  const runs = new Map();
82
+ const roots = await readRoots(options.rootsPath);
83
+ let saving = Promise.resolve();
84
+ const remember = (root) => {
85
+ if (roots.has(root)) {
86
+ return;
87
+ }
88
+ roots.add(root);
89
+ const value = `${JSON.stringify([...roots], null, 2)}\n`;
90
+ saving = saving
91
+ .then(() => writeFile(options.rootsPath, value, { mode: 0o600 }))
92
+ .catch(() => undefined);
93
+ };
94
+ const reads = new Map();
95
+ const delivered = new WeakMap();
64
96
  let restart = false;
65
97
  let finish = () => undefined;
66
98
  const closed = new Promise((resolve) => {
@@ -81,6 +113,26 @@ export async function startWorker(options) {
81
113
  send(socket, value);
82
114
  }
83
115
  };
116
+ const sendQuestions = async (root, targets) => {
117
+ remember(root);
118
+ const read = (reads.get(root) ?? 0) + 1;
119
+ reads.set(root, read);
120
+ let value;
121
+ try {
122
+ value = { type: 'questions', root, questions: await options.questions(root) };
123
+ }
124
+ catch (error) {
125
+ value = { type: 'questions-error', root, message: message(error) };
126
+ }
127
+ for (const socket of targets()) {
128
+ const seen = delivered.get(socket) ?? new Map();
129
+ if ((seen.get(root) ?? 0) < read) {
130
+ seen.set(root, read);
131
+ delivered.set(socket, seen);
132
+ send(socket, value);
133
+ }
134
+ }
135
+ };
84
136
  const busy = () => [...runs.values()].some((run) => run.progress.status === 'running' || run.progress.status === 'stopping');
85
137
  const close = async () => {
86
138
  for (const socket of sockets.clients) {
@@ -89,6 +141,7 @@ export async function startWorker(options) {
89
141
  await new Promise((resolve) => sockets.close(() => resolve()));
90
142
  await new Promise((resolve) => server.close(() => resolve()));
91
143
  await rm(options.socketPath, { force: true });
144
+ await saving;
92
145
  await rm(options.lockPath, { force: true });
93
146
  finish();
94
147
  };
@@ -109,6 +162,7 @@ export async function startWorker(options) {
109
162
  }
110
163
  const run = { progress: startProgress(), stop: false };
111
164
  runs.set(root, run);
165
+ remember(root);
112
166
  const interrupts = {
113
167
  afterIssue: () => run.stop,
114
168
  afterRound: () => run.stop,
@@ -132,7 +186,12 @@ export async function startWorker(options) {
132
186
  options
133
187
  .runRepo(root, { ...parseArgs([]), merge }, {
134
188
  interrupts,
135
- onEvent: (record) => update(applyEvent(run.progress, record)),
189
+ onEvent: (record) => {
190
+ update(applyEvent(run.progress, record));
191
+ if (record['kind'] === 'issue.end' || record['kind'] === 'issue.error') {
192
+ void sendQuestions(root, () => sockets.clients);
193
+ }
194
+ },
136
195
  })
137
196
  .then(() => {
138
197
  const status = run.stop
@@ -157,6 +216,9 @@ export async function startWorker(options) {
157
216
  sockets.on('connection', (socket) => {
158
217
  send(socket, { type: 'hello', version: options.version });
159
218
  send(socket, snapshot());
219
+ for (const root of roots) {
220
+ void sendQuestions(root, () => [socket]);
221
+ }
160
222
  socket.on('message', (data) => {
161
223
  let value;
162
224
  try {
@@ -177,6 +239,9 @@ export async function startWorker(options) {
177
239
  else if (value.type === 'stop') {
178
240
  stop(value.root);
179
241
  }
242
+ else if (value.type === 'questions') {
243
+ void sendQuestions(value.root, () => [socket]);
244
+ }
180
245
  });
181
246
  });
182
247
  await new Promise((resolve, reject) => {
@@ -200,6 +265,7 @@ export async function serve() {
200
265
  ...paths,
201
266
  version: await packageVersion(),
202
267
  runRepo,
268
+ questions: listQuestions,
203
269
  });
204
270
  if (worker === undefined) {
205
271
  process.stderr.write('next-issue: another worker is running\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",