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 CHANGED
@@ -461,12 +461,28 @@ 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, the url of the comment that holds it, the `owner/name`
475
+ repository, the pull request when there is one, and the `needsHuman` and
476
+ `skip` labels. It reads them
477
+ from GitHub, so the list is the same after a restart. It sends them when a
478
+ client connects, for each repository it knows, after each issue of a run
479
+ ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
480
+ An issue that no longer has the label leaves the list. An issue with the
481
+ label but no hand-over comment is in the list with an empty reason and url.
482
+ The issues are read one at a time. A client never gets an older list after
483
+ a newer one. When the client adds an `"id"` to its message, the list that
484
+ answers it carries the same `id` and is read after the message arrived. When the list cannot be read, the client gets
485
+ `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
470
486
  - stops a run, when a client asks, like a second `Ctrl-C`: after the current
471
487
  round, with the state kept;
472
488
  - 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,50 @@
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
+ repo: `${repo.owner}/${repo.name}`,
37
+ ...(pr === undefined ? {} : { pr }),
38
+ labels: { needsHuman: config.labels.needsHuman, skip: config.labels.skip },
39
+ });
40
+ }
41
+ return questions;
42
+ }
43
+ export async function handOverReply(context, issue, pr) {
44
+ const { repo, ports } = context;
45
+ const comments = await ports.threadComments(repo, pr ?? issue);
46
+ const writer = writers(repo, ports);
47
+ const last = await lastHandOver(comments, writer);
22
48
  if (last === -1) {
23
49
  return [];
24
50
  }
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,30 @@ export async function startWorker(options) {
81
113
  send(socket, value);
82
114
  }
83
115
  };
116
+ const sendQuestions = async (root, targets, id) => {
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
+ const last = seen.get(root);
130
+ if (last === undefined || last.read < read) {
131
+ seen.set(root, { read, value });
132
+ delivered.set(socket, seen);
133
+ send(socket, id === undefined ? value : { ...value, id });
134
+ }
135
+ else if (id !== undefined) {
136
+ send(socket, { ...last.value, id });
137
+ }
138
+ }
139
+ };
84
140
  const busy = () => [...runs.values()].some((run) => run.progress.status === 'running' || run.progress.status === 'stopping');
85
141
  const close = async () => {
86
142
  for (const socket of sockets.clients) {
@@ -89,6 +145,7 @@ export async function startWorker(options) {
89
145
  await new Promise((resolve) => sockets.close(() => resolve()));
90
146
  await new Promise((resolve) => server.close(() => resolve()));
91
147
  await rm(options.socketPath, { force: true });
148
+ await saving;
92
149
  await rm(options.lockPath, { force: true });
93
150
  finish();
94
151
  };
@@ -109,6 +166,7 @@ export async function startWorker(options) {
109
166
  }
110
167
  const run = { progress: startProgress(), stop: false };
111
168
  runs.set(root, run);
169
+ remember(root);
112
170
  const interrupts = {
113
171
  afterIssue: () => run.stop,
114
172
  afterRound: () => run.stop,
@@ -132,7 +190,12 @@ export async function startWorker(options) {
132
190
  options
133
191
  .runRepo(root, { ...parseArgs([]), merge }, {
134
192
  interrupts,
135
- onEvent: (record) => update(applyEvent(run.progress, record)),
193
+ onEvent: (record) => {
194
+ update(applyEvent(run.progress, record));
195
+ if (record['kind'] === 'issue.end' || record['kind'] === 'issue.error') {
196
+ void sendQuestions(root, () => sockets.clients);
197
+ }
198
+ },
136
199
  })
137
200
  .then(() => {
138
201
  const status = run.stop
@@ -157,6 +220,9 @@ export async function startWorker(options) {
157
220
  sockets.on('connection', (socket) => {
158
221
  send(socket, { type: 'hello', version: options.version });
159
222
  send(socket, snapshot());
223
+ for (const root of roots) {
224
+ void sendQuestions(root, () => [socket]);
225
+ }
160
226
  socket.on('message', (data) => {
161
227
  let value;
162
228
  try {
@@ -177,6 +243,9 @@ export async function startWorker(options) {
177
243
  else if (value.type === 'stop') {
178
244
  stop(value.root);
179
245
  }
246
+ else if (value.type === 'questions') {
247
+ void sendQuestions(value.root, () => [socket], value.id);
248
+ }
180
249
  });
181
250
  });
182
251
  await new Promise((resolve, reject) => {
@@ -200,6 +269,7 @@ export async function serve() {
200
269
  ...paths,
201
270
  version: await packageVersion(),
202
271
  runRepo,
272
+ questions: listQuestions,
203
273
  });
204
274
  if (worker === undefined) {
205
275
  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.12.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",