next-issue 0.8.1 → 0.10.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 +67 -22
- package/dist/agents.mjs +3 -3
- package/dist/cli.mjs +9 -6
- package/dist/config.mjs +2 -0
- package/dist/exec.mjs +20 -6
- package/dist/git.mjs +8 -0
- package/dist/github.mjs +17 -8
- package/dist/loop.mjs +0 -3
- package/dist/observe.mjs +30 -1
- package/dist/pipeline.mjs +23 -13
- package/dist/prompts.mjs +3 -1
- package/dist/setup.mjs +27 -0
- package/dist/state.mjs +2 -7
- package/dist/verdict.mjs +10 -5
- 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
|
|
@@ -39,13 +40,21 @@ For each open issue, oldest first:
|
|
|
39
40
|
7. On a red build, give the failed job logs to the fixer agent, then go to step
|
|
40
41
|
6 again. The budget is `maxCiFixes`.
|
|
41
42
|
8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
|
|
42
|
-
reviewer
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
reviewer reads files and runs commands in the worktree, so it can prove a
|
|
44
|
+
finding with the tests or the type check. The harness then throws away every
|
|
45
|
+
change that the reviewer left, so no such change can reach a later commit.
|
|
46
|
+
The reviewer returns a structured verdict that rates each finding `blocking`
|
|
47
|
+
or `minor`. The harness puts the result on the pull request.
|
|
48
|
+
9. An open finding is any finding, blocking or minor, while a review round is
|
|
49
|
+
left. In the last round, only a blocking finding stays open: a minor finding
|
|
50
|
+
there goes on the pull request but starts no fix, because a fix in the last
|
|
51
|
+
round gets no review. Thus `maxReviewRounds` of `1` never fixes a minor
|
|
52
|
+
finding.
|
|
53
|
+
10. No open finding means approval: mark the pull request ready for review and
|
|
54
|
+
set `status:done`. The merge stays with you. The worktree, the local branch
|
|
55
|
+
and the state file go away. The remote branch stays. An open finding always
|
|
56
|
+
stops the approval, even when the reviewer also says approve.
|
|
57
|
+
11. With an open finding, run the fixer agent and go to step 6 again. The
|
|
49
58
|
budget is `maxReviewRounds`.
|
|
50
59
|
|
|
51
60
|
## Stops for a loop
|
|
@@ -85,16 +94,20 @@ too, from the first review round. Thus a draft pull request shows work that the
|
|
|
85
94
|
harness has not finished, and a `status:needs-human` pull request shows work
|
|
86
95
|
that waits for a person.
|
|
87
96
|
|
|
88
|
-
The harness runs all git and `gh` commands itself. The
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
The harness runs all git and `gh` commands itself. The commit holds all the
|
|
98
|
+
changes in the worktree, so a setup command that writes a file outside
|
|
99
|
+
`.gitignore` puts that file in the commit. The reviewer is the one agent whose
|
|
100
|
+
changes never get that far: the harness discards them before it reads the
|
|
101
|
+
verdict.
|
|
91
102
|
|
|
92
103
|
## Safety
|
|
93
104
|
|
|
94
|
-
The implementer and the fixer run with the
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
Every agent can run a shell command. The implementer and the fixer run with the
|
|
106
|
+
permission checks off; the reviewer runs with a smaller tool set, but its shell
|
|
107
|
+
has no limit on the commands. They all start in the worktree, but nothing holds
|
|
108
|
+
them there: they can run any command, with your rights, anywhere on the machine.
|
|
109
|
+
The prompt holds the title, the body and the comments of the issue, which come
|
|
110
|
+
from GitHub.
|
|
98
111
|
|
|
99
112
|
Thus a person who can write an issue or a comment can try to give an
|
|
100
113
|
instruction to the agent. Use the harness only on a repository where you trust
|
|
@@ -111,6 +124,9 @@ Every run has an id, `<timestamp>`, and writes two files under
|
|
|
111
124
|
one report per issue with the outcome, the reason, the pull request number
|
|
112
125
|
and the budgets used.
|
|
113
126
|
|
|
127
|
+
At the start of a run, the harness deletes the files under `.next-issue/runs/`
|
|
128
|
+
that are older than `keepRunDays`. Set the field to `0` to keep them all.
|
|
129
|
+
|
|
114
130
|
A short text summary of the same data goes to standard output. Use `--json` to
|
|
115
131
|
get the full summary object there instead, so the harness fits in a pipe. All
|
|
116
132
|
progress goes to standard error.
|
|
@@ -154,8 +170,8 @@ npx -y next-issue@latest init
|
|
|
154
170
|
|
|
155
171
|
The command writes `next-issue.config.json` in the root of the repository and
|
|
156
172
|
adds `.next-issue/` to `.gitignore`. It stops when the config file is there
|
|
157
|
-
already; `--force` replaces it. The file holds no `setupCommand`, because
|
|
158
|
-
|
|
173
|
+
already; `--force` replaces it. The file holds no `setupCommand`, because the
|
|
174
|
+
harness deduces one from the lock file of the project.
|
|
159
175
|
|
|
160
176
|
A field that is not in the file keeps its default. A file that is not valid
|
|
161
177
|
JSON, a field with a value of the wrong type and a field name that the harness
|
|
@@ -175,6 +191,7 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
175
191
|
"commandTimeoutMinutes": 10,
|
|
176
192
|
"logMaxChars": 20000,
|
|
177
193
|
"diffMaxChars": 60000,
|
|
194
|
+
"keepRunDays": 7,
|
|
178
195
|
"draftPullRequest": true,
|
|
179
196
|
"models": {},
|
|
180
197
|
"labels": {
|
|
@@ -200,10 +217,11 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
200
217
|
| `agentTimeoutMinutes` | `30` | The limit for one agent call |
|
|
201
218
|
| `setupTimeoutMinutes` | `15` | The limit for the setup command |
|
|
202
219
|
| `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
|
|
203
|
-
| `logMaxChars` | `20000` | The maximum length of the failed job logs |
|
|
220
|
+
| `logMaxChars` | `20000` | The maximum length of the failed job logs, shared between the failed jobs |
|
|
204
221
|
| `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
|
|
222
|
+
| `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
|
|
205
223
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
206
|
-
| `setupCommand` |
|
|
224
|
+
| `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
|
|
207
225
|
| `models` | `{}` | The model per agent role |
|
|
208
226
|
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
209
227
|
|
|
@@ -213,8 +231,30 @@ claims every open issue that no other label and no assignee holds back.
|
|
|
213
231
|
The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
|
|
214
232
|
`in-flight` or `assigned`.
|
|
215
233
|
|
|
216
|
-
A role without an entry in `models` uses the default model of the SDK.
|
|
217
|
-
|
|
234
|
+
A role without an entry in `models` uses the default model of the SDK.
|
|
235
|
+
|
|
236
|
+
Without a `setupCommand`, the harness looks in the new worktree for a lock file
|
|
237
|
+
and runs the install command that goes with it:
|
|
238
|
+
|
|
239
|
+
| Lock file | Command |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `pnpm-lock.yaml` | `pnpm install --frozen-lockfile` |
|
|
242
|
+
| `package-lock.json` | `npm ci` |
|
|
243
|
+
| `yarn.lock` | `yarn install --frozen-lockfile`, or `yarn install --immutable` with a `.yarnrc.yml` |
|
|
244
|
+
| `bun.lock`, `bun.lockb` | `bun install --frozen-lockfile` |
|
|
245
|
+
| `Cargo.lock` | `cargo fetch` |
|
|
246
|
+
| `go.sum` | `go mod download` |
|
|
247
|
+
| `uv.lock` | `uv sync` |
|
|
248
|
+
| `poetry.lock` | `poetry install` |
|
|
249
|
+
| `Gemfile.lock` | `bundle install` |
|
|
250
|
+
|
|
251
|
+
The first match in that order wins. A project with no lock file in the list
|
|
252
|
+
runs no setup, and a `setupCommand` in the config file always wins. Thus the
|
|
253
|
+
agents find a worktree with its dependencies in place, and the reviewer can run
|
|
254
|
+
the tests. Each command holds to the lock file, so a lock file that does not
|
|
255
|
+
match the manifest stops the issue rather than changing the lock file.
|
|
256
|
+
|
|
257
|
+
A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
|
|
218
258
|
hands the issue to a person. The wait for the checks has its own limit, so
|
|
219
259
|
`commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
|
|
220
260
|
issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
|
|
@@ -228,6 +268,8 @@ would hand the issue over again at once. To give the issue another try, take the
|
|
|
228
268
|
and the findings back to zero and keeps the branch, the pull request and the
|
|
229
269
|
step that the last run reached, so the work carries on where it stopped. A
|
|
230
270
|
commit that you pushed to the pull request in the meantime is fetched first.
|
|
271
|
+
The flag touches only an issue that the run handles, so the state of an issue
|
|
272
|
+
that a label still holds back stays as it was.
|
|
231
273
|
|
|
232
274
|
## Commands and options
|
|
233
275
|
|
|
@@ -243,6 +285,9 @@ commit that you pushed to the pull request in the meantime is fetched first.
|
|
|
243
285
|
| `--quiet` | Show only the milestones and the summary |
|
|
244
286
|
| `--help` | Show the option list |
|
|
245
287
|
|
|
288
|
+
The exit code is 1 when an issue needs a person or failed, and 0 when it does
|
|
289
|
+
not, so a cron job or a CI step can read the result of the run.
|
|
290
|
+
|
|
246
291
|
## Development
|
|
247
292
|
|
|
248
293
|
```bash
|
package/dist/agents.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { emptyUsage } from "./observe.mjs";
|
|
3
|
-
const
|
|
4
|
-
tools: ["Read", "Grep", "Glob"],
|
|
3
|
+
const REVIEWING = {
|
|
4
|
+
tools: ["Read", "Grep", "Glob", "Bash"],
|
|
5
5
|
permissionMode: "dontAsk",
|
|
6
6
|
};
|
|
7
7
|
const CODING = {
|
|
@@ -9,7 +9,7 @@ const CODING = {
|
|
|
9
9
|
permissionMode: "bypassPermissions",
|
|
10
10
|
allowDangerouslySkipPermissions: true,
|
|
11
11
|
};
|
|
12
|
-
export { CODING,
|
|
12
|
+
export { CODING, REVIEWING };
|
|
13
13
|
export async function runAgent(recorder, request) {
|
|
14
14
|
const log = recorder.scope({ role: request.name });
|
|
15
15
|
const usage = emptyUsage();
|
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/git.mjs
CHANGED
|
@@ -100,6 +100,14 @@ export async function commitAll(cwd, message) {
|
|
|
100
100
|
await must("git", ["commit", "-m", message], { cwd });
|
|
101
101
|
return true;
|
|
102
102
|
}
|
|
103
|
+
export async function discardChanges(cwd) {
|
|
104
|
+
if (!(await isDirty(cwd))) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
await must("git", ["checkout", "--", "."], { cwd });
|
|
108
|
+
await must("git", ["clean", "-fd"], { cwd });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
103
111
|
export async function push(cwd, remote, branch) {
|
|
104
112
|
await must("git", ["push", "-u", remote, branch], { cwd });
|
|
105
113
|
}
|
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
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { CODING,
|
|
1
|
+
import { CODING, REVIEWING, runAgent } from "./agents.mjs";
|
|
2
2
|
import { managedLabels } from "./config.mjs";
|
|
3
3
|
import { run } from "./exec.mjs";
|
|
4
|
-
import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
|
|
4
|
+
import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, 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 {
|
|
9
|
-
import {
|
|
8
|
+
import { detectSetupCommand } from "./setup.mjs";
|
|
9
|
+
import { dropState, readState, resetState, writeState, } from "./state.mjs";
|
|
10
|
+
import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
|
|
10
11
|
export const PORTS = {
|
|
11
12
|
addWorktree,
|
|
12
13
|
assignIssue,
|
|
@@ -14,7 +15,9 @@ export const PORTS = {
|
|
|
14
15
|
commitAll,
|
|
15
16
|
createPr,
|
|
16
17
|
deleteBranch,
|
|
18
|
+
detectSetupCommand,
|
|
17
19
|
diff,
|
|
20
|
+
discardChanges,
|
|
18
21
|
failedCheckLogs,
|
|
19
22
|
findPr,
|
|
20
23
|
hasWorktree,
|
|
@@ -55,7 +58,7 @@ export async function processIssue(context, issue) {
|
|
|
55
58
|
return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
56
59
|
}
|
|
57
60
|
log.event("issue.start", { title: issue.title, createdAt: issue.createdAt }, "quiet");
|
|
58
|
-
const
|
|
61
|
+
const fresh = {
|
|
59
62
|
issue: issue.number,
|
|
60
63
|
phase: "claimed",
|
|
61
64
|
branch: branchName(issue.number),
|
|
@@ -63,6 +66,7 @@ export async function processIssue(context, issue) {
|
|
|
63
66
|
reviewRounds: 0,
|
|
64
67
|
reviewLog: [],
|
|
65
68
|
};
|
|
69
|
+
const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
|
|
66
70
|
try {
|
|
67
71
|
const report = await work(context, issue, log, state);
|
|
68
72
|
log.event("issue.end", { outcome: report.outcome, reason: report.reason, ms: report.ms }, "quiet");
|
|
@@ -119,8 +123,9 @@ async function work(context, issue, log, state) {
|
|
|
119
123
|
ms: Date.now() - started,
|
|
120
124
|
};
|
|
121
125
|
};
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
|
|
127
|
+
if (setupCommand !== undefined) {
|
|
128
|
+
const setup = await log.step("setup", { cmd: setupCommand, found: config.setupCommand === undefined }, () => ports.runSetup(setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
|
|
124
129
|
if (setup.code !== 0) {
|
|
125
130
|
const late = setup.timedOut === true;
|
|
126
131
|
log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
|
|
@@ -223,26 +228,31 @@ async function work(context, issue, log, state) {
|
|
|
223
228
|
name: "reviewer",
|
|
224
229
|
cwd: worktree,
|
|
225
230
|
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
226
|
-
profile:
|
|
231
|
+
profile: REVIEWING,
|
|
227
232
|
model: config.models.reviewer,
|
|
228
233
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
229
234
|
outputSchema: VERDICT_SCHEMA,
|
|
230
235
|
}));
|
|
236
|
+
if (await ports.discardChanges(worktree)) {
|
|
237
|
+
log.event("review.discard", { round: state.reviewRounds }, "quiet");
|
|
238
|
+
}
|
|
231
239
|
const verdict = readVerdict(review.structured);
|
|
232
240
|
if (verdict === undefined) {
|
|
233
241
|
await escalate("The reviewer gave no verdict.");
|
|
234
242
|
return finish("needs-human", "no verdict");
|
|
235
243
|
}
|
|
236
244
|
const blocking = blockingFindings(verdict);
|
|
245
|
+
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
237
246
|
log.event("verdict", {
|
|
238
247
|
round: state.reviewRounds,
|
|
239
248
|
verdict: verdict.verdict,
|
|
240
249
|
blocking: blocking.length,
|
|
241
250
|
minor: verdict.findings.length - blocking.length,
|
|
251
|
+
open: open.length,
|
|
242
252
|
summary: verdict.summary,
|
|
243
253
|
}, "quiet");
|
|
244
|
-
await ports.commentOnPr(repo, state.pr, formatVerdict(verdict));
|
|
245
|
-
if (
|
|
254
|
+
await ports.commentOnPr(repo, state.pr, formatVerdict(verdict, open));
|
|
255
|
+
if (open.length === 0) {
|
|
246
256
|
const after = await gate();
|
|
247
257
|
if (after.done) {
|
|
248
258
|
return after.report;
|
|
@@ -256,15 +266,15 @@ async function work(context, issue, log, state) {
|
|
|
256
266
|
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
257
267
|
return finish("done");
|
|
258
268
|
}
|
|
259
|
-
const mark = fingerprint(
|
|
269
|
+
const mark = fingerprint(open);
|
|
260
270
|
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
261
271
|
await escalate("The reviewer repeated findings that an earlier round did not fix.");
|
|
262
272
|
return finish("needs-human", "repeated findings");
|
|
263
273
|
}
|
|
264
274
|
const earlier = history(state.reviewLog);
|
|
265
|
-
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(
|
|
275
|
+
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
|
|
266
276
|
await writeState(repo, state);
|
|
267
|
-
const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(
|
|
277
|
+
const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
|
|
268
278
|
if (!fix.committed) {
|
|
269
279
|
await escalate("The fixer added no commit for the review findings.");
|
|
270
280
|
return finish("needs-human", "no fix commit");
|
package/dist/prompts.mjs
CHANGED
|
@@ -43,7 +43,9 @@ export function reviewPrompt(issue, comments, diff, earlier) {
|
|
|
43
43
|
diff,
|
|
44
44
|
"```",
|
|
45
45
|
"## Rules",
|
|
46
|
-
"Read files
|
|
46
|
+
"Read files and run commands to prove what you say: the tests, the type check or the linter.",
|
|
47
|
+
"The worktree is ready to use. Do not change a file. Do not write with git or gh.",
|
|
48
|
+
"The harness discards each change that you make, so a change is only lost work.",
|
|
47
49
|
scope,
|
|
48
50
|
"Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.",
|
|
49
51
|
"Give the result as the structured output: the verdict, a short summary and one entry per finding.",
|
package/dist/setup.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const MARKERS = [
|
|
4
|
+
["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
|
|
5
|
+
["package-lock.json", "npm ci"],
|
|
6
|
+
[".yarnrc.yml", "yarn install --immutable"],
|
|
7
|
+
["yarn.lock", "yarn install --frozen-lockfile"],
|
|
8
|
+
["bun.lock", "bun install --frozen-lockfile"],
|
|
9
|
+
["bun.lockb", "bun install --frozen-lockfile"],
|
|
10
|
+
["Cargo.lock", "cargo fetch"],
|
|
11
|
+
["go.sum", "go mod download"],
|
|
12
|
+
["uv.lock", "uv sync"],
|
|
13
|
+
["poetry.lock", "poetry install"],
|
|
14
|
+
["Gemfile.lock", "bundle install"],
|
|
15
|
+
];
|
|
16
|
+
export async function detectSetupCommand(root) {
|
|
17
|
+
for (const [file, command] of MARKERS) {
|
|
18
|
+
try {
|
|
19
|
+
await access(join(root, file));
|
|
20
|
+
return command;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
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/dist/verdict.mjs
CHANGED
|
@@ -55,13 +55,18 @@ export function readVerdict(value) {
|
|
|
55
55
|
export function blockingFindings(verdict) {
|
|
56
56
|
return verdict.findings.filter((finding) => finding.severity === "blocking");
|
|
57
57
|
}
|
|
58
|
-
export function
|
|
59
|
-
return blockingFindings(verdict)
|
|
58
|
+
export function openFindings(verdict, lastRound) {
|
|
59
|
+
return lastRound ? blockingFindings(verdict) : verdict.findings;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const LEFT = "The findings above stay as they are: no blocking finding is open and the review budget is spent.";
|
|
62
|
+
export function formatVerdict(verdict, open) {
|
|
63
|
+
const approved = open.length === 0;
|
|
64
|
+
const head = approved ? "Review: approved" : "Review: changes requested";
|
|
63
65
|
const lines = verdict.findings.map((finding) => `- **${finding.severity}** ${finding.detail}`);
|
|
64
|
-
|
|
66
|
+
const note = approved && verdict.findings.length > 0 ? LEFT : "";
|
|
67
|
+
return [`### ${head}`, verdict.summary, lines.join("\n"), note]
|
|
68
|
+
.filter((part) => part.length > 0)
|
|
69
|
+
.join("\n\n");
|
|
65
70
|
}
|
|
66
71
|
export function formatFindings(findings) {
|
|
67
72
|
return findings.map((finding) => `- ${finding.detail}`).join("\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-issue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
}
|