next-issue 0.18.0 → 0.19.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
@@ -59,9 +59,15 @@ For each open issue, oldest first:
59
59
  finding.
60
60
  10. No open finding means approval: mark the pull request ready for review and
61
61
  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
62
+ then the harness squash-merges the pull request, deletes the remote
63
+ branch and waits for the checks that the merge commit starts on the base
64
+ branch. A cancelled run there is no failure. When those checks fail, the
65
+ fixer gets their logs on a branch reset to the base branch, from the check
66
+ budget, and the harness reopens the issue and opens a new pull request that
67
+ goes to step 6 again, with fresh review rounds. The next issue waits for
68
+ this, and a second ctrl-c stops only after it. The worktree, the local
69
+ branch and the state file go away. Without `--merge`, the remote branch
70
+ stays. A problem that the
65
71
  reviewer leaves out of the findings thus gets an approval: only the findings
66
72
  count.
67
73
  11. With an open finding, run the fixer agent and go to step 6 again. The
@@ -95,6 +101,9 @@ when:
95
101
  - the reviewer gives no verdict, or the checks do not finish in time;
96
102
  - with `--merge`, the merge of an approved pull request fails, for example
97
103
  because branch protection asks for a human approval;
104
+ - with `--merge`, the checks on the base branch fail after the merge and the
105
+ fixer cannot fix them, or they do not finish in time. The base branch is
106
+ then broken, so the run stops after this issue;
98
107
  - an agent does not finish in `agentTimeoutMinutes`;
99
108
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
100
109
  - a step of the run fails with an error. A `gh` call that is safe to repeat,
@@ -349,7 +358,7 @@ that a label still holds back stays as it was.
349
358
  | `--once` | Stop after the first handled issue |
350
359
  | `--max <n>` | Handle at most n issues |
351
360
  | `--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 |
361
+ | `--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
362
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
354
363
  | `--json` | Print the machine summary instead of the text one |
355
364
  | `--verbose` | Show every command and all agent output |
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,8 +1,8 @@
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
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.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) {
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",