next-issue 0.8.1 → 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 +14 -3
- package/dist/cli.mjs +9 -6
- package/dist/config.mjs +2 -0
- package/dist/exec.mjs +20 -6
- package/dist/github.mjs +17 -8
- package/dist/loop.mjs +0 -3
- package/dist/observe.mjs +30 -1
- package/dist/pipeline.mjs +3 -2
- package/dist/state.mjs +2 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -21,8 +21,9 @@ 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
28
|
3. Fetch the remote, fast-forward the local copy of the default branch when
|
|
28
29
|
that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
|
|
@@ -111,6 +112,9 @@ Every run has an id, `<timestamp>`, and writes two files under
|
|
|
111
112
|
one report per issue with the outcome, the reason, the pull request number
|
|
112
113
|
and the budgets used.
|
|
113
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
|
+
|
|
114
118
|
A short text summary of the same data goes to standard output. Use `--json` to
|
|
115
119
|
get the full summary object there instead, so the harness fits in a pipe. All
|
|
116
120
|
progress goes to standard error.
|
|
@@ -175,6 +179,7 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
175
179
|
"commandTimeoutMinutes": 10,
|
|
176
180
|
"logMaxChars": 20000,
|
|
177
181
|
"diffMaxChars": 60000,
|
|
182
|
+
"keepRunDays": 7,
|
|
178
183
|
"draftPullRequest": true,
|
|
179
184
|
"models": {},
|
|
180
185
|
"labels": {
|
|
@@ -200,8 +205,9 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
200
205
|
| `agentTimeoutMinutes` | `30` | The limit for one agent call |
|
|
201
206
|
| `setupTimeoutMinutes` | `15` | The limit for the setup command |
|
|
202
207
|
| `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
|
|
203
|
-
| `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 |
|
|
204
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 |
|
|
205
211
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
206
212
|
| `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
|
|
207
213
|
| `models` | `{}` | The model per agent role |
|
|
@@ -228,6 +234,8 @@ would hand the issue over again at once. To give the issue another try, take the
|
|
|
228
234
|
and the findings back to zero and keeps the branch, the pull request and the
|
|
229
235
|
step that the last run reached, so the work carries on where it stopped. A
|
|
230
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.
|
|
231
239
|
|
|
232
240
|
## Commands and options
|
|
233
241
|
|
|
@@ -243,6 +251,9 @@ commit that you pushed to the pull request in the meantime is fetched first.
|
|
|
243
251
|
| `--quiet` | Show only the milestones and the summary |
|
|
244
252
|
| `--help` | Show the option list |
|
|
245
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
|
+
|
|
246
257
|
## Development
|
|
247
258
|
|
|
248
259
|
```bash
|
package/dist/cli.mjs
CHANGED
|
@@ -7,9 +7,9 @@ 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
9
|
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
|
-
import { message, Recorder } from "./observe.mjs";
|
|
10
|
+
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
11
11
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import { STATE_DIR, takeStop } from "./state.mjs";
|
|
13
13
|
import { formatSummary } from "./summary.mjs";
|
|
14
14
|
function write(stream, text) {
|
|
15
15
|
return new Promise((resolve, reject) => {
|
|
@@ -30,6 +30,10 @@ async function main() {
|
|
|
30
30
|
setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
|
|
31
31
|
const repo = await detectRepo(root, config.remote);
|
|
32
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
|
+
}
|
|
33
37
|
setCommandObserver((record) => {
|
|
34
38
|
recorder.event(record.code === 0 ? "command" : "command.fail", {
|
|
35
39
|
cmd: `${record.command} ${record.args.join(" ")}`,
|
|
@@ -56,27 +60,26 @@ async function init(root, force) {
|
|
|
56
60
|
}
|
|
57
61
|
async function handle(args, config, repo, recorder) {
|
|
58
62
|
const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
|
|
59
|
-
const context = { repo, config, base, login, recorder, ports: PORTS };
|
|
63
|
+
const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
|
|
60
64
|
recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
|
|
61
65
|
const issues = await openIssues(repo, config.issueLimit);
|
|
62
66
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
63
67
|
const { reports, stoppedAt } = await runIssues(args, {
|
|
64
68
|
issues,
|
|
65
69
|
process: (issue) => processIssue(context, issue),
|
|
66
|
-
reset: (issue) => resetState(repo, issue),
|
|
67
70
|
stop: () => takeStop(repo),
|
|
68
71
|
});
|
|
69
72
|
if (stoppedAt !== undefined) {
|
|
70
73
|
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
71
74
|
}
|
|
72
|
-
const summary = { ...recorder.summary(), issues: reports };
|
|
73
75
|
recorder.event("run.end", {
|
|
74
76
|
done: count(reports, "done"),
|
|
75
77
|
needsHuman: count(reports, "needs-human"),
|
|
76
78
|
failed: count(reports, "error"),
|
|
77
79
|
skipped: count(reports, "skipped"),
|
|
78
|
-
tokens: summary.total.total,
|
|
80
|
+
tokens: recorder.summary().total.total,
|
|
79
81
|
}, "quiet");
|
|
82
|
+
const summary = { ...recorder.summary(), issues: reports };
|
|
80
83
|
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
81
84
|
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
82
85
|
await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
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
|
}
|
|
@@ -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/github.mjs
CHANGED
|
@@ -78,8 +78,12 @@ export async function ensureLabel(repo, label) {
|
|
|
78
78
|
if (known.has(label)) {
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
81
|
-
await run("gh", ["label", "create", label, "--repo", slug(repo)], {
|
|
82
|
-
|
|
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
|
+
}
|
|
83
87
|
}
|
|
84
88
|
export async function findPr(repo, branch) {
|
|
85
89
|
const result = await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--repo", slug(repo)], { cwd: repo.root });
|
|
@@ -90,7 +94,7 @@ export async function findPr(repo, branch) {
|
|
|
90
94
|
return parsed[0]?.number;
|
|
91
95
|
}
|
|
92
96
|
export async function createPr(repo, branch, base, title, body, draft) {
|
|
93
|
-
await gh(repo, [
|
|
97
|
+
const created = await gh(repo, [
|
|
94
98
|
"pr",
|
|
95
99
|
"create",
|
|
96
100
|
"--head",
|
|
@@ -103,12 +107,16 @@ export async function createPr(repo, branch, base, title, body, draft) {
|
|
|
103
107
|
body,
|
|
104
108
|
...(draft ? ["--draft"] : []),
|
|
105
109
|
]);
|
|
106
|
-
const number = await findPr(repo, branch);
|
|
110
|
+
const number = prNumber(created) ?? (await findPr(repo, branch));
|
|
107
111
|
if (number === undefined) {
|
|
108
112
|
throw new Error(`The pull request for ${branch} was not found after creation.`);
|
|
109
113
|
}
|
|
110
114
|
return number;
|
|
111
115
|
}
|
|
116
|
+
export function prNumber(output) {
|
|
117
|
+
const match = /\/pull\/(\d+)\b/.exec(output);
|
|
118
|
+
return match === null ? undefined : Number(match[1]);
|
|
119
|
+
}
|
|
112
120
|
async function prHead(repo, branch) {
|
|
113
121
|
return gh(repo, ["pr", "view", branch, "--json", "headRefOid", "--jq", ".headRefOid"]);
|
|
114
122
|
}
|
|
@@ -178,11 +186,11 @@ async function read(probe, head, timeoutMs, shown) {
|
|
|
178
186
|
if ((await probe.list()) === "none") {
|
|
179
187
|
return "none";
|
|
180
188
|
}
|
|
189
|
+
return await probe.watch(timeoutMs);
|
|
181
190
|
}
|
|
182
191
|
catch (error) {
|
|
183
192
|
return error instanceof Error ? error : new Error(String(error));
|
|
184
193
|
}
|
|
185
|
-
return probe.watch(timeoutMs);
|
|
186
194
|
}
|
|
187
195
|
export function waitForChecks(repo, branch, options) {
|
|
188
196
|
return pollChecks({
|
|
@@ -197,6 +205,7 @@ export async function failedCheckLogs(repo, branch, maxChars) {
|
|
|
197
205
|
return "The checks failed, but gh reports no check for the branch.";
|
|
198
206
|
}
|
|
199
207
|
const failed = checks.filter((check) => check.bucket === "fail");
|
|
208
|
+
const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
|
|
200
209
|
const parts = [];
|
|
201
210
|
for (const check of failed) {
|
|
202
211
|
parts.push(`## ${check.name}\n${check.description}`);
|
|
@@ -206,11 +215,11 @@ export async function failedCheckLogs(repo, branch, maxChars) {
|
|
|
206
215
|
}
|
|
207
216
|
const logs = await run("gh", ["run", "view", runId, "--log-failed", "--repo", slug(repo)], {
|
|
208
217
|
cwd: repo.root,
|
|
209
|
-
tailChars:
|
|
218
|
+
tailChars: share,
|
|
210
219
|
});
|
|
211
|
-
parts.push(logs.stdout.slice(-
|
|
220
|
+
parts.push(logs.stdout.slice(-share));
|
|
212
221
|
}
|
|
213
|
-
return parts.join("\n\n")
|
|
222
|
+
return parts.join("\n\n");
|
|
214
223
|
}
|
|
215
224
|
export async function markPrReady(repo, pr) {
|
|
216
225
|
const result = await run("gh", ["pr", "ready", String(pr), "--repo", slug(repo)], {
|
package/dist/loop.mjs
CHANGED
|
@@ -5,9 +5,6 @@ export async function runIssues(args, loop) {
|
|
|
5
5
|
if (args.max !== undefined && handled(reports) >= args.max) {
|
|
6
6
|
break;
|
|
7
7
|
}
|
|
8
|
-
if (args.reset) {
|
|
9
|
-
await loop.reset(issue.number);
|
|
10
|
-
}
|
|
11
8
|
const report = await loop.process(issue);
|
|
12
9
|
reports.push(report);
|
|
13
10
|
if (await loop.stop()) {
|
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
|
@@ -5,7 +5,7 @@ import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, pu
|
|
|
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 { dropState, 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,
|
|
@@ -55,7 +55,7 @@ export async function processIssue(context, issue) {
|
|
|
55
55
|
return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
56
56
|
}
|
|
57
57
|
log.event("issue.start", { title: issue.title, createdAt: issue.createdAt }, "quiet");
|
|
58
|
-
const
|
|
58
|
+
const fresh = {
|
|
59
59
|
issue: issue.number,
|
|
60
60
|
phase: "claimed",
|
|
61
61
|
branch: branchName(issue.number),
|
|
@@ -63,6 +63,7 @@ export async function processIssue(context, issue) {
|
|
|
63
63
|
reviewRounds: 0,
|
|
64
64
|
reviewLog: [],
|
|
65
65
|
};
|
|
66
|
+
const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
|
|
66
67
|
try {
|
|
67
68
|
const report = await work(context, issue, log, state);
|
|
68
69
|
log.event("issue.end", { outcome: report.outcome, reason: report.reason, ms: report.ms }, "quiet");
|
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 });
|
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
|
}
|