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 +16 -0
- package/dist/github.mjs +3 -3
- package/dist/handover.mjs +36 -10
- package/dist/serve.mjs +72 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
+
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 {
|
|
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) =>
|
|
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');
|