next-issue 2.4.0 → 2.4.2
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 +68 -68
- package/dist/agents.mjs +32 -31
- package/dist/args.mjs +17 -17
- package/dist/cli.mjs +16 -14
- package/dist/config.mjs +42 -42
- package/dist/exec.mjs +26 -26
- package/dist/git.mjs +40 -40
- package/dist/github.mjs +110 -79
- package/dist/interrupt.mjs +7 -7
- package/dist/loop.mjs +5 -3
- package/dist/observe.mjs +19 -19
- package/dist/pipeline.mjs +138 -116
- package/dist/progress.mjs +22 -22
- package/dist/prompts.mjs +52 -52
- package/dist/run.mjs +28 -28
- package/dist/serve.mjs +49 -45
- package/dist/setup.mjs +13 -13
- package/dist/state.mjs +20 -20
- package/dist/summary.mjs +7 -7
- package/dist/verdict.mjs +30 -25
- package/dist/version.mjs +3 -3
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -98,32 +98,32 @@ when:
|
|
|
98
98
|
- the check budget or the review budget runs out;
|
|
99
99
|
- a fixer round adds no commit and gives no reason, so there is no progress;
|
|
100
100
|
- the fixer reports that the checks fail for a reason the change did not cause,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
such as a test already broken on the base branch or one that fails only under
|
|
102
|
+
load. It changes nothing and the reason reaches the pull request, rather than
|
|
103
|
+
the fixer spending its whole budget on a failure it cannot fix;
|
|
104
104
|
- a fixer disputes the findings and leaves a change behind, either in the
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
worktree or in a commit of its own. A dispute carries a reason, not a diff,
|
|
106
|
+
so the harness throws none of it away: an unclean worktree stays on the
|
|
107
|
+
disk, and a commit of the fixer stays on the local branch. Neither is
|
|
108
|
+
pushed, so the pull request shows no such change. It holds the reason of the
|
|
109
|
+
dispute, and the change is on the machine that ran the harness;
|
|
110
110
|
- the reviewer repeats a finding set from an earlier round, which shows a
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
ping-pong between the fixer and the reviewer, or a finding that the fixer
|
|
112
|
+
disputed and the reviewer holds to. The person then decides;
|
|
113
113
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
114
114
|
- with `--merge`, the merge of an approved pull request fails, for example
|
|
115
|
-
|
|
115
|
+
because branch protection asks for a human approval;
|
|
116
116
|
- with `--merge`, a queued merge does not finish in `checkTimeoutMinutes`, or
|
|
117
|
-
|
|
117
|
+
the pull request closes without a merge;
|
|
118
118
|
- with `--merge`, the checks on the base branch fail after the merge and the
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
fixer cannot fix them, or they do not finish in time. The base branch is
|
|
120
|
+
then broken, so the run stops after this issue;
|
|
121
121
|
- an agent does not finish in `agentTimeoutMinutes`;
|
|
122
122
|
- the setup command fails, or does not finish in `setupTimeoutMinutes`;
|
|
123
123
|
- a step of the run fails with an error. A `gh` call that is safe to repeat,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
such as a read or a label edit, is tried up to three times first when the
|
|
125
|
+
network fails. A `gh pr create` or `gh pr comment` is tried once, so a lost
|
|
126
|
+
reply never makes a duplicate.
|
|
127
127
|
|
|
128
128
|
The fixer sees the findings of all earlier rounds, not only the last one, with
|
|
129
129
|
its own disputes next to them. From round two, the reviewer judges only the
|
|
@@ -213,11 +213,11 @@ jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
|
|
|
213
213
|
|
|
214
214
|
Three levels of console output:
|
|
215
215
|
|
|
216
|
-
| Level
|
|
217
|
-
|
|
|
218
|
-
| `--quiet`
|
|
219
|
-
| default
|
|
220
|
-
| `--verbose` | The above plus every command and all agent text
|
|
216
|
+
| Level | Shows |
|
|
217
|
+
| ----------- | ---------------------------------------------------------------- |
|
|
218
|
+
| `--quiet` | Run and issue milestones, verdicts, hand-overs, errors |
|
|
219
|
+
| default | The above plus each step end, each tool call and failed commands |
|
|
220
|
+
| `--verbose` | The above plus every command and all agent text |
|
|
221
221
|
|
|
222
222
|
## Requirements
|
|
223
223
|
|
|
@@ -281,26 +281,26 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
281
281
|
}
|
|
282
282
|
```
|
|
283
283
|
|
|
284
|
-
| Field
|
|
285
|
-
|
|
|
286
|
-
| `remote`
|
|
287
|
-
| `issueLimit`
|
|
288
|
-
| `maxCiFixes`
|
|
289
|
-
| `maxCommitFixes`
|
|
290
|
-
| `maxReviewRounds`
|
|
291
|
-
| `checkIntervalSeconds`
|
|
292
|
-
| `checkGraceSeconds`
|
|
293
|
-
| `checkTimeoutMinutes`
|
|
294
|
-
| `agentTimeoutMinutes`
|
|
295
|
-
| `setupTimeoutMinutes`
|
|
296
|
-
| `commandTimeoutMinutes` | `10`
|
|
297
|
-
| `logMaxChars`
|
|
298
|
-
| `diffMaxChars`
|
|
299
|
-
| `keepRunDays`
|
|
300
|
-
| `draftPullRequest`
|
|
301
|
-
| `setupCommand`
|
|
302
|
-
| `models`
|
|
303
|
-
| `labels`
|
|
284
|
+
| Field | Default | Effect |
|
|
285
|
+
| ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
286
|
+
| `remote` | `origin` | The git remote for the repository and the branches |
|
|
287
|
+
| `issueLimit` | `100` | The maximum number of open issues to read. With `labels.ready` set, only issues with the ready, in-progress or in-review label count |
|
|
288
|
+
| `maxCiFixes` | `5` | The budget for check fixes per issue |
|
|
289
|
+
| `maxCommitFixes` | `10` | The budget for fixes of a commit that a git hook stopped, per issue |
|
|
290
|
+
| `maxReviewRounds` | `5` | The budget for review rounds per issue |
|
|
291
|
+
| `checkIntervalSeconds` | `15` | The time between two check states |
|
|
292
|
+
| `checkGraceSeconds` | `60` | The time to wait for the first check to show |
|
|
293
|
+
| `checkTimeoutMinutes` | `60` | The limit for one check wait |
|
|
294
|
+
| `agentTimeoutMinutes` | `30` | The limit for one agent call |
|
|
295
|
+
| `setupTimeoutMinutes` | `15` | The limit for the setup command |
|
|
296
|
+
| `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
|
|
297
|
+
| `logMaxChars` | `20000` | The maximum length of the failed job logs, shared between the failed jobs |
|
|
298
|
+
| `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
|
|
299
|
+
| `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
|
|
300
|
+
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
301
|
+
| `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
|
|
302
|
+
| `models` | `null` per role | The pi model per agent role, as `provider/model`: `implementer`, `reviewer`, `fixer` |
|
|
303
|
+
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
304
304
|
|
|
305
305
|
An empty `labels.ready` turns the ready requirement off. The harness then
|
|
306
306
|
claims every open issue that no other label holds back.
|
|
@@ -314,17 +314,17 @@ your pi settings.
|
|
|
314
314
|
Without a `setupCommand`, the harness looks in the new worktree for a lock file
|
|
315
315
|
and runs the install command that goes with it:
|
|
316
316
|
|
|
317
|
-
| Lock file
|
|
318
|
-
|
|
|
319
|
-
| `pnpm-lock.yaml`
|
|
320
|
-
| `package-lock.json`
|
|
321
|
-
| `yarn.lock`
|
|
322
|
-
| `bun.lock`, `bun.lockb` | `bun install --frozen-lockfile`
|
|
323
|
-
| `Cargo.lock`
|
|
324
|
-
| `go.sum`
|
|
325
|
-
| `uv.lock`
|
|
326
|
-
| `poetry.lock`
|
|
327
|
-
| `Gemfile.lock`
|
|
317
|
+
| Lock file | Command |
|
|
318
|
+
| ----------------------- | ------------------------------------------------------------------------------------ |
|
|
319
|
+
| `pnpm-lock.yaml` | `pnpm install --frozen-lockfile` |
|
|
320
|
+
| `package-lock.json` | `npm ci` |
|
|
321
|
+
| `yarn.lock` | `yarn install --frozen-lockfile`, or `yarn install --immutable` with a `.yarnrc.yml` |
|
|
322
|
+
| `bun.lock`, `bun.lockb` | `bun install --frozen-lockfile` |
|
|
323
|
+
| `Cargo.lock` | `cargo fetch` |
|
|
324
|
+
| `go.sum` | `go mod download` |
|
|
325
|
+
| `uv.lock` | `uv sync` |
|
|
326
|
+
| `poetry.lock` | `poetry install` |
|
|
327
|
+
| `Gemfile.lock` | `bundle install` |
|
|
328
328
|
|
|
329
329
|
The first match in that order wins. A project with no lock file in the list
|
|
330
330
|
runs no setup, and a `setupCommand` in the config file always wins. Thus the
|
|
@@ -353,21 +353,21 @@ that a label still holds back stays as it was.
|
|
|
353
353
|
|
|
354
354
|
## Commands and options
|
|
355
355
|
|
|
356
|
-
| Argument
|
|
357
|
-
|
|
|
358
|
-
| `init`
|
|
359
|
-
| `serve`
|
|
360
|
-
| `stop`
|
|
361
|
-
| `--force`
|
|
362
|
-
| `--once`
|
|
363
|
-
| `--max <n>` | Handle at most n issues
|
|
364
|
-
| `--merge`
|
|
365
|
-
| `--watch`
|
|
366
|
-
| `--reset`
|
|
367
|
-
| `--json`
|
|
368
|
-
| `--verbose` | Show every command and all agent output
|
|
369
|
-
| `--quiet`
|
|
370
|
-
| `--help`
|
|
356
|
+
| Argument | Effect |
|
|
357
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
358
|
+
| `init` | Write the config file with the defaults |
|
|
359
|
+
| `serve` | Run the worker that pi extensions talk to, see [Worker](#worker) |
|
|
360
|
+
| `stop` | Stop the worker, see [Worker](#worker) |
|
|
361
|
+
| `--force` | Replace an existing config file, with `init` |
|
|
362
|
+
| `--once` | Stop after the first handled issue |
|
|
363
|
+
| `--max <n>` | Handle at most n issues |
|
|
364
|
+
| `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
|
|
365
|
+
| `--watch` | Keep running, and read the open issues again after each pass |
|
|
366
|
+
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
367
|
+
| `--json` | Print the machine summary instead of the text one |
|
|
368
|
+
| `--verbose` | Show every command and all agent output |
|
|
369
|
+
| `--quiet` | Show only the milestones and the summary |
|
|
370
|
+
| `--help` | Show the option list |
|
|
371
371
|
|
|
372
372
|
The exit code is 1 when an issue needs a person or failed, and 0 when it does
|
|
373
373
|
not, so a cron job or a CI step can read the result of the run.
|
package/dist/agents.mjs
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
import { join, sep } from
|
|
2
|
-
import { createAgentSession, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, } from
|
|
3
|
-
import { onStop } from
|
|
4
|
-
import { emptyUsage } from
|
|
1
|
+
import { join, sep } from 'node:path';
|
|
2
|
+
import { createAgentSession, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { onStop } from './exec.mjs';
|
|
4
|
+
import { emptyUsage } from './observe.mjs';
|
|
5
5
|
const REVIEWING = {
|
|
6
|
-
tools: [
|
|
6
|
+
tools: ['read', 'bash', 'grep', 'find', 'ls'],
|
|
7
7
|
};
|
|
8
8
|
const CODING = {
|
|
9
|
-
tools: [
|
|
9
|
+
tools: ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'],
|
|
10
10
|
};
|
|
11
11
|
export { CODING, REVIEWING };
|
|
12
|
-
export const SUBMIT_TOOL =
|
|
12
|
+
export const SUBMIT_TOOL = 'submit_result';
|
|
13
13
|
export function isOwnExtension(path) {
|
|
14
|
-
return path.split(sep).includes(
|
|
14
|
+
return path.split(sep).includes('pi-next-issue');
|
|
15
15
|
}
|
|
16
16
|
export async function runAgent(recorder, request) {
|
|
17
17
|
const log = recorder.scope({ role: request.name });
|
|
18
18
|
const runtime = request.runtime ?? {};
|
|
19
19
|
const agentDir = runtime.agentDir ?? getAgentDir();
|
|
20
20
|
const started = Date.now();
|
|
21
|
-
log.event(
|
|
21
|
+
log.event('agent.start', { model: request.model, timeoutMs: request.timeoutMs }, 'normal');
|
|
22
22
|
let structured;
|
|
23
23
|
const customTools = [];
|
|
24
24
|
if (request.outputSchema !== undefined) {
|
|
25
25
|
customTools.push(defineTool({
|
|
26
26
|
name: SUBMIT_TOOL,
|
|
27
|
-
label:
|
|
28
|
-
description:
|
|
27
|
+
label: 'Submit result',
|
|
28
|
+
description: 'Give the result. Call it once, as your last action.',
|
|
29
29
|
parameters: request.outputSchema,
|
|
30
30
|
async execute(_toolCallId, params) {
|
|
31
31
|
structured = params;
|
|
32
32
|
return {
|
|
33
|
-
content: [{ type:
|
|
33
|
+
content: [{ type: 'text', text: 'Result saved.' }],
|
|
34
34
|
details: {},
|
|
35
35
|
terminate: true,
|
|
36
36
|
};
|
|
@@ -46,10 +46,11 @@ export async function runAgent(recorder, request) {
|
|
|
46
46
|
}),
|
|
47
47
|
});
|
|
48
48
|
await resourceLoader.reload();
|
|
49
|
-
const modelRuntime = runtime.modelRuntime ??
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
const modelRuntime = runtime.modelRuntime ??
|
|
50
|
+
(await ModelRuntime.create({
|
|
51
|
+
authPath: join(agentDir, 'auth.json'),
|
|
52
|
+
modelsPath: join(agentDir, 'models.json'),
|
|
53
|
+
}));
|
|
53
54
|
const model = request.model === undefined ? undefined : await findModel(modelRuntime, request.model);
|
|
54
55
|
const { session, modelFallbackMessage } = await createAgentSession({
|
|
55
56
|
cwd: request.cwd,
|
|
@@ -63,7 +64,7 @@ export async function runAgent(recorder, request) {
|
|
|
63
64
|
...(model === undefined ? {} : { model }),
|
|
64
65
|
});
|
|
65
66
|
if (modelFallbackMessage !== undefined) {
|
|
66
|
-
log.event(
|
|
67
|
+
log.event('agent.model', { note: modelFallbackMessage }, 'quiet');
|
|
67
68
|
}
|
|
68
69
|
let timedOut = false;
|
|
69
70
|
const timer = request.timeoutMs === undefined
|
|
@@ -80,9 +81,9 @@ export async function runAgent(recorder, request) {
|
|
|
80
81
|
if (timedOut) {
|
|
81
82
|
throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
|
|
82
83
|
}
|
|
83
|
-
const last = session.messages.findLast((message) => message.role ===
|
|
84
|
-
if (last !== undefined &&
|
|
85
|
-
throw new Error(`The ${request.name} agent failed: ${last.errorMessage ??
|
|
84
|
+
const last = session.messages.findLast((message) => message.role === 'assistant');
|
|
85
|
+
if (last !== undefined && 'stopReason' in last && last.stopReason === 'error') {
|
|
86
|
+
throw new Error(`The ${request.name} agent failed: ${last.errorMessage ?? 'no reason given'}`);
|
|
86
87
|
}
|
|
87
88
|
const stats = session.getSessionStats();
|
|
88
89
|
const usage = { ...emptyUsage(), ...stats.tokens };
|
|
@@ -96,7 +97,7 @@ export async function runAgent(recorder, request) {
|
|
|
96
97
|
costUsd: stats.cost,
|
|
97
98
|
});
|
|
98
99
|
return {
|
|
99
|
-
text: (session.getLastAssistantText() ??
|
|
100
|
+
text: (session.getLastAssistantText() ?? '').trim(),
|
|
100
101
|
usage,
|
|
101
102
|
turns: stats.assistantMessages,
|
|
102
103
|
toolCalls: stats.toolCalls,
|
|
@@ -115,7 +116,7 @@ export async function runAgent(recorder, request) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
async function findModel(modelRuntime, name) {
|
|
118
|
-
const slash = name.indexOf(
|
|
119
|
+
const slash = name.indexOf('/');
|
|
119
120
|
if (slash <= 0) {
|
|
120
121
|
throw new Error(`The model ${name} is not in the form provider/model.`);
|
|
121
122
|
}
|
|
@@ -126,22 +127,22 @@ async function findModel(modelRuntime, name) {
|
|
|
126
127
|
return found;
|
|
127
128
|
}
|
|
128
129
|
function observe(log, event) {
|
|
129
|
-
if (event.type ===
|
|
130
|
-
log.event(
|
|
130
|
+
if (event.type === 'tool_execution_start') {
|
|
131
|
+
log.event('tool', toolFields(event.toolName, event.args));
|
|
131
132
|
}
|
|
132
|
-
else if (event.type ===
|
|
133
|
-
log.event(
|
|
133
|
+
else if (event.type === 'tool_execution_end' && event.isError) {
|
|
134
|
+
log.event('tool.error', { tool: event.toolName }, 'quiet');
|
|
134
135
|
}
|
|
135
|
-
else if (event.type ===
|
|
136
|
-
event.assistantMessageEvent.type ===
|
|
137
|
-
log.level ===
|
|
136
|
+
else if (event.type === 'message_update' &&
|
|
137
|
+
event.assistantMessageEvent.type === 'text_delta' &&
|
|
138
|
+
log.level === 'verbose') {
|
|
138
139
|
process.stderr.write(event.assistantMessageEvent.delta);
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
142
|
export function toolFields(name, input) {
|
|
142
|
-
if (name !==
|
|
143
|
+
if (name !== 'bash' || typeof input !== 'object' || input === null) {
|
|
143
144
|
return { tool: name };
|
|
144
145
|
}
|
|
145
146
|
const { command } = input;
|
|
146
|
-
return typeof command ===
|
|
147
|
+
return typeof command === 'string' ? { tool: name, command } : { tool: name };
|
|
147
148
|
}
|
package/dist/args.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG_FILE } from
|
|
1
|
+
import { CONFIG_FILE } from './config.mjs';
|
|
2
2
|
export const USAGE = `next-issue [command] [options]
|
|
3
3
|
|
|
4
4
|
init write ${CONFIG_FILE} with the defaults
|
|
@@ -18,7 +18,7 @@ export const USAGE = `next-issue [command] [options]
|
|
|
18
18
|
`;
|
|
19
19
|
export function parseArgs(argv) {
|
|
20
20
|
const args = {
|
|
21
|
-
command:
|
|
21
|
+
command: 'run',
|
|
22
22
|
once: false,
|
|
23
23
|
help: false,
|
|
24
24
|
json: false,
|
|
@@ -26,44 +26,44 @@ export function parseArgs(argv) {
|
|
|
26
26
|
force: false,
|
|
27
27
|
merge: false,
|
|
28
28
|
watch: false,
|
|
29
|
-
level:
|
|
29
|
+
level: 'normal',
|
|
30
30
|
};
|
|
31
31
|
for (let index = 0; index < argv.length; index += 1) {
|
|
32
32
|
const arg = argv[index];
|
|
33
|
-
if (index === 0 && !arg.startsWith(
|
|
34
|
-
if (arg !==
|
|
33
|
+
if (index === 0 && !arg.startsWith('-')) {
|
|
34
|
+
if (arg !== 'init' && arg !== 'serve' && arg !== 'stop') {
|
|
35
35
|
throw new Error(`Unknown command: ${arg}`);
|
|
36
36
|
}
|
|
37
37
|
args.command = arg;
|
|
38
38
|
}
|
|
39
|
-
else if (arg ===
|
|
39
|
+
else if (arg === '--force') {
|
|
40
40
|
args.force = true;
|
|
41
41
|
}
|
|
42
|
-
else if (arg ===
|
|
42
|
+
else if (arg === '--json') {
|
|
43
43
|
args.json = true;
|
|
44
44
|
}
|
|
45
|
-
else if (arg ===
|
|
45
|
+
else if (arg === '--help') {
|
|
46
46
|
args.help = true;
|
|
47
47
|
}
|
|
48
|
-
else if (arg ===
|
|
48
|
+
else if (arg === '--merge') {
|
|
49
49
|
args.merge = true;
|
|
50
50
|
}
|
|
51
|
-
else if (arg ===
|
|
51
|
+
else if (arg === '--watch') {
|
|
52
52
|
args.watch = true;
|
|
53
53
|
}
|
|
54
|
-
else if (arg ===
|
|
54
|
+
else if (arg === '--reset') {
|
|
55
55
|
args.reset = true;
|
|
56
56
|
}
|
|
57
|
-
else if (arg ===
|
|
57
|
+
else if (arg === '--once') {
|
|
58
58
|
args.once = true;
|
|
59
59
|
}
|
|
60
|
-
else if (arg ===
|
|
61
|
-
args.level =
|
|
60
|
+
else if (arg === '--verbose') {
|
|
61
|
+
args.level = 'verbose';
|
|
62
62
|
}
|
|
63
|
-
else if (arg ===
|
|
64
|
-
args.level =
|
|
63
|
+
else if (arg === '--quiet') {
|
|
64
|
+
args.level = 'quiet';
|
|
65
65
|
}
|
|
66
|
-
else if (arg ===
|
|
66
|
+
else if (arg === '--max') {
|
|
67
67
|
args.max = whole(argv[++index], arg);
|
|
68
68
|
}
|
|
69
69
|
else {
|
package/dist/cli.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseArgs, USAGE } from
|
|
3
|
-
import { writeDefaultConfig } from
|
|
4
|
-
import { ensureIgnored, repoRoot, WORKTREE_DIR } from
|
|
5
|
-
import { listenForInterrupts } from
|
|
6
|
-
import { message } from
|
|
7
|
-
import { runRepo } from
|
|
8
|
-
import { serve, socketPaths, stopWorker } from
|
|
9
|
-
import { STATE_DIR } from
|
|
10
|
-
import { formatSummary } from
|
|
2
|
+
import { parseArgs, USAGE } from './args.mjs';
|
|
3
|
+
import { writeDefaultConfig } from './config.mjs';
|
|
4
|
+
import { ensureIgnored, repoRoot, WORKTREE_DIR } from './git.mjs';
|
|
5
|
+
import { listenForInterrupts } from './interrupt.mjs';
|
|
6
|
+
import { message } from './observe.mjs';
|
|
7
|
+
import { runRepo } from './run.mjs';
|
|
8
|
+
import { serve, socketPaths, stopWorker } from './serve.mjs';
|
|
9
|
+
import { STATE_DIR } from './state.mjs';
|
|
10
|
+
import { formatSummary } from './summary.mjs';
|
|
11
11
|
function write(stream, text) {
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
13
|
-
stream.write(text, (error) =>
|
|
13
|
+
stream.write(text, (error) => error === null || error === undefined ? resolve() : reject(error));
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
async function main() {
|
|
@@ -19,16 +19,18 @@ async function main() {
|
|
|
19
19
|
await write(process.stdout, USAGE);
|
|
20
20
|
return 0;
|
|
21
21
|
}
|
|
22
|
-
if (args.command ===
|
|
22
|
+
if (args.command === 'serve') {
|
|
23
23
|
return serve();
|
|
24
24
|
}
|
|
25
|
-
if (args.command ===
|
|
25
|
+
if (args.command === 'stop') {
|
|
26
26
|
const pid = await stopWorker(socketPaths().lockPath);
|
|
27
|
-
await write(process.stdout, pid === undefined
|
|
27
|
+
await write(process.stdout, pid === undefined
|
|
28
|
+
? 'next-issue: no worker is running\n'
|
|
29
|
+
: `next-issue: stopped the worker with process id ${pid}\n`);
|
|
28
30
|
return 0;
|
|
29
31
|
}
|
|
30
32
|
const root = await repoRoot(process.cwd());
|
|
31
|
-
if (args.command ===
|
|
33
|
+
if (args.command === 'init') {
|
|
32
34
|
return init(root, args.force);
|
|
33
35
|
}
|
|
34
36
|
const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
|
package/dist/config.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { readFile, writeFile } from
|
|
2
|
-
import { join } from
|
|
3
|
-
import { message } from
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { message } from './observe.mjs';
|
|
4
4
|
const DEFAULTS = {
|
|
5
|
-
remote:
|
|
5
|
+
remote: 'origin',
|
|
6
6
|
issueLimit: 100,
|
|
7
7
|
maxCiFixes: 5,
|
|
8
8
|
maxCommitFixes: 10,
|
|
@@ -19,49 +19,49 @@ const DEFAULTS = {
|
|
|
19
19
|
draftPullRequest: true,
|
|
20
20
|
models: {},
|
|
21
21
|
labels: {
|
|
22
|
-
ready:
|
|
23
|
-
inProgress:
|
|
24
|
-
inReview:
|
|
25
|
-
done:
|
|
26
|
-
needsHuman:
|
|
27
|
-
skip:
|
|
22
|
+
ready: 'status:todo',
|
|
23
|
+
inProgress: 'status:in-progress',
|
|
24
|
+
inReview: 'status:in-review',
|
|
25
|
+
done: 'status:done',
|
|
26
|
+
needsHuman: 'status:needs-human',
|
|
27
|
+
skip: 'status:blocked',
|
|
28
28
|
},
|
|
29
29
|
};
|
|
30
30
|
function defaults() {
|
|
31
31
|
return { ...DEFAULTS, models: { ...DEFAULTS.models }, labels: { ...DEFAULTS.labels } };
|
|
32
32
|
}
|
|
33
|
-
const FIELDS = [...Object.keys(DEFAULTS),
|
|
33
|
+
const FIELDS = [...Object.keys(DEFAULTS), 'setupCommand'];
|
|
34
34
|
const WHOLE_FIELDS = [
|
|
35
|
-
[
|
|
36
|
-
[
|
|
37
|
-
[
|
|
38
|
-
[
|
|
39
|
-
[
|
|
40
|
-
[
|
|
41
|
-
[
|
|
42
|
-
[
|
|
43
|
-
[
|
|
44
|
-
[
|
|
45
|
-
[
|
|
46
|
-
[
|
|
47
|
-
[
|
|
35
|
+
['issueLimit', 1],
|
|
36
|
+
['maxCiFixes', 0],
|
|
37
|
+
['maxCommitFixes', 0],
|
|
38
|
+
['maxReviewRounds', 1],
|
|
39
|
+
['checkIntervalSeconds', 1],
|
|
40
|
+
['checkGraceSeconds', 0],
|
|
41
|
+
['checkTimeoutMinutes', 1],
|
|
42
|
+
['agentTimeoutMinutes', 1],
|
|
43
|
+
['setupTimeoutMinutes', 1],
|
|
44
|
+
['commandTimeoutMinutes', 1],
|
|
45
|
+
['logMaxChars', 1],
|
|
46
|
+
['diffMaxChars', 1],
|
|
47
|
+
['keepRunDays', 0],
|
|
48
48
|
];
|
|
49
|
-
const MODEL_ROLES = [
|
|
50
|
-
const LABEL_NAMES = [
|
|
49
|
+
const MODEL_ROLES = ['implementer', 'reviewer', 'fixer'];
|
|
50
|
+
const LABEL_NAMES = ['ready', 'inProgress', 'inReview', 'done', 'needsHuman', 'skip'];
|
|
51
51
|
const FILE_DEFAULTS = {
|
|
52
52
|
...defaults(),
|
|
53
53
|
models: Object.fromEntries(MODEL_ROLES.map((role) => [role, null])),
|
|
54
54
|
};
|
|
55
|
-
export const CONFIG_FILE =
|
|
55
|
+
export const CONFIG_FILE = 'next-issue.config.json';
|
|
56
56
|
export async function writeDefaultConfig(root, force) {
|
|
57
57
|
const path = join(root, CONFIG_FILE);
|
|
58
58
|
try {
|
|
59
59
|
await writeFile(path, `${JSON.stringify(FILE_DEFAULTS, null, 2)}\n`, {
|
|
60
|
-
flag: force ?
|
|
60
|
+
flag: force ? 'w' : 'wx',
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
63
|
catch (error) {
|
|
64
|
-
if (error.code ===
|
|
64
|
+
if (error.code === 'EEXIST') {
|
|
65
65
|
throw new Error(`${CONFIG_FILE} exists already. Use --force to replace it.`);
|
|
66
66
|
}
|
|
67
67
|
throw error;
|
|
@@ -71,10 +71,10 @@ export async function writeDefaultConfig(root, force) {
|
|
|
71
71
|
export async function loadConfig(root) {
|
|
72
72
|
let raw;
|
|
73
73
|
try {
|
|
74
|
-
raw = await readFile(join(root, CONFIG_FILE),
|
|
74
|
+
raw = await readFile(join(root, CONFIG_FILE), 'utf8');
|
|
75
75
|
}
|
|
76
76
|
catch (error) {
|
|
77
|
-
if (error.code ===
|
|
77
|
+
if (error.code === 'ENOENT') {
|
|
78
78
|
return defaults();
|
|
79
79
|
}
|
|
80
80
|
throw new Error(`${CONFIG_FILE} cannot be read: ${message(error)}`);
|
|
@@ -89,7 +89,7 @@ export async function loadConfig(root) {
|
|
|
89
89
|
return parseConfig(parsed);
|
|
90
90
|
}
|
|
91
91
|
function parseConfig(value) {
|
|
92
|
-
const file = record(value,
|
|
92
|
+
const file = record(value, 'the file');
|
|
93
93
|
for (const key of Object.keys(file)) {
|
|
94
94
|
if (!FIELDS.includes(key)) {
|
|
95
95
|
throw new Error(`${CONFIG_FILE} holds an unknown field: ${key}`);
|
|
@@ -97,7 +97,7 @@ function parseConfig(value) {
|
|
|
97
97
|
}
|
|
98
98
|
const config = defaults();
|
|
99
99
|
if (file.remote !== undefined) {
|
|
100
|
-
config.remote = text(file.remote,
|
|
100
|
+
config.remote = text(file.remote, 'remote', false);
|
|
101
101
|
}
|
|
102
102
|
for (const [field, least] of WHOLE_FIELDS) {
|
|
103
103
|
if (file[field] !== undefined) {
|
|
@@ -105,16 +105,16 @@ function parseConfig(value) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
if (file.draftPullRequest !== undefined) {
|
|
108
|
-
if (typeof file.draftPullRequest !==
|
|
108
|
+
if (typeof file.draftPullRequest !== 'boolean') {
|
|
109
109
|
throw new Error(`${CONFIG_FILE}: draftPullRequest must be true or false.`);
|
|
110
110
|
}
|
|
111
111
|
config.draftPullRequest = file.draftPullRequest;
|
|
112
112
|
}
|
|
113
113
|
if (file.setupCommand !== undefined) {
|
|
114
|
-
config.setupCommand = text(file.setupCommand,
|
|
114
|
+
config.setupCommand = text(file.setupCommand, 'setupCommand', false);
|
|
115
115
|
}
|
|
116
116
|
if (file.models !== undefined) {
|
|
117
|
-
const models = record(file.models,
|
|
117
|
+
const models = record(file.models, 'models');
|
|
118
118
|
for (const key of Object.keys(models)) {
|
|
119
119
|
if (!MODEL_ROLES.includes(key)) {
|
|
120
120
|
throw new Error(`${CONFIG_FILE} holds an unknown model role: ${key}`);
|
|
@@ -127,7 +127,7 @@ function parseConfig(value) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
if (file.labels !== undefined) {
|
|
130
|
-
const labels = record(file.labels,
|
|
130
|
+
const labels = record(file.labels, 'labels');
|
|
131
131
|
for (const key of Object.keys(labels)) {
|
|
132
132
|
if (!LABEL_NAMES.includes(key)) {
|
|
133
133
|
throw new Error(`${CONFIG_FILE} holds an unknown label name: ${key}`);
|
|
@@ -135,27 +135,27 @@ function parseConfig(value) {
|
|
|
135
135
|
}
|
|
136
136
|
for (const name of LABEL_NAMES) {
|
|
137
137
|
if (labels[name] !== undefined) {
|
|
138
|
-
config.labels[name] = text(labels[name], `labels.${name}`, name ===
|
|
138
|
+
config.labels[name] = text(labels[name], `labels.${name}`, name === 'ready');
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
return config;
|
|
143
143
|
}
|
|
144
144
|
function record(value, what) {
|
|
145
|
-
if (typeof value !==
|
|
145
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
146
146
|
throw new Error(`${CONFIG_FILE}: ${what} must be an object.`);
|
|
147
147
|
}
|
|
148
148
|
return value;
|
|
149
149
|
}
|
|
150
150
|
function whole(value, field, least) {
|
|
151
|
-
if (typeof value !==
|
|
151
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < least) {
|
|
152
152
|
throw new Error(`${CONFIG_FILE}: ${field} must be a whole number of ${least} or more.`);
|
|
153
153
|
}
|
|
154
154
|
return value;
|
|
155
155
|
}
|
|
156
156
|
function text(value, field, allowEmpty) {
|
|
157
|
-
if (typeof value !==
|
|
158
|
-
throw new Error(`${CONFIG_FILE}: ${field} must be ${allowEmpty ?
|
|
157
|
+
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
|
|
158
|
+
throw new Error(`${CONFIG_FILE}: ${field} must be ${allowEmpty ? 'text' : 'text that is not empty'}.`);
|
|
159
159
|
}
|
|
160
160
|
return value;
|
|
161
161
|
}
|