next-issue 0.18.0 → 0.20.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 CHANGED
@@ -33,7 +33,9 @@ For each open issue, oldest first:
33
33
  local branch that fell behind the remote is fast-forwarded. A fast-forward
34
34
  of the base that is not safe leaves the local base branch as it is and
35
35
  writes a `base.behind` event. A new branch then starts from the remote base.
36
- 4. Let the implementer agent do the work, then commit and push.
36
+ 4. Let the implementer agent do the work, then commit and push. When a git
37
+ hook stops a commit, the agent that made the change gets the hook output,
38
+ fixes it, and the harness commits again. The budget is `maxCommitFixes`.
37
39
  5. Open a draft pull request that closes the issue.
38
40
  6. Wait for the checks, once the pull request shows the pushed commit. A
39
41
  pending state waits again, up to `checkTimeoutMinutes`. A read that fails
@@ -59,9 +61,15 @@ For each open issue, oldest first:
59
61
  finding.
60
62
  10. No open finding means approval: mark the pull request ready for review and
61
63
  set `status:done`. The merge stays with you, unless you pass `--merge`:
62
- then the harness squash-merges the pull request and deletes the remote
63
- branch. The worktree, the local branch and the state file go away. Without
64
- `--merge`, the remote branch stays. A problem that the
64
+ then the harness squash-merges the pull request, deletes the remote
65
+ branch and waits for the checks that the merge commit starts on the base
66
+ branch. A cancelled run there is no failure. When those checks fail, the
67
+ fixer gets their logs on a branch reset to the base branch, from the check
68
+ budget, and the harness reopens the issue and opens a new pull request that
69
+ goes to step 6 again, with fresh review rounds. The next issue waits for
70
+ this, and a second ctrl-c stops only after it. The worktree, the local
71
+ branch and the state file go away. Without `--merge`, the remote branch
72
+ stays. A problem that the
65
73
  reviewer leaves out of the findings thus gets an approval: only the findings
66
74
  count.
67
75
  11. With an open finding, run the fixer agent and go to step 6 again. The
@@ -95,6 +103,9 @@ when:
95
103
  - the reviewer gives no verdict, or the checks do not finish in time;
96
104
  - with `--merge`, the merge of an approved pull request fails, for example
97
105
  because branch protection asks for a human approval;
106
+ - with `--merge`, the checks on the base branch fail after the merge and the
107
+ fixer cannot fix them, or they do not finish in time. The base branch is
108
+ then broken, so the run stops after this issue;
98
109
  - an agent does not finish in `agentTimeoutMinutes`;
99
110
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
100
111
  - a step of the run fails with an error. A `gh` call that is safe to repeat,
@@ -229,6 +240,7 @@ does not know all stop the run, so a typo cannot pass without a word.
229
240
  "remote": "origin",
230
241
  "issueLimit": 100,
231
242
  "maxCiFixes": 5,
243
+ "maxCommitFixes": 10,
232
244
  "maxReviewRounds": 5,
233
245
  "checkIntervalSeconds": 15,
234
246
  "checkGraceSeconds": 60,
@@ -262,6 +274,7 @@ does not know all stop the run, so a typo cannot pass without a word.
262
274
  | `remote` | `origin` | The git remote for the repository and the branches |
263
275
  | `issueLimit` | `100` | The maximum number of open issues to read |
264
276
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
277
+ | `maxCommitFixes` | `10` | The budget for fixes of a commit that a git hook stopped, per issue |
265
278
  | `maxReviewRounds` | `5` | The budget for review rounds per issue |
266
279
  | `checkIntervalSeconds` | `15` | The time between two check states |
267
280
  | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
@@ -349,7 +362,7 @@ that a label still holds back stays as it was.
349
362
  | `--once` | Stop after the first handled issue |
350
363
  | `--max <n>` | Handle at most n issues |
351
364
  | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
352
- | `--merge` | Squash-merge the pull request after the approval, and delete the remote branch |
365
+ | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
353
366
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
354
367
  | `--json` | Print the machine summary instead of the text one |
355
368
  | `--verbose` | Show every command and all agent output |
package/dist/config.mjs CHANGED
@@ -7,6 +7,7 @@ const DEFAULTS = {
7
7
  remote: "origin",
8
8
  issueLimit: 100,
9
9
  maxCiFixes: 5,
10
+ maxCommitFixes: 10,
10
11
  maxReviewRounds: 5,
11
12
  checkIntervalSeconds: 15,
12
13
  checkGraceSeconds: 60,
@@ -35,6 +36,7 @@ const FIELDS = [...Object.keys(DEFAULTS), "setupCommand"];
35
36
  const WHOLE_FIELDS = [
36
37
  ["issueLimit", 1],
37
38
  ["maxCiFixes", 0],
39
+ ["maxCommitFixes", 0],
38
40
  ["maxReviewRounds", 1],
39
41
  ["checkIntervalSeconds", 1],
40
42
  ["checkGraceSeconds", 0],
package/dist/git.mjs CHANGED
@@ -111,6 +111,10 @@ export async function discardChanges(cwd) {
111
111
  export async function push(cwd, remote, branch) {
112
112
  await must("git", ["push", "-u", remote, branch], { cwd });
113
113
  }
114
+ export async function resetToBase(cwd, base, remote) {
115
+ await must("git", ["fetch", remote, base], { cwd });
116
+ await must("git", ["reset", "--hard", `${remote}/${base}`], { cwd });
117
+ }
114
118
  export async function revision(cwd) {
115
119
  return must("git", ["rev-parse", "HEAD"], { cwd });
116
120
  }
package/dist/github.mjs CHANGED
@@ -5,6 +5,7 @@ const NETWORK_TRIES = 3;
5
5
  const NETWORK_FAULT = /unexpected EOF|connection reset|connection refused|i\/o timeout|TLS handshake timeout|HTTP 50[234]/i;
6
6
  const KNOWN_LABELS = new Map();
7
7
  const NO_CHECKS = /no checks reported/i;
8
+ const FAILED_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure"]);
8
9
  function slug(repo) {
9
10
  return `${repo.owner}/${repo.name}`;
10
11
  }
@@ -228,12 +229,42 @@ export async function failedCheckLogs(repo, branch, maxChars) {
228
229
  if (checks === "none") {
229
230
  return "The checks failed, but gh reports no check for the branch.";
230
231
  }
231
- const failed = checks.filter((check) => check.bucket === "fail");
232
+ const failed = checks
233
+ .filter((check) => check.bucket === "fail")
234
+ .map((check) => ({
235
+ heading: `## ${check.name}\n${check.description}`,
236
+ runId: /\/actions\/runs\/(\d+)/.exec(check.link)?.[1],
237
+ }));
238
+ return runLogs(repo, failed, maxChars);
239
+ }
240
+ async function baseRuns(repo, sha) {
241
+ const raw = await gh(repo, ["run", "list", "--commit", sha, "--json", "databaseId,name,status,conclusion"]);
242
+ return JSON.parse(raw);
243
+ }
244
+ function baseState(runs) {
245
+ if (runs.some((entry) => entry.status === "completed" && FAILED_CONCLUSIONS.has(entry.conclusion))) {
246
+ return "fail";
247
+ }
248
+ return runs.every((entry) => entry.status === "completed") ? "pass" : "pending";
249
+ }
250
+ export function waitForBaseChecks(repo, sha, options) {
251
+ return pollChecks({
252
+ head: async () => sha,
253
+ list: async () => ((await baseRuns(repo, sha)).length === 0 ? "none" : "some"),
254
+ watch: async () => baseState(await baseRuns(repo, sha)),
255
+ }, options);
256
+ }
257
+ export async function failedBaseLogs(repo, sha, maxChars) {
258
+ const failed = (await baseRuns(repo, sha))
259
+ .filter((entry) => entry.status === "completed" && FAILED_CONCLUSIONS.has(entry.conclusion))
260
+ .map((entry) => ({ heading: `## ${entry.name}`, runId: String(entry.databaseId) }));
261
+ return runLogs(repo, failed, maxChars);
262
+ }
263
+ async function runLogs(repo, failed, maxChars) {
232
264
  const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
233
265
  const parts = [];
234
- for (const check of failed) {
235
- parts.push(`## ${check.name}\n${check.description}`);
236
- const runId = /\/actions\/runs\/(\d+)/.exec(check.link)?.[1];
266
+ for (const { heading, runId } of failed) {
267
+ parts.push(heading);
237
268
  if (runId === undefined) {
238
269
  continue;
239
270
  }
@@ -262,6 +293,12 @@ export async function mergePr(repo, pr) {
262
293
  const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
263
294
  return result.code === 0;
264
295
  }
296
+ export async function mergeCommit(repo, pr) {
297
+ return gh(repo, ["pr", "view", String(pr), "--json", "mergeCommit", "--jq", ".mergeCommit.oid"]);
298
+ }
299
+ export async function reopenIssue(repo, issue) {
300
+ await gh(repo, ["issue", "reopen", String(issue)]);
301
+ }
265
302
  export async function commentOnPr(repo, pr, body) {
266
303
  await ghOnce(repo, ["pr", "comment", String(pr), "--body-file", "-"], body);
267
304
  }
package/dist/loop.mjs CHANGED
@@ -6,7 +6,7 @@ export async function runIssues(args, loop) {
6
6
  }
7
7
  const report = await loop.process(issue);
8
8
  reports.push(report);
9
- if (loop.stop()) {
9
+ if (loop.stop() || report.halt === true) {
10
10
  return { reports, stoppedAt: issue.number };
11
11
  }
12
12
  if (args.once && report.outcome !== "skipped") {
package/dist/pipeline.mjs CHANGED
@@ -1,10 +1,10 @@
1
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, discardChanges, hasWorktree, isDirty, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
- import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, mergePr, openBlockers, setLabel, waitForChecks, } from "./github.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, isDirty, push, removeWorktree, resetToBase, revision, worktreePath, } from "./git.mjs";
5
+ import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, reopenIssue, setLabel, waitForBaseChecks, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
- import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
7
+ import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
9
9
  import { dropState, readState, resetState, writeState, } from "./state.mjs";
10
10
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
@@ -17,20 +17,25 @@ export const PORTS = {
17
17
  detectSetupCommand,
18
18
  diff,
19
19
  discardChanges,
20
+ failedBaseLogs,
20
21
  failedCheckLogs,
21
22
  findPr,
22
23
  hasWorktree,
23
24
  isDirty,
24
25
  issueComments,
25
26
  markPrReady,
27
+ mergeCommit,
26
28
  mergePr,
27
29
  openBlockers,
28
30
  push,
29
31
  removeWorktree,
32
+ reopenIssue,
33
+ resetToBase,
30
34
  revision,
31
35
  runAgent,
32
36
  runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
33
37
  setLabel,
38
+ waitForBaseChecks,
34
39
  waitForChecks,
35
40
  };
36
41
  export function skipReason(issue, config, resuming) {
@@ -135,6 +140,7 @@ async function work(context, issue, log, state) {
135
140
  comments,
136
141
  started,
137
142
  noChecks: false,
143
+ mainBroken: false,
138
144
  };
139
145
  const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
140
146
  if (setupCommand !== undefined) {
@@ -160,7 +166,7 @@ async function work(context, issue, log, state) {
160
166
  timeoutMs: config.agentTimeoutMinutes * 60_000,
161
167
  }));
162
168
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
163
- if (!(await commitWork(job, before, subject))) {
169
+ if (!(await commitWork(job, before, subject, "implementer"))) {
164
170
  await escalate(job, "The implementer produced no change.");
165
171
  return finish(job, "needs-human", "no implementation commit");
166
172
  }
@@ -171,15 +177,8 @@ async function work(context, issue, log, state) {
171
177
  return stop(job);
172
178
  }
173
179
  await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
174
- const pr = await log.step("pull-request", { branch }, async () => {
175
- const existing = await ports.findPr(repo, branch);
176
- return (existing ??
177
- ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
178
- });
179
- state.pr = pr;
180
180
  state.phase = "review";
181
- await writeState(repo, state);
182
- log.event("pull-request.ready", { pr }, "quiet");
181
+ await openPr(job);
183
182
  for (;;) {
184
183
  if (context.stopAfterRound()) {
185
184
  return stop(job);
@@ -200,6 +199,18 @@ async function work(context, issue, log, state) {
200
199
  }
201
200
  }
202
201
  }
202
+ async function openPr(job) {
203
+ const { context, issue, log, state, branch } = job;
204
+ const { repo, config, ports } = context;
205
+ const pr = await log.step("pull-request", { branch }, async () => {
206
+ const existing = await ports.findPr(repo, branch);
207
+ return (existing ??
208
+ ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
209
+ });
210
+ state.pr = pr;
211
+ await writeState(repo, state);
212
+ log.event("pull-request.ready", { pr }, "quiet");
213
+ }
203
214
  async function checkGate(job) {
204
215
  const { context, log, state, branch, worktree } = job;
205
216
  const { repo, config, ports } = context;
@@ -298,9 +309,18 @@ async function reviewRound(job) {
298
309
  if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
299
310
  log.event("pull-request.undraft.fail", { pr }, "quiet");
300
311
  }
301
- if (context.merge && !(await ports.mergePr(repo, pr))) {
302
- await escalate(job, "The merge of the pull request failed.");
303
- return { done: true, report: await finish(job, "needs-human", "merge failed") };
312
+ if (context.merge) {
313
+ if (!(await ports.mergePr(repo, pr))) {
314
+ await escalate(job, "The merge of the pull request failed.");
315
+ return { done: true, report: await finish(job, "needs-human", "merge failed") };
316
+ }
317
+ const watched = await watchBase(job);
318
+ if (watched.done) {
319
+ return { done: true, report: watched.report };
320
+ }
321
+ if (watched.fixed) {
322
+ return { done: false };
323
+ }
304
324
  }
305
325
  await setStatus(context, issue.number, pr, config.labels.done);
306
326
  return { done: true, report: await finish(job, "done") };
@@ -330,6 +350,50 @@ async function reviewRound(job) {
330
350
  }
331
351
  return { done: false };
332
352
  }
353
+ async function watchBase(job) {
354
+ const { context, issue, log, state, worktree } = job;
355
+ const { repo, config, ports } = context;
356
+ const sha = await ports.mergeCommit(repo, state.pr);
357
+ const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
358
+ intervalSeconds: config.checkIntervalSeconds,
359
+ graceMs: config.checkGraceSeconds * 1000,
360
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
361
+ }));
362
+ log.event("main-checks.state", { state: checks });
363
+ if (checks === "pass" || checks === "none") {
364
+ return { done: false, fixed: false };
365
+ }
366
+ job.mainBroken = true;
367
+ if (checks !== "fail") {
368
+ await escalate(job, "The checks on main did not finish in time.");
369
+ return { done: true, report: await finish(job, "needs-human", "main checks timeout") };
370
+ }
371
+ if (state.ciFixes >= config.maxCiFixes) {
372
+ await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
373
+ return { done: true, report: await finish(job, "needs-human", "check budget") };
374
+ }
375
+ state.ciFixes += 1;
376
+ await writeState(repo, state);
377
+ await ports.resetToBase(worktree, context.base, config.remote);
378
+ const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
379
+ log.event("main-checks.logs", { chars: logs.length });
380
+ const fix = await fixRound(job, "The checks on main failed after the merge.", logs, [], "checks");
381
+ if (!fix.committed) {
382
+ const note = fix.unrelated;
383
+ await escalate(job, note === undefined
384
+ ? "The fixer added no commit for the failed checks on main."
385
+ : `The checks on main fail for a reason this change did not cause: ${note}`);
386
+ return {
387
+ done: true,
388
+ report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
389
+ };
390
+ }
391
+ await ports.reopenIssue(repo, issue.number);
392
+ state.reviewRounds = 0;
393
+ state.reviewLog = [];
394
+ await openPr(job);
395
+ return { done: false, fixed: true };
396
+ }
333
397
  function escalate(job, reason) {
334
398
  return handOver(job.context, job.log, job.issue.number, job.state, reason);
335
399
  }
@@ -363,6 +427,7 @@ async function finish(job, outcome, reason) {
363
427
  ciFixes: state.ciFixes,
364
428
  reviewRounds: state.reviewRounds,
365
429
  ms: Date.now() - job.started,
430
+ ...(job.mainBroken && outcome !== "done" ? { halt: true } : {}),
366
431
  };
367
432
  }
368
433
  async function fixRound(job, reason, detail, earlier, kind) {
@@ -385,19 +450,43 @@ async function fixRound(job, reason, detail, earlier, kind) {
385
450
  return { committed: false, unrelated, stray };
386
451
  }
387
452
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
388
- if (!(await commitWork(job, before, subject))) {
453
+ if (!(await commitWork(job, before, subject, "fixer"))) {
389
454
  log.event("commit.empty", { reason }, "quiet");
390
455
  return { committed: false };
391
456
  }
392
457
  await ports.push(worktree, context.config.remote, branch);
393
458
  return { committed: true };
394
459
  }
395
- async function commitWork(job, before, subject) {
396
- const { context, issue, log, worktree } = job;
397
- if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
398
- log.event("commit", { subject });
460
+ async function commitWork(job, before, subject, role) {
461
+ const { context, issue, log, state, worktree, comments } = job;
462
+ const { repo, config, ports } = context;
463
+ for (;;) {
464
+ try {
465
+ if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
466
+ log.event("commit", { subject });
467
+ }
468
+ return (await ports.revision(worktree)) !== before;
469
+ }
470
+ catch (error) {
471
+ const used = state.commitFixes ?? 0;
472
+ if (used >= config.maxCommitFixes) {
473
+ throw error;
474
+ }
475
+ state.commitFixes = used + 1;
476
+ await writeState(repo, state);
477
+ const output = message(error).slice(-config.logMaxChars);
478
+ log.event("commit.fail", { role, output }, "quiet");
479
+ await log.step("commit-fix", { role }, () => ports.runAgent(log, {
480
+ name: role,
481
+ cwd: worktree,
482
+ prompt: hookPrompt(issue, comments, role, output),
483
+ profile: CODING,
484
+ model: config.models[role],
485
+ agent: config.agent,
486
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
487
+ }));
488
+ }
399
489
  }
400
- return (await context.ports.revision(worktree)) !== before;
401
490
  }
402
491
  async function cleanUp(context, log, issue) {
403
492
  const path = worktreePath(context.repo, issue);
package/dist/prompts.mjs CHANGED
@@ -21,6 +21,16 @@ export function implementPrompt(issue, comments) {
21
21
  COMMIT_RULES,
22
22
  ].join("\n\n");
23
23
  }
24
+ export function hookPrompt(issue, comments, role, output) {
25
+ return [
26
+ `You are the ${role}. The harness could not commit your work for the issue below, because a git hook failed.`,
27
+ issueBlock(issue, comments),
28
+ `## Hook output\n${output}`,
29
+ "## Rules",
30
+ "Fix what the hook reports and keep the rest of the work in this worktree. Do not skip or weaken the hook.",
31
+ COMMIT_RULES,
32
+ ].join("\n\n");
33
+ }
24
34
  export function capDiff(diff, maxChars) {
25
35
  return diff.length <= maxChars
26
36
  ? diff
package/dist/state.mjs CHANGED
@@ -16,7 +16,7 @@ export async function readState(repo, issue) {
16
16
  }
17
17
  }
18
18
  export function resetState(state) {
19
- return { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
19
+ return { ...state, ciFixes: 0, commitFixes: 0, reviewRounds: 0, reviewLog: [] };
20
20
  }
21
21
  export async function writeState(repo, state) {
22
22
  await mkdir(dir(repo), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",