next-issue 0.8.0 → 0.9.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 +38 -21
- package/dist/cli.mjs +18 -25
- package/dist/config.mjs +2 -0
- package/dist/exec.mjs +21 -7
- package/dist/git.mjs +22 -10
- package/dist/github.mjs +64 -16
- package/dist/loop.mjs +24 -0
- package/dist/observe.mjs +30 -1
- package/dist/pipeline.mjs +31 -13
- package/dist/prompts.mjs +2 -2
- package/dist/state.mjs +5 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -21,25 +21,31 @@ For each open issue, oldest first:
|
|
|
21
21
|
1. Claim the issue only when it holds the `status:todo` label, holds no
|
|
22
22
|
`status:in-progress` or `status:in-review` label, and has no assignee. A
|
|
23
23
|
`status:done`, `status:needs-human` or `status:blocked` label always stops
|
|
24
|
-
the claim. An issue with saved state under `.next-issue/`
|
|
25
|
-
whatever its labels, unless another person is now the
|
|
24
|
+
the claim, a resume too. An issue with saved state under `.next-issue/`
|
|
25
|
+
resumes instead, whatever its other labels, unless another person is now the
|
|
26
|
+
assignee.
|
|
26
27
|
2. Assign the issue to you and add `status:in-progress`.
|
|
27
|
-
3. Fetch the
|
|
28
|
-
safe, then add a git worktree
|
|
28
|
+
3. Fetch the remote, fast-forward the local copy of the default branch when
|
|
29
|
+
that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
|
|
30
|
+
`issue-<n>` branch. A branch that exists on the remote is the start, and a
|
|
31
|
+
local branch that fell behind the remote is fast-forwarded.
|
|
29
32
|
4. Let the implementer agent do the work, then commit and push.
|
|
30
33
|
5. Open a draft pull request that closes the issue.
|
|
31
|
-
6. Wait for the checks
|
|
32
|
-
`checkTimeoutMinutes`. A
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
6. Wait for the checks, once the pull request shows the pushed commit. A
|
|
35
|
+
pending state waits again, up to `checkTimeoutMinutes`. A read that fails
|
|
36
|
+
is tried three times before the run stops. A repository that reports no
|
|
37
|
+
check keeps asking for `checkGraceSeconds`, because a new pull request often
|
|
38
|
+
shows no check yet, and only then goes straight to the review. The later
|
|
39
|
+
gates of the same issue ask once.
|
|
35
40
|
7. On a red build, give the failed job logs to the fixer agent, then go to step
|
|
36
41
|
6 again. The budget is `maxCiFixes`.
|
|
37
42
|
8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
|
|
38
43
|
reviewer returns a structured verdict that rates each finding `blocking` or
|
|
39
44
|
`minor`. The harness puts the result on the pull request.
|
|
40
45
|
9. No blocking finding means approval: mark the pull request ready for review
|
|
41
|
-
and set `status:done`. The merge stays with you.
|
|
42
|
-
|
|
46
|
+
and set `status:done`. The merge stays with you. The worktree, the local
|
|
47
|
+
branch and the state file go away. The remote branch stays. A blocking
|
|
48
|
+
finding always stops the approval, even when the reviewer also says approve.
|
|
43
49
|
10. With a blocking finding, run the fixer agent and go to step 6 again. The
|
|
44
50
|
budget is `maxReviewRounds`.
|
|
45
51
|
|
|
@@ -106,18 +112,21 @@ Every run has an id, `<timestamp>`, and writes two files under
|
|
|
106
112
|
one report per issue with the outcome, the reason, the pull request number
|
|
107
113
|
and the budgets used.
|
|
108
114
|
|
|
115
|
+
At the start of a run, the harness deletes the files under `.next-issue/runs/`
|
|
116
|
+
that are older than `keepRunDays`. Set the field to `0` to keep them all.
|
|
117
|
+
|
|
109
118
|
A short text summary of the same data goes to standard output. Use `--json` to
|
|
110
119
|
get the full summary object there instead, so the harness fits in a pipe. All
|
|
111
120
|
progress goes to standard error.
|
|
112
121
|
|
|
113
|
-
Recorded for each step: `claim`, `comments`, `worktree`, `
|
|
114
|
-
`pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
115
|
-
`.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
116
|
-
with its exit code and duration. A `tool` event holds the tool
|
|
117
|
-
`Bash` tool the command too. Each agent call adds a `usage`
|
|
118
|
-
tokens, the cost estimate in dollars, the turn count, the
|
|
119
|
-
model and the session id, so you can open the session
|
|
120
|
-
transcript.
|
|
122
|
+
Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
|
|
123
|
+
`push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
124
|
+
`.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
125
|
+
is recorded with its exit code and duration. A `tool` event holds the tool
|
|
126
|
+
name, and for a `Bash` tool the command too. Each agent call adds a `usage`
|
|
127
|
+
event with the tokens, the cost estimate in dollars, the turn count, the
|
|
128
|
+
tool-call count, the model and the session id, so you can open the session
|
|
129
|
+
again and read the full transcript.
|
|
121
130
|
|
|
122
131
|
```bash
|
|
123
132
|
jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
|
|
@@ -134,7 +143,7 @@ Three levels of console output:
|
|
|
134
143
|
|
|
135
144
|
## Requirements
|
|
136
145
|
|
|
137
|
-
- Node 24 or later
|
|
146
|
+
- Node 24 or later
|
|
138
147
|
- `gh`, authenticated with write access to the repository
|
|
139
148
|
- Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
|
|
140
149
|
Claude subscription login
|
|
@@ -170,6 +179,7 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
170
179
|
"commandTimeoutMinutes": 10,
|
|
171
180
|
"logMaxChars": 20000,
|
|
172
181
|
"diffMaxChars": 60000,
|
|
182
|
+
"keepRunDays": 7,
|
|
173
183
|
"draftPullRequest": true,
|
|
174
184
|
"models": {},
|
|
175
185
|
"labels": {
|
|
@@ -195,8 +205,9 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
195
205
|
| `agentTimeoutMinutes` | `30` | The limit for one agent call |
|
|
196
206
|
| `setupTimeoutMinutes` | `15` | The limit for the setup command |
|
|
197
207
|
| `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
|
|
198
|
-
| `logMaxChars` | `20000` | The maximum length of the failed job logs |
|
|
208
|
+
| `logMaxChars` | `20000` | The maximum length of the failed job logs, shared between the failed jobs |
|
|
199
209
|
| `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
|
|
210
|
+
| `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
|
|
200
211
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
201
212
|
| `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
|
|
202
213
|
| `models` | `{}` | The model per agent role |
|
|
@@ -221,7 +232,10 @@ A hand-over leaves the state file with the used budgets in it, so the next run
|
|
|
221
232
|
would hand the issue over again at once. To give the issue another try, take the
|
|
222
233
|
`status:needs-human` label off and run with `--reset`. The flag puts the budgets
|
|
223
234
|
and the findings back to zero and keeps the branch, the pull request and the
|
|
224
|
-
step that the last run reached, so the work carries on where it stopped.
|
|
235
|
+
step that the last run reached, so the work carries on where it stopped. A
|
|
236
|
+
commit that you pushed to the pull request in the meantime is fetched first.
|
|
237
|
+
The flag touches only an issue that the run handles, so the state of an issue
|
|
238
|
+
that a label still holds back stays as it was.
|
|
225
239
|
|
|
226
240
|
## Commands and options
|
|
227
241
|
|
|
@@ -237,6 +251,9 @@ step that the last run reached, so the work carries on where it stopped.
|
|
|
237
251
|
| `--quiet` | Show only the milestones and the summary |
|
|
238
252
|
| `--help` | Show the option list |
|
|
239
253
|
|
|
254
|
+
The exit code is 1 when an issue needs a person or failed, and 0 when it does
|
|
255
|
+
not, so a cron job or a CI step can read the result of the run.
|
|
256
|
+
|
|
240
257
|
## Development
|
|
241
258
|
|
|
242
259
|
```bash
|
package/dist/cli.mjs
CHANGED
|
@@ -6,9 +6,10 @@ import { loadConfig, writeDefaultConfig } from "./config.mjs";
|
|
|
6
6
|
import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
|
|
8
8
|
import { currentLogin, defaultBranch, openIssues } from "./github.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
|
+
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
10
11
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
11
|
-
import {
|
|
12
|
+
import { STATE_DIR, takeStop } from "./state.mjs";
|
|
12
13
|
import { formatSummary } from "./summary.mjs";
|
|
13
14
|
function write(stream, text) {
|
|
14
15
|
return new Promise((resolve, reject) => {
|
|
@@ -29,6 +30,10 @@ async function main() {
|
|
|
29
30
|
setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
|
|
30
31
|
const repo = await detectRepo(root, config.remote);
|
|
31
32
|
const recorder = await Recorder.create(repo.root, args.level);
|
|
33
|
+
const pruned = await pruneRuns(repo.root, config.keepRunDays);
|
|
34
|
+
if (pruned > 0) {
|
|
35
|
+
recorder.event("runs.pruned", { files: pruned, keepDays: config.keepRunDays }, "verbose");
|
|
36
|
+
}
|
|
32
37
|
setCommandObserver((record) => {
|
|
33
38
|
recorder.event(record.code === 0 ? "command" : "command.fail", {
|
|
34
39
|
cmd: `${record.command} ${record.args.join(" ")}`,
|
|
@@ -55,42 +60,30 @@ async function init(root, force) {
|
|
|
55
60
|
}
|
|
56
61
|
async function handle(args, config, repo, recorder) {
|
|
57
62
|
const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
|
|
58
|
-
const context = { repo, config, base, login, recorder, ports: PORTS };
|
|
63
|
+
const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
|
|
59
64
|
recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
|
|
60
65
|
const issues = await openIssues(repo, config.issueLimit);
|
|
61
66
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
62
|
-
await
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
if (args.reset) {
|
|
70
|
-
await resetState(repo, issue.number);
|
|
71
|
-
}
|
|
72
|
-
const report = await processIssue(context, issue);
|
|
73
|
-
reports.push(report);
|
|
74
|
-
if (await takeStop(repo)) {
|
|
75
|
-
recorder.event("run.stop", { issue: issue.number }, "quiet");
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
if (args.once && report.outcome !== "skipped") {
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
67
|
+
const { reports, stoppedAt } = await runIssues(args, {
|
|
68
|
+
issues,
|
|
69
|
+
process: (issue) => processIssue(context, issue),
|
|
70
|
+
stop: () => takeStop(repo),
|
|
71
|
+
});
|
|
72
|
+
if (stoppedAt !== undefined) {
|
|
73
|
+
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
81
74
|
}
|
|
82
|
-
const summary = { ...recorder.summary(), issues: reports };
|
|
83
75
|
recorder.event("run.end", {
|
|
84
76
|
done: count(reports, "done"),
|
|
85
77
|
needsHuman: count(reports, "needs-human"),
|
|
86
78
|
failed: count(reports, "error"),
|
|
87
79
|
skipped: count(reports, "skipped"),
|
|
88
|
-
tokens: summary.total.total,
|
|
80
|
+
tokens: recorder.summary().total.total,
|
|
89
81
|
}, "quiet");
|
|
82
|
+
const summary = { ...recorder.summary(), issues: reports };
|
|
90
83
|
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
91
84
|
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
92
85
|
await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
|
93
|
-
return
|
|
86
|
+
return exitCode(reports);
|
|
94
87
|
}
|
|
95
88
|
function count(reports, outcome) {
|
|
96
89
|
return reports.filter((report) => report.outcome === outcome).length;
|
package/dist/config.mjs
CHANGED
|
@@ -14,6 +14,7 @@ const DEFAULTS = {
|
|
|
14
14
|
commandTimeoutMinutes: 10,
|
|
15
15
|
logMaxChars: 20000,
|
|
16
16
|
diffMaxChars: 60000,
|
|
17
|
+
keepRunDays: 7,
|
|
17
18
|
draftPullRequest: true,
|
|
18
19
|
models: {},
|
|
19
20
|
labels: {
|
|
@@ -38,6 +39,7 @@ const WHOLE_FIELDS = [
|
|
|
38
39
|
["commandTimeoutMinutes", 1],
|
|
39
40
|
["logMaxChars", 1],
|
|
40
41
|
["diffMaxChars", 1],
|
|
42
|
+
["keepRunDays", 0],
|
|
41
43
|
];
|
|
42
44
|
const MODEL_ROLES = ["implementer", "reviewer", "fixer"];
|
|
43
45
|
const LABEL_NAMES = ["ready", "inProgress", "inReview", "done", "needsHuman", "skip"];
|
package/dist/exec.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
let observer;
|
|
3
3
|
let defaultTimeoutMs = 10 * 60_000;
|
|
4
|
+
const STDERR_TAIL = 200_000;
|
|
4
5
|
export function setCommandObserver(next) {
|
|
5
6
|
observer = next;
|
|
6
7
|
}
|
|
@@ -15,7 +16,7 @@ export function run(command, args, options = {}) {
|
|
|
15
16
|
cwd: options.cwd,
|
|
16
17
|
stdio: [
|
|
17
18
|
options.input === undefined ? "ignore" : "pipe",
|
|
18
|
-
options.
|
|
19
|
+
options.show === true ? 2 : "pipe",
|
|
19
20
|
"pipe",
|
|
20
21
|
],
|
|
21
22
|
});
|
|
@@ -30,20 +31,19 @@ export function run(command, args, options = {}) {
|
|
|
30
31
|
stdout = stdout.slice(-options.tailChars);
|
|
31
32
|
}
|
|
32
33
|
});
|
|
34
|
+
const stderrLimit = options.tailChars ?? STDERR_TAIL;
|
|
33
35
|
child.stderr?.on("data", (chunk) => {
|
|
34
36
|
stderr += chunk;
|
|
37
|
+
if (stderr.length > stderrLimit) {
|
|
38
|
+
stderr = stderr.slice(-stderrLimit);
|
|
39
|
+
}
|
|
35
40
|
});
|
|
36
41
|
const timer = setTimeout(() => {
|
|
37
42
|
timedOut = true;
|
|
38
43
|
child.kill("SIGKILL");
|
|
39
44
|
}, limit);
|
|
40
45
|
let done = false;
|
|
41
|
-
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
done = true;
|
|
44
|
-
reject(error);
|
|
45
|
-
});
|
|
46
|
-
child.on("close", (code) => {
|
|
46
|
+
const settle = (code) => {
|
|
47
47
|
clearTimeout(timer);
|
|
48
48
|
if (done) {
|
|
49
49
|
return;
|
|
@@ -60,7 +60,21 @@ export function run(command, args, options = {}) {
|
|
|
60
60
|
timedOut,
|
|
61
61
|
});
|
|
62
62
|
resolve(result);
|
|
63
|
+
};
|
|
64
|
+
child.on("error", (error) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
done = true;
|
|
67
|
+
reject(error);
|
|
68
|
+
});
|
|
69
|
+
child.on("exit", (code) => {
|
|
70
|
+
if (!timedOut) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
child.stdout?.destroy();
|
|
74
|
+
child.stderr?.destroy();
|
|
75
|
+
settle(code);
|
|
63
76
|
});
|
|
77
|
+
child.on("close", settle);
|
|
64
78
|
if (options.input !== undefined) {
|
|
65
79
|
child.stdin?.on("error", () => undefined);
|
|
66
80
|
child.stdin?.end(options.input);
|
package/dist/git.mjs
CHANGED
|
@@ -30,21 +30,26 @@ export function worktreePath(repo, issue) {
|
|
|
30
30
|
export async function addWorktree(repo, issue, base, remote) {
|
|
31
31
|
const path = worktreePath(repo, issue);
|
|
32
32
|
const branch = branchName(issue);
|
|
33
|
-
|
|
33
|
+
const upstream = `${remote}/${branch}`;
|
|
34
|
+
await must("git", ["fetch", remote], { cwd: repo.root });
|
|
34
35
|
await updateBase(repo, base, remote);
|
|
35
36
|
await must("git", ["worktree", "prune"], { cwd: repo.root });
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const pushed = await hasBranch(repo, upstream, "--remotes");
|
|
38
|
+
if (!(await hasWorktree(repo, path))) {
|
|
39
|
+
const args = (await hasBranch(repo, branch))
|
|
40
|
+
? ["worktree", "add", path, branch]
|
|
41
|
+
: ["worktree", "add", "-b", branch, path, pushed ? upstream : `${remote}/${base}`];
|
|
42
|
+
await must("git", args, { cwd: repo.root });
|
|
43
|
+
}
|
|
44
|
+
if (pushed) {
|
|
45
|
+
await run("git", ["merge", "--ff-only", upstream], { cwd: path });
|
|
38
46
|
}
|
|
39
|
-
const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
40
|
-
cwd: repo.root,
|
|
41
|
-
});
|
|
42
|
-
const args = existing.code === 0
|
|
43
|
-
? ["worktree", "add", path, branch]
|
|
44
|
-
: ["worktree", "add", "-b", branch, path, `${remote}/${base}`];
|
|
45
|
-
await must("git", args, { cwd: repo.root });
|
|
46
47
|
return path;
|
|
47
48
|
}
|
|
49
|
+
async function hasBranch(repo, name, ...flags) {
|
|
50
|
+
const list = await must("git", ["branch", "--list", ...flags, name], { cwd: repo.root });
|
|
51
|
+
return list.length > 0;
|
|
52
|
+
}
|
|
48
53
|
export async function updateBase(repo, base, remote) {
|
|
49
54
|
const head = await run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repo.root });
|
|
50
55
|
const args = head.code === 0 && head.stdout.trim() === base
|
|
@@ -98,3 +103,10 @@ export async function commitAll(cwd, message) {
|
|
|
98
103
|
export async function push(cwd, remote, branch) {
|
|
99
104
|
await must("git", ["push", "-u", remote, branch], { cwd });
|
|
100
105
|
}
|
|
106
|
+
export async function revision(cwd) {
|
|
107
|
+
return must("git", ["rev-parse", "HEAD"], { cwd });
|
|
108
|
+
}
|
|
109
|
+
export async function deleteBranch(repo, branch) {
|
|
110
|
+
const result = await run("git", ["branch", "-D", branch], { cwd: repo.root });
|
|
111
|
+
return result.code === 0;
|
|
112
|
+
}
|
package/dist/github.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { must, run } from "./exec.mjs";
|
|
2
2
|
const CHECK_PENDING_CODE = 8;
|
|
3
|
+
const READ_TRIES = 3;
|
|
4
|
+
const KNOWN_LABELS = new Map();
|
|
3
5
|
const NO_CHECKS = /no checks reported/i;
|
|
4
6
|
function slug(repo) {
|
|
5
7
|
return `${repo.owner}/${repo.name}`;
|
|
@@ -22,6 +24,8 @@ export async function openIssues(repo, limit) {
|
|
|
22
24
|
"list",
|
|
23
25
|
"--state",
|
|
24
26
|
"open",
|
|
27
|
+
"--search",
|
|
28
|
+
"sort:created-asc",
|
|
25
29
|
"--limit",
|
|
26
30
|
String(limit),
|
|
27
31
|
"--json",
|
|
@@ -65,7 +69,21 @@ export async function setLabel(repo, kind, number, add, remove) {
|
|
|
65
69
|
await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
|
|
66
70
|
}
|
|
67
71
|
export async function ensureLabel(repo, label) {
|
|
68
|
-
|
|
72
|
+
let known = KNOWN_LABELS.get(slug(repo));
|
|
73
|
+
if (known === undefined) {
|
|
74
|
+
const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
|
|
75
|
+
known = new Set(JSON.parse(raw).map((item) => item.name));
|
|
76
|
+
KNOWN_LABELS.set(slug(repo), known);
|
|
77
|
+
}
|
|
78
|
+
if (known.has(label)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const result = await run("gh", ["label", "create", label, "--repo", slug(repo)], {
|
|
82
|
+
cwd: repo.root,
|
|
83
|
+
});
|
|
84
|
+
if (result.code === 0) {
|
|
85
|
+
known.add(label);
|
|
86
|
+
}
|
|
69
87
|
}
|
|
70
88
|
export async function findPr(repo, branch) {
|
|
71
89
|
const result = await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--repo", slug(repo)], { cwd: repo.root });
|
|
@@ -76,7 +94,7 @@ export async function findPr(repo, branch) {
|
|
|
76
94
|
return parsed[0]?.number;
|
|
77
95
|
}
|
|
78
96
|
export async function createPr(repo, branch, base, title, body, draft) {
|
|
79
|
-
await gh(repo, [
|
|
97
|
+
const created = await gh(repo, [
|
|
80
98
|
"pr",
|
|
81
99
|
"create",
|
|
82
100
|
"--head",
|
|
@@ -89,12 +107,19 @@ export async function createPr(repo, branch, base, title, body, draft) {
|
|
|
89
107
|
body,
|
|
90
108
|
...(draft ? ["--draft"] : []),
|
|
91
109
|
]);
|
|
92
|
-
const number = await findPr(repo, branch);
|
|
110
|
+
const number = prNumber(created) ?? (await findPr(repo, branch));
|
|
93
111
|
if (number === undefined) {
|
|
94
112
|
throw new Error(`The pull request for ${branch} was not found after creation.`);
|
|
95
113
|
}
|
|
96
114
|
return number;
|
|
97
115
|
}
|
|
116
|
+
export function prNumber(output) {
|
|
117
|
+
const match = /\/pull\/(\d+)\b/.exec(output);
|
|
118
|
+
return match === null ? undefined : Number(match[1]);
|
|
119
|
+
}
|
|
120
|
+
async function prHead(repo, branch) {
|
|
121
|
+
return gh(repo, ["pr", "view", branch, "--json", "headRefOid", "--jq", ".headRefOid"]);
|
|
122
|
+
}
|
|
98
123
|
async function readChecks(repo, branch) {
|
|
99
124
|
const result = await run("gh", ["pr", "checks", branch, "--json", "name,bucket,link,description", "--repo", slug(repo)], { cwd: repo.root });
|
|
100
125
|
const output = result.stdout.trim();
|
|
@@ -107,8 +132,8 @@ async function readChecks(repo, branch) {
|
|
|
107
132
|
const checks = JSON.parse(output);
|
|
108
133
|
return checks.length === 0 ? "none" : checks;
|
|
109
134
|
}
|
|
110
|
-
async function watchOnce(repo, branch, intervalSeconds, timeoutMs,
|
|
111
|
-
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root,
|
|
135
|
+
async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
|
|
136
|
+
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, show, timeoutMs });
|
|
112
137
|
if (watch.timedOut) {
|
|
113
138
|
return "timeout";
|
|
114
139
|
}
|
|
@@ -125,31 +150,53 @@ const REAL_CLOCK = {
|
|
|
125
150
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
126
151
|
};
|
|
127
152
|
export async function pollChecks(probe, options, clock = REAL_CLOCK) {
|
|
128
|
-
const
|
|
129
|
-
const deadline = started + options.timeoutMs;
|
|
153
|
+
const deadline = clock.now() + options.timeoutMs;
|
|
130
154
|
const rest = () => Math.max(0, deadline - clock.now());
|
|
155
|
+
let shown;
|
|
156
|
+
let failures = 0;
|
|
131
157
|
for (;;) {
|
|
132
158
|
if (rest() === 0) {
|
|
133
159
|
return "timeout";
|
|
134
160
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
const state = await read(probe, options.head, rest(), () => (shown ??= clock.now()));
|
|
162
|
+
if (state instanceof Error) {
|
|
163
|
+
failures += 1;
|
|
164
|
+
if (failures >= READ_TRIES) {
|
|
165
|
+
throw state;
|
|
138
166
|
}
|
|
139
167
|
}
|
|
140
168
|
else {
|
|
141
|
-
|
|
142
|
-
if (state
|
|
169
|
+
failures = 0;
|
|
170
|
+
if (state === "none" && clock.now() - (shown ?? 0) >= options.graceMs) {
|
|
171
|
+
return "none";
|
|
172
|
+
}
|
|
173
|
+
if (state !== "none" && state !== "behind" && state !== "pending") {
|
|
143
174
|
return state;
|
|
144
175
|
}
|
|
145
176
|
}
|
|
146
177
|
await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
|
|
147
178
|
}
|
|
148
179
|
}
|
|
180
|
+
async function read(probe, head, timeoutMs, shown) {
|
|
181
|
+
try {
|
|
182
|
+
if (head !== undefined && (await probe.head()) !== head) {
|
|
183
|
+
return "behind";
|
|
184
|
+
}
|
|
185
|
+
shown();
|
|
186
|
+
if ((await probe.list()) === "none") {
|
|
187
|
+
return "none";
|
|
188
|
+
}
|
|
189
|
+
return await probe.watch(timeoutMs);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
149
195
|
export function waitForChecks(repo, branch, options) {
|
|
150
196
|
return pollChecks({
|
|
197
|
+
head: () => prHead(repo, branch),
|
|
151
198
|
list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
|
|
152
|
-
watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.
|
|
199
|
+
watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.show),
|
|
153
200
|
}, options);
|
|
154
201
|
}
|
|
155
202
|
export async function failedCheckLogs(repo, branch, maxChars) {
|
|
@@ -158,6 +205,7 @@ export async function failedCheckLogs(repo, branch, maxChars) {
|
|
|
158
205
|
return "The checks failed, but gh reports no check for the branch.";
|
|
159
206
|
}
|
|
160
207
|
const failed = checks.filter((check) => check.bucket === "fail");
|
|
208
|
+
const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
|
|
161
209
|
const parts = [];
|
|
162
210
|
for (const check of failed) {
|
|
163
211
|
parts.push(`## ${check.name}\n${check.description}`);
|
|
@@ -167,11 +215,11 @@ export async function failedCheckLogs(repo, branch, maxChars) {
|
|
|
167
215
|
}
|
|
168
216
|
const logs = await run("gh", ["run", "view", runId, "--log-failed", "--repo", slug(repo)], {
|
|
169
217
|
cwd: repo.root,
|
|
170
|
-
tailChars:
|
|
218
|
+
tailChars: share,
|
|
171
219
|
});
|
|
172
|
-
parts.push(logs.stdout.slice(-
|
|
220
|
+
parts.push(logs.stdout.slice(-share));
|
|
173
221
|
}
|
|
174
|
-
return parts.join("\n\n")
|
|
222
|
+
return parts.join("\n\n");
|
|
175
223
|
}
|
|
176
224
|
export async function markPrReady(repo, pr) {
|
|
177
225
|
const result = await run("gh", ["pr", "ready", String(pr), "--repo", slug(repo)], {
|
package/dist/loop.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function runIssues(args, loop) {
|
|
2
|
+
await loop.stop();
|
|
3
|
+
const reports = [];
|
|
4
|
+
for (const issue of loop.issues) {
|
|
5
|
+
if (args.max !== undefined && handled(reports) >= args.max) {
|
|
6
|
+
break;
|
|
7
|
+
}
|
|
8
|
+
const report = await loop.process(issue);
|
|
9
|
+
reports.push(report);
|
|
10
|
+
if (await loop.stop()) {
|
|
11
|
+
return { reports, stoppedAt: issue.number };
|
|
12
|
+
}
|
|
13
|
+
if (args.once && report.outcome !== "skipped") {
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { reports };
|
|
18
|
+
}
|
|
19
|
+
export function exitCode(reports) {
|
|
20
|
+
return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
|
|
21
|
+
}
|
|
22
|
+
function handled(reports) {
|
|
23
|
+
return reports.filter((report) => report.outcome !== "skipped").length;
|
|
24
|
+
}
|
package/dist/observe.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createWriteStream } from "node:fs";
|
|
2
|
-
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { STATE_DIR } from "./state.mjs";
|
|
5
5
|
const VISIBILITY = { quiet: 0, normal: 1, verbose: 2 };
|
|
@@ -117,6 +117,35 @@ export class Recorder {
|
|
|
117
117
|
await this.sink.close();
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
export async function pruneRuns(root, keepDays) {
|
|
121
|
+
if (keepDays <= 0) {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const dir = join(root, STATE_DIR, "runs");
|
|
125
|
+
let names;
|
|
126
|
+
try {
|
|
127
|
+
names = await readdir(dir);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
const cutoff = Date.now() - keepDays * 24 * 60 * 60_000;
|
|
133
|
+
let dropped = 0;
|
|
134
|
+
for (const name of names) {
|
|
135
|
+
const path = join(dir, name);
|
|
136
|
+
try {
|
|
137
|
+
const info = await stat(path);
|
|
138
|
+
if (info.isFile() && info.mtimeMs < cutoff) {
|
|
139
|
+
await rm(path, { force: true });
|
|
140
|
+
dropped += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return dropped;
|
|
148
|
+
}
|
|
120
149
|
export function message(error) {
|
|
121
150
|
return error instanceof Error ? error.message : String(error);
|
|
122
151
|
}
|
package/dist/pipeline.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { CODING, READ_ONLY, runAgent } from "./agents.mjs";
|
|
2
2
|
import { managedLabels } from "./config.mjs";
|
|
3
3
|
import { run } from "./exec.mjs";
|
|
4
|
-
import { addWorktree, branchName, commitAll, diff, hasWorktree, push, removeWorktree, worktreePath, } from "./git.mjs";
|
|
4
|
+
import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
|
|
5
5
|
import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
|
|
6
6
|
import { message } from "./observe.mjs";
|
|
7
7
|
import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
|
|
8
|
-
import { readState, writeState } from "./state.mjs";
|
|
8
|
+
import { dropState, readState, resetState, writeState, } from "./state.mjs";
|
|
9
9
|
import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
|
|
10
10
|
export const PORTS = {
|
|
11
11
|
addWorktree,
|
|
@@ -13,6 +13,7 @@ export const PORTS = {
|
|
|
13
13
|
commentOnPr,
|
|
14
14
|
commitAll,
|
|
15
15
|
createPr,
|
|
16
|
+
deleteBranch,
|
|
16
17
|
diff,
|
|
17
18
|
failedCheckLogs,
|
|
18
19
|
findPr,
|
|
@@ -21,6 +22,7 @@ export const PORTS = {
|
|
|
21
22
|
markPrReady,
|
|
22
23
|
push,
|
|
23
24
|
removeWorktree,
|
|
25
|
+
revision,
|
|
24
26
|
runAgent,
|
|
25
27
|
runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
|
|
26
28
|
setLabel,
|
|
@@ -53,7 +55,7 @@ export async function processIssue(context, issue) {
|
|
|
53
55
|
return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
54
56
|
}
|
|
55
57
|
log.event("issue.start", { title: issue.title, createdAt: issue.createdAt }, "quiet");
|
|
56
|
-
const
|
|
58
|
+
const fresh = {
|
|
57
59
|
issue: issue.number,
|
|
58
60
|
phase: "claimed",
|
|
59
61
|
branch: branchName(issue.number),
|
|
@@ -61,6 +63,7 @@ export async function processIssue(context, issue) {
|
|
|
61
63
|
reviewRounds: 0,
|
|
62
64
|
reviewLog: [],
|
|
63
65
|
};
|
|
66
|
+
const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
|
|
64
67
|
try {
|
|
65
68
|
const report = await work(context, issue, log, state);
|
|
66
69
|
log.event("issue.end", { outcome: report.outcome, reason: report.reason, ms: report.ms }, "quiet");
|
|
@@ -101,6 +104,12 @@ async function work(context, issue, log, state) {
|
|
|
101
104
|
const escalate = (reason) => handOver(context, log, issue.number, state, reason);
|
|
102
105
|
const finish = async (outcome, reason) => {
|
|
103
106
|
await cleanUp(context, log, issue.number);
|
|
107
|
+
if (outcome === "done") {
|
|
108
|
+
if (!(await ports.deleteBranch(repo, branch))) {
|
|
109
|
+
log.event("branch.kept", { branch }, "quiet");
|
|
110
|
+
}
|
|
111
|
+
await dropState(repo, issue.number);
|
|
112
|
+
}
|
|
104
113
|
return {
|
|
105
114
|
issue: issue.number,
|
|
106
115
|
outcome,
|
|
@@ -123,6 +132,7 @@ async function work(context, issue, log, state) {
|
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
134
|
if (state.phase === "claimed") {
|
|
135
|
+
const before = await ports.revision(worktree);
|
|
126
136
|
const result = await log.step("implement", {}, () => ports.runAgent(log, {
|
|
127
137
|
name: "implementer",
|
|
128
138
|
cwd: worktree,
|
|
@@ -132,11 +142,10 @@ async function work(context, issue, log, state) {
|
|
|
132
142
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
133
143
|
}));
|
|
134
144
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
135
|
-
if (!(await
|
|
145
|
+
if (!(await commitWork(job, before, subject))) {
|
|
136
146
|
await escalate("The implementer produced no change.");
|
|
137
147
|
return finish("needs-human", "no implementation commit");
|
|
138
148
|
}
|
|
139
|
-
log.event("commit", { subject });
|
|
140
149
|
state.phase = "implemented";
|
|
141
150
|
await writeState(repo, state);
|
|
142
151
|
}
|
|
@@ -149,14 +158,18 @@ async function work(context, issue, log, state) {
|
|
|
149
158
|
state.phase = "review";
|
|
150
159
|
await writeState(repo, state);
|
|
151
160
|
log.event("pull-request.ready", { pr: state.pr }, "quiet");
|
|
161
|
+
let noChecks = false;
|
|
152
162
|
const gate = async () => {
|
|
153
|
-
const
|
|
163
|
+
const head = await ports.revision(worktree);
|
|
164
|
+
const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
|
|
165
|
+
head,
|
|
154
166
|
intervalSeconds: config.checkIntervalSeconds,
|
|
155
|
-
graceMs: config.checkGraceSeconds * 1000,
|
|
167
|
+
graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
156
168
|
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
157
|
-
|
|
169
|
+
show: log.level === "verbose",
|
|
158
170
|
}));
|
|
159
171
|
log.event("checks.state", { state: checks });
|
|
172
|
+
noChecks ||= checks === "none";
|
|
160
173
|
if (checks === "timeout") {
|
|
161
174
|
await escalate("The checks did not finish in time.");
|
|
162
175
|
return { done: true, report: await finish("needs-human", "checks timeout") };
|
|
@@ -210,7 +223,7 @@ async function work(context, issue, log, state) {
|
|
|
210
223
|
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
211
224
|
name: "reviewer",
|
|
212
225
|
cwd: worktree,
|
|
213
|
-
prompt: reviewPrompt(issue, comments, patch,
|
|
226
|
+
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
214
227
|
profile: READ_ONLY,
|
|
215
228
|
model: config.models.reviewer,
|
|
216
229
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
@@ -242,8 +255,6 @@ async function work(context, issue, log, state) {
|
|
|
242
255
|
log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
|
|
243
256
|
}
|
|
244
257
|
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
245
|
-
state.phase = "done";
|
|
246
|
-
await writeState(repo, state);
|
|
247
258
|
return finish("done");
|
|
248
259
|
}
|
|
249
260
|
const mark = fingerprint(blocking);
|
|
@@ -264,6 +275,7 @@ async function work(context, issue, log, state) {
|
|
|
264
275
|
async function fixRound(job, reason, detail, earlier, kind) {
|
|
265
276
|
const { context, issue, log, worktree, branch } = job;
|
|
266
277
|
const { ports } = context;
|
|
278
|
+
const before = await ports.revision(worktree);
|
|
267
279
|
const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
|
|
268
280
|
name: "fixer",
|
|
269
281
|
cwd: worktree,
|
|
@@ -278,14 +290,20 @@ async function fixRound(job, reason, detail, earlier, kind) {
|
|
|
278
290
|
return { committed: false, unrelated };
|
|
279
291
|
}
|
|
280
292
|
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
281
|
-
if (!(await
|
|
293
|
+
if (!(await commitWork(job, before, subject))) {
|
|
282
294
|
log.event("commit.empty", { reason }, "quiet");
|
|
283
295
|
return { committed: false };
|
|
284
296
|
}
|
|
285
|
-
log.event("commit", { subject });
|
|
286
297
|
await ports.push(worktree, context.config.remote, branch);
|
|
287
298
|
return { committed: true };
|
|
288
299
|
}
|
|
300
|
+
async function commitWork(job, before, subject) {
|
|
301
|
+
const { context, issue, log, worktree } = job;
|
|
302
|
+
if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
303
|
+
log.event("commit", { subject });
|
|
304
|
+
}
|
|
305
|
+
return (await context.ports.revision(worktree)) !== before;
|
|
306
|
+
}
|
|
289
307
|
async function cleanUp(context, log, issue) {
|
|
290
308
|
const path = worktreePath(context.repo, issue);
|
|
291
309
|
try {
|
package/dist/prompts.mjs
CHANGED
|
@@ -26,8 +26,8 @@ export function capDiff(diff, maxChars) {
|
|
|
26
26
|
? diff
|
|
27
27
|
: `${diff.slice(0, maxChars)}\n... The diff is cut here, after ${maxChars} characters.`;
|
|
28
28
|
}
|
|
29
|
-
export function reviewPrompt(issue, comments, diff,
|
|
30
|
-
const scope =
|
|
29
|
+
export function reviewPrompt(issue, comments, diff, earlier) {
|
|
30
|
+
const scope = earlier.length === 0
|
|
31
31
|
? "Check correctness, scope, repository conventions and test cover."
|
|
32
32
|
: [
|
|
33
33
|
"This is a later round. Judge two things only:",
|
package/dist/state.mjs
CHANGED
|
@@ -15,15 +15,10 @@ export async function readState(repo, issue) {
|
|
|
15
15
|
return undefined;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
export
|
|
19
|
-
const state = await readState(repo, issue);
|
|
20
|
-
if (state === undefined) {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
18
|
+
export function resetState(state) {
|
|
23
19
|
const next = { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
|
|
24
20
|
delete next.handedOver;
|
|
25
|
-
|
|
26
|
-
return true;
|
|
21
|
+
return next;
|
|
27
22
|
}
|
|
28
23
|
export async function writeState(repo, state) {
|
|
29
24
|
await mkdir(dir(repo), { recursive: true });
|
|
@@ -38,3 +33,6 @@ export async function takeStop(repo) {
|
|
|
38
33
|
return false;
|
|
39
34
|
}
|
|
40
35
|
}
|
|
36
|
+
export async function dropState(repo, issue) {
|
|
37
|
+
await rm(file(repo, issue), { force: true });
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-issue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Autonomous issue harness driven by the Claude Agent SDK",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^26.
|
|
40
|
+
"@types/node": "^26.4.1",
|
|
41
41
|
"typescript": "^7.0.2"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
44
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.261"
|
|
45
45
|
}
|
|
46
46
|
}
|