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 +13 -0
- package/dist/github.mjs +3 -3
- package/dist/handover.mjs +33 -10
- package/dist/serve.mjs +68 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
7
|
+
return (author) => {
|
|
10
8
|
if (!access.has(author)) {
|
|
11
|
-
access.set(author,
|
|
9
|
+
access.set(author, ports.canWrite(repo, author));
|
|
12
10
|
}
|
|
13
|
-
return access.get(author)
|
|
11
|
+
return access.get(author);
|
|
14
12
|
};
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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 {
|
|
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) =>
|
|
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');
|