next-issue 1.0.0 → 1.0.1
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 +28 -26
- package/dist/agents.mjs +30 -4
- package/dist/git.mjs +3 -3
- package/dist/github.mjs +19 -3
- package/dist/pipeline.mjs +26 -6
- package/dist/prompts.mjs +2 -2
- package/dist/state.mjs +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,13 +7,6 @@ the Claude Agent SDK. Run it inside a git repository.
|
|
|
7
7
|
npx -y next-issue@latest
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
Or install it once:
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
npm install -g next-issue@latest
|
|
14
|
-
next-issue
|
|
15
|
-
```
|
|
16
|
-
|
|
17
10
|
## What it does
|
|
18
11
|
|
|
19
12
|
For each open issue, oldest first:
|
|
@@ -34,11 +27,16 @@ For each open issue, oldest first:
|
|
|
34
27
|
writes a `base.behind` event. A new branch then starts from the remote base.
|
|
35
28
|
4. Let the implementer agent do the work, then commit and push. When a git
|
|
36
29
|
hook stops a commit, the agent that made the change gets the hook output,
|
|
37
|
-
fixes it, and the harness commits again.
|
|
38
|
-
|
|
30
|
+
fixes it, and the harness commits again. A `commit:` line in the reply of
|
|
31
|
+
the agent replaces the subject, so a hook that rejects the subject can pass.
|
|
32
|
+
The budget is `maxCommitFixes`.
|
|
33
|
+
5. Open a draft pull request that closes the issue. Its title is the subject
|
|
34
|
+
of the commit that starts it, so a squash merge gives a conventional
|
|
35
|
+
commit.
|
|
39
36
|
6. Wait for the checks, once the pull request shows the pushed commit. A
|
|
40
37
|
pending state waits again, up to `checkTimeoutMinutes`. A read that fails
|
|
41
|
-
is tried three times before the run stops. A
|
|
38
|
+
is tried three times before the run stops. A watch that exits with an error
|
|
39
|
+
while no check failed counts as such a read, not as a red build. A repository that reports no
|
|
42
40
|
check keeps asking for `checkGraceSeconds`, because a new pull request often
|
|
43
41
|
shows no check yet, and only then goes straight to the review. The later
|
|
44
42
|
gates of the same issue ask once.
|
|
@@ -61,16 +59,16 @@ For each open issue, oldest first:
|
|
|
61
59
|
10. No open finding means approval: mark the pull request ready for review and
|
|
62
60
|
set `status:done`. The merge stays with you, unless you pass `--merge`:
|
|
63
61
|
then the harness squash-merges the pull request, deletes the remote
|
|
64
|
-
branch
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
count.
|
|
62
|
+
branch, waits until the pull request is merged, for example through a
|
|
63
|
+
merge queue, and then waits for the checks that the merge commit starts on
|
|
64
|
+
the base branch. A cancelled run there is no failure. When those checks
|
|
65
|
+
fail, the fixer gets their logs on a branch reset to the base branch, from
|
|
66
|
+
the check budget, and the harness reopens the issue and opens a new pull
|
|
67
|
+
request that goes to step 6 again, with fresh review rounds. The next
|
|
68
|
+
issue waits for this, and a second ctrl-c stops only after it. The
|
|
69
|
+
worktree, the local branch and the state file go away. Without `--merge`,
|
|
70
|
+
the remote branch stays. A problem that the reviewer leaves out of the
|
|
71
|
+
findings thus gets an approval: only the findings count.
|
|
74
72
|
11. With an open finding, run the fixer agent and go to step 6 again. The
|
|
75
73
|
budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
|
|
76
74
|
changes nothing and gives its reason instead. The reason goes on the pull
|
|
@@ -102,6 +100,8 @@ when:
|
|
|
102
100
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
103
101
|
- with `--merge`, the merge of an approved pull request fails, for example
|
|
104
102
|
because branch protection asks for a human approval;
|
|
103
|
+
- with `--merge`, a queued merge does not finish in `checkTimeoutMinutes`, or
|
|
104
|
+
the pull request closes without a merge;
|
|
105
105
|
- with `--merge`, the checks on the base branch fail after the merge and the
|
|
106
106
|
fixer cannot fix them, or they do not finish in time. The base branch is
|
|
107
107
|
then broken, so the run stops after this issue;
|
|
@@ -151,8 +151,8 @@ and a `status:needs-human` pull request shows work that waits for a person.
|
|
|
151
151
|
The harness runs all git and `gh` commands itself. The commit holds all the
|
|
152
152
|
changes in the worktree, so a setup command that writes a file outside
|
|
153
153
|
`.gitignore` puts that file in the commit. The reviewer is the one agent whose
|
|
154
|
-
changes never get that far: the harness discards them
|
|
155
|
-
verdict.
|
|
154
|
+
changes never get that far: the harness discards them, and undoes any commit
|
|
155
|
+
that the reviewer made, before it reads the verdict.
|
|
156
156
|
|
|
157
157
|
## Safety
|
|
158
158
|
|
|
@@ -319,7 +319,8 @@ A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
|
|
|
319
319
|
hands the issue to a person. The wait for the checks has its own limit, so
|
|
320
320
|
`commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
|
|
321
321
|
issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
|
|
322
|
-
run stopped.
|
|
322
|
+
run stopped. A state file that is not valid JSON gives the issue an `error`
|
|
323
|
+
outcome that names the file, rather than a silent skip.
|
|
323
324
|
|
|
324
325
|
## Retry an issue that waits for a person
|
|
325
326
|
|
|
@@ -374,6 +375,7 @@ The workflow then bumps the version and publishes the package to npm with a
|
|
|
374
375
|
[trusted publish](https://docs.npmjs.com/trusted-publishers), so no token is
|
|
375
376
|
stored. Only after the publish does it push the tag and make the GitHub release,
|
|
376
377
|
so a failed publish leaves no half-release behind. A manual run of the workflow
|
|
377
|
-
|
|
378
|
-
a release that reached GitHub but not npm.
|
|
379
|
-
GitHub runner, because npm takes provenance only
|
|
378
|
+
checks out the last tag and publishes that commit under the tag version, and
|
|
379
|
+
changes nothing else. This repairs a release that reached GitHub but not npm.
|
|
380
|
+
The release job stays on a GitHub runner, because npm takes provenance only
|
|
381
|
+
from a GitHub runner.
|
package/dist/agents.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
5
|
import { track } from "./exec.mjs";
|
|
4
6
|
import { emptyUsage } from "./observe.mjs";
|
|
5
7
|
const REVIEWING = {
|
|
@@ -12,6 +14,8 @@ const CODING = {
|
|
|
12
14
|
allowDangerouslySkipPermissions: true,
|
|
13
15
|
};
|
|
14
16
|
export { CODING, REVIEWING };
|
|
17
|
+
const STDERR_TAIL = 2000;
|
|
18
|
+
const STDERR_GRACE_MS = 500;
|
|
15
19
|
export async function runAgent(recorder, request) {
|
|
16
20
|
const log = recorder.scope({ role: request.name });
|
|
17
21
|
const usage = emptyUsage();
|
|
@@ -23,6 +27,8 @@ export async function runAgent(recorder, request) {
|
|
|
23
27
|
let model = request.model;
|
|
24
28
|
let sessionId;
|
|
25
29
|
let structured;
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let stderrClosed = Promise.resolve();
|
|
26
32
|
const started = Date.now();
|
|
27
33
|
log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
|
|
28
34
|
const controller = new AbortController();
|
|
@@ -42,7 +48,14 @@ export async function runAgent(recorder, request) {
|
|
|
42
48
|
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
43
49
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
44
50
|
abortController: controller,
|
|
45
|
-
|
|
51
|
+
pathToClaudeCodeExecutable: request.executable,
|
|
52
|
+
spawnClaudeCodeProcess: (options) => {
|
|
53
|
+
const child = spawnClaude(options, (chunk) => {
|
|
54
|
+
stderr = (stderr + chunk).slice(-STDERR_TAIL);
|
|
55
|
+
});
|
|
56
|
+
stderrClosed = once(child.stderr, "close").catch(() => undefined);
|
|
57
|
+
return child;
|
|
58
|
+
},
|
|
46
59
|
outputFormat: request.outputSchema === undefined
|
|
47
60
|
? undefined
|
|
48
61
|
: { type: "json_schema", schema: request.outputSchema },
|
|
@@ -101,7 +114,11 @@ export async function runAgent(recorder, request) {
|
|
|
101
114
|
}
|
|
102
115
|
}
|
|
103
116
|
catch (error) {
|
|
104
|
-
|
|
117
|
+
if (controller.signal.aborted) {
|
|
118
|
+
throw late();
|
|
119
|
+
}
|
|
120
|
+
await Promise.race([stderrClosed, delay(STDERR_GRACE_MS, undefined, { ref: false })]);
|
|
121
|
+
throw withStderr(error, stderr);
|
|
105
122
|
}
|
|
106
123
|
finally {
|
|
107
124
|
clearTimeout(timer);
|
|
@@ -147,14 +164,23 @@ export function toolFields(name, input) {
|
|
|
147
164
|
const { command } = input;
|
|
148
165
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
149
166
|
}
|
|
150
|
-
export function
|
|
167
|
+
export function withStderr(error, stderr) {
|
|
168
|
+
const tail = stderr.trim();
|
|
169
|
+
if (tail.length === 0 || !(error instanceof Error)) {
|
|
170
|
+
return error;
|
|
171
|
+
}
|
|
172
|
+
return new Error(`${error.message}\nClaude CLI stderr:\n${tail}`, { cause: error });
|
|
173
|
+
}
|
|
174
|
+
export function spawnClaude(options, onStderr = () => undefined) {
|
|
151
175
|
const child = spawn(options.command, options.args, {
|
|
152
176
|
cwd: options.cwd,
|
|
153
177
|
env: options.env,
|
|
154
178
|
signal: options.signal,
|
|
155
179
|
detached: true,
|
|
156
|
-
stdio: ["pipe", "pipe", "
|
|
180
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
157
181
|
});
|
|
182
|
+
child.stderr.setEncoding("utf8");
|
|
183
|
+
child.stderr.on("data", onStderr);
|
|
158
184
|
track(child);
|
|
159
185
|
return child;
|
|
160
186
|
}
|
package/dist/git.mjs
CHANGED
|
@@ -100,11 +100,11 @@ 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))) {
|
|
103
|
+
export async function discardChanges(cwd, target) {
|
|
104
|
+
if (!(await isDirty(cwd)) && (await revision(cwd)) === target) {
|
|
105
105
|
return false;
|
|
106
106
|
}
|
|
107
|
-
await must("git", ["reset", "--hard",
|
|
107
|
+
await must("git", ["reset", "--hard", target], { cwd });
|
|
108
108
|
await must("git", ["clean", "-fd"], { cwd });
|
|
109
109
|
return true;
|
|
110
110
|
}
|
package/dist/github.mjs
CHANGED
|
@@ -168,7 +168,11 @@ async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
|
|
|
168
168
|
if (watch.code === CHECK_PENDING_CODE) {
|
|
169
169
|
return "pending";
|
|
170
170
|
}
|
|
171
|
-
|
|
171
|
+
const checks = await readChecks(repo, branch);
|
|
172
|
+
if (checks !== "none" && checks.some((check) => check.bucket === "fail")) {
|
|
173
|
+
return "fail";
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`gh pr checks ${branch} --watch failed with code ${watch.code} and no check failed: ${watch.stderr.trim()}`);
|
|
172
176
|
}
|
|
173
177
|
const REAL_CLOCK = {
|
|
174
178
|
now: () => Date.now(),
|
|
@@ -293,8 +297,20 @@ export async function mergePr(repo, pr) {
|
|
|
293
297
|
const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
|
|
294
298
|
return result.code === 0;
|
|
295
299
|
}
|
|
296
|
-
export async function mergeCommit(repo, pr) {
|
|
297
|
-
|
|
300
|
+
export async function mergeCommit(repo, pr, options, clock = REAL_CLOCK) {
|
|
301
|
+
const deadline = clock.now() + options.timeoutMs;
|
|
302
|
+
for (;;) {
|
|
303
|
+
const raw = await gh(repo, ["pr", "view", String(pr), "--json", "state,mergeCommit"]);
|
|
304
|
+
const view = JSON.parse(raw);
|
|
305
|
+
if (view.state === "MERGED" && view.mergeCommit?.oid) {
|
|
306
|
+
return view.mergeCommit.oid;
|
|
307
|
+
}
|
|
308
|
+
const rest = deadline - clock.now();
|
|
309
|
+
if (view.state === "CLOSED" || rest <= 0) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
await clock.sleep(Math.min(options.intervalSeconds * 1000, rest));
|
|
313
|
+
}
|
|
298
314
|
}
|
|
299
315
|
export async function reopenIssue(repo, issue) {
|
|
300
316
|
await gh(repo, ["issue", "reopen", String(issue)]);
|
package/dist/pipeline.mjs
CHANGED
|
@@ -58,7 +58,14 @@ export function skipReason(issue, config, resuming) {
|
|
|
58
58
|
export async function processIssue(context, issue) {
|
|
59
59
|
const started = Date.now();
|
|
60
60
|
const log = context.recorder.scope({ issue: issue.number });
|
|
61
|
-
|
|
61
|
+
let saved;
|
|
62
|
+
try {
|
|
63
|
+
saved = await readState(context.repo, issue.number);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
log.event("issue.error", { error: message(error), ms: Date.now() - started }, "quiet");
|
|
67
|
+
return { ...skipped(issue), outcome: "error", reason: message(error), ms: Date.now() - started };
|
|
68
|
+
}
|
|
62
69
|
const skip = skipReason(issue, context.config, saved !== undefined);
|
|
63
70
|
if (skip !== undefined) {
|
|
64
71
|
log.event("issue.skip", { title: issue.title, reason: skip });
|
|
@@ -204,7 +211,7 @@ async function openPr(job) {
|
|
|
204
211
|
const pr = await log.step("pull-request", { branch }, async () => {
|
|
205
212
|
const existing = await ports.findPr(repo, branch);
|
|
206
213
|
return (existing ??
|
|
207
|
-
ports.createPr(repo, branch, context.base,
|
|
214
|
+
ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
208
215
|
});
|
|
209
216
|
state.pr = pr;
|
|
210
217
|
await writeState(repo, state);
|
|
@@ -269,6 +276,7 @@ async function reviewRound(job) {
|
|
|
269
276
|
diffChars: full.length,
|
|
270
277
|
cut: full.length > config.diffMaxChars,
|
|
271
278
|
};
|
|
279
|
+
const before = await ports.revision(worktree);
|
|
272
280
|
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
273
281
|
name: "reviewer",
|
|
274
282
|
cwd: worktree,
|
|
@@ -278,7 +286,7 @@ async function reviewRound(job) {
|
|
|
278
286
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
279
287
|
outputSchema: VERDICT_SCHEMA,
|
|
280
288
|
}));
|
|
281
|
-
if (await ports.discardChanges(worktree)) {
|
|
289
|
+
if (await ports.discardChanges(worktree, before)) {
|
|
282
290
|
log.event("review.discard", { round: state.reviewRounds }, "quiet");
|
|
283
291
|
}
|
|
284
292
|
const verdict = readVerdict(review.structured);
|
|
@@ -351,7 +359,14 @@ async function reviewRound(job) {
|
|
|
351
359
|
async function watchBase(job) {
|
|
352
360
|
const { context, issue, log, state, worktree } = job;
|
|
353
361
|
const { repo, config, ports } = context;
|
|
354
|
-
const sha = await ports.mergeCommit(repo, state.pr
|
|
362
|
+
const sha = await ports.mergeCommit(repo, state.pr, {
|
|
363
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
364
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
365
|
+
});
|
|
366
|
+
if (sha === undefined) {
|
|
367
|
+
await escalate(job, "The pull request did not merge in time, or it closed without a merge.");
|
|
368
|
+
return { done: true, report: await finish(job, "needs-human", "not merged") };
|
|
369
|
+
}
|
|
355
370
|
const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
|
|
356
371
|
intervalSeconds: config.checkIntervalSeconds,
|
|
357
372
|
graceMs: config.checkGraceSeconds * 1000,
|
|
@@ -462,7 +477,11 @@ async function commitWork(job, before, subject, role) {
|
|
|
462
477
|
if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
463
478
|
log.event("commit", { subject });
|
|
464
479
|
}
|
|
465
|
-
|
|
480
|
+
if ((await ports.revision(worktree)) === before) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
state.title = subject;
|
|
484
|
+
return true;
|
|
466
485
|
}
|
|
467
486
|
catch (error) {
|
|
468
487
|
const used = state.commitFixes ?? 0;
|
|
@@ -473,7 +492,7 @@ async function commitWork(job, before, subject, role) {
|
|
|
473
492
|
await writeState(repo, state);
|
|
474
493
|
const output = message(error).slice(-config.logMaxChars);
|
|
475
494
|
log.event("commit.fail", { role, output }, "quiet");
|
|
476
|
-
await log.step("commit-fix", { role }, () => ports.runAgent(log, {
|
|
495
|
+
const fix = await log.step("commit-fix", { role }, () => ports.runAgent(log, {
|
|
477
496
|
name: role,
|
|
478
497
|
cwd: worktree,
|
|
479
498
|
prompt: hookPrompt(issue, comments, role, output),
|
|
@@ -481,6 +500,7 @@ async function commitWork(job, before, subject, role) {
|
|
|
481
500
|
model: config.models[role],
|
|
482
501
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
483
502
|
}));
|
|
503
|
+
subject = parseCommitSubject(fix.text, subject);
|
|
484
504
|
}
|
|
485
505
|
}
|
|
486
506
|
}
|
package/dist/prompts.mjs
CHANGED
|
@@ -95,12 +95,12 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
|
95
95
|
.join("\n\n");
|
|
96
96
|
}
|
|
97
97
|
export function parseUnrelated(text) {
|
|
98
|
-
const match = /^unrelated:\s*(.+)$/
|
|
98
|
+
const match = [...text.matchAll(/^unrelated:\s*(.+)$/gim)].at(-1);
|
|
99
99
|
const note = match?.[1]?.trim();
|
|
100
100
|
return note !== undefined && note.length > 0 ? note : undefined;
|
|
101
101
|
}
|
|
102
102
|
export function parseCommitSubject(text, fallback) {
|
|
103
|
-
const match = /^commit:\s*(.+)$/
|
|
103
|
+
const match = [...text.matchAll(/^commit:\s*(.+)$/gim)].at(-1);
|
|
104
104
|
const subject = match?.[1]
|
|
105
105
|
?.trim()
|
|
106
106
|
.replace(/^`+|`+$/g, "")
|
package/dist/state.mjs
CHANGED
|
@@ -8,11 +8,22 @@ function file(repo, issue) {
|
|
|
8
8
|
return join(dir(repo), `${issue}.json`);
|
|
9
9
|
}
|
|
10
10
|
export async function readState(repo, issue) {
|
|
11
|
+
const path = file(repo, issue);
|
|
12
|
+
let text;
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
text = await readFile(path, "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === "ENOENT") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text);
|
|
13
24
|
}
|
|
14
25
|
catch {
|
|
15
|
-
|
|
26
|
+
throw new Error(`the state file ${path} is not valid JSON`);
|
|
16
27
|
}
|
|
17
28
|
}
|
|
18
29
|
export function resetState(state) {
|