next-issue 1.0.2 → 1.0.3

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
@@ -17,7 +17,8 @@ For each open issue, oldest first:
17
17
  resume too. An issue that GitHub lists as blocked by an open issue waits,
18
18
  because its blocker closes only when the pull request of the blocker merges.
19
19
  An issue with saved state under `.next-issue/` resumes instead, whatever its
20
- other labels and blockers.
20
+ other labels and blockers. Such an issue joins the list also when it is
21
+ closed, if its pull request is merged.
21
22
  2. Add `status:in-progress` to the issue.
22
23
  3. Fetch the remote, fast-forward the local copy of the default branch when
23
24
  that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
@@ -66,8 +67,13 @@ For each open issue, oldest first:
66
67
  the check budget, and the harness force-pushes the fix, reopens the issue
67
68
  and opens a new pull request that goes to step 6 again, with fresh review
68
69
  rounds. The next issue waits for this, and a second ctrl-c stops only after
69
- it. The worktree, the local branch and the state file go away. Without
70
- `--merge`, the remote branch stays. A problem that the reviewer leaves out
70
+ it. The worktree, the local branch and the state file go away. A resume
71
+ whose pull request is already merged goes straight to the wait for the
72
+ checks on the base branch, and opens no new pull request. A resume that
73
+ stopped inside the fix of the base branch carries on with that fix, and
74
+ uses no more of the check budget. Without `--merge`, the remote branch
75
+ stays, and a resume whose pull request a person merged sets
76
+ `status:done` and waits for no check. A problem that the reviewer leaves out
71
77
  of the findings thus gets an approval: only the findings count.
72
78
  11. With an open finding, run the fixer agent and go to step 6 again. The
73
79
  budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
package/dist/cli.mjs CHANGED
@@ -5,11 +5,11 @@ import { parseArgs, USAGE } from "./args.mjs";
5
5
  import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
6
  import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
7
  import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
8
- import { defaultBranch, openIssues } from "./github.mjs";
8
+ import { defaultBranch } from "./github.mjs";
9
9
  import { listenForInterrupts } from "./interrupt.mjs";
10
10
  import { exitCode, runIssues } from "./loop.mjs";
11
11
  import { message, pruneRuns, Recorder } from "./observe.mjs";
12
- import { PORTS, processIssue } from "./pipeline.mjs";
12
+ import { pendingIssues, PORTS, processIssue } from "./pipeline.mjs";
13
13
  import { STATE_DIR } from "./state.mjs";
14
14
  import { formatSummary } from "./summary.mjs";
15
15
  import { packageVersion } from "./version.mjs";
@@ -79,7 +79,7 @@ async function handle(args, config, repo, recorder) {
79
79
  base,
80
80
  log: recorder.logFile,
81
81
  }, "quiet");
82
- const issues = await openIssues(repo, config.issueLimit);
82
+ const issues = await pendingIssues(context);
83
83
  recorder.event("issues", { open: issues.length }, "quiet");
84
84
  const { reports, stoppedAt } = await runIssues(args, {
85
85
  issues,
package/dist/github.mjs CHANGED
@@ -34,6 +34,16 @@ export async function defaultBranch(repo) {
34
34
  });
35
35
  return JSON.parse(raw).defaultBranchRef.name;
36
36
  }
37
+ const ISSUE_FIELDS = "number,title,body,createdAt,labels";
38
+ function toIssue(item) {
39
+ return {
40
+ number: item.number,
41
+ title: item.title,
42
+ body: item.body ?? "",
43
+ createdAt: item.createdAt,
44
+ labels: item.labels.map((label) => label.name),
45
+ };
46
+ }
37
47
  export async function openIssues(repo, limit) {
38
48
  const raw = await gh(repo, [
39
49
  "issue",
@@ -45,18 +55,14 @@ export async function openIssues(repo, limit) {
45
55
  "--limit",
46
56
  String(limit),
47
57
  "--json",
48
- "number,title,body,createdAt,labels",
58
+ ISSUE_FIELDS,
49
59
  ]);
50
60
  const parsed = JSON.parse(raw);
51
- return parsed
52
- .map((item) => ({
53
- number: item.number,
54
- title: item.title,
55
- body: item.body ?? "",
56
- createdAt: item.createdAt,
57
- labels: item.labels.map((label) => label.name),
58
- }))
59
- .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
61
+ return parsed.map(toIssue).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
62
+ }
63
+ export async function viewIssue(repo, issue) {
64
+ const raw = await gh(repo, ["issue", "view", String(issue), "--json", ISSUE_FIELDS]);
65
+ return toIssue(JSON.parse(raw));
60
66
  }
61
67
  export async function issueComments(repo, issue) {
62
68
  const raw = await gh(repo, ["issue", "view", String(issue), "--json", "comments"]);
@@ -297,6 +303,9 @@ export async function mergePr(repo, pr) {
297
303
  const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
298
304
  return result.code === 0;
299
305
  }
306
+ export async function prMerged(repo, pr) {
307
+ return (await gh(repo, ["pr", "view", String(pr), "--json", "state", "--jq", ".state"])).trim() === "MERGED";
308
+ }
300
309
  export async function mergeCommit(repo, pr, options, clock = REAL_CLOCK) {
301
310
  const deadline = clock.now() + options.timeoutMs;
302
311
  for (;;) {
package/dist/pipeline.mjs CHANGED
@@ -2,11 +2,11 @@ import { CODING, REVIEWING, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
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";
5
+ import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
7
  import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
9
- import { dropState, readState, resetState, writeState, } from "./state.mjs";
9
+ import { dropState, readState, resetState, savedIssues, writeState, } from "./state.mjs";
10
10
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
11
11
  export const PORTS = {
12
12
  addWorktree,
@@ -27,6 +27,8 @@ export const PORTS = {
27
27
  mergeCommit,
28
28
  mergePr,
29
29
  openBlockers,
30
+ openIssues,
31
+ prMerged,
30
32
  push,
31
33
  removeWorktree,
32
34
  reopenIssue,
@@ -35,6 +37,7 @@ export const PORTS = {
35
37
  runAgent,
36
38
  runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
37
39
  setLabel,
40
+ viewIssue,
38
41
  waitForBaseChecks,
39
42
  waitForChecks,
40
43
  };
@@ -55,6 +58,21 @@ export function skipReason(issue, config, resuming) {
55
58
  }
56
59
  return undefined;
57
60
  }
61
+ export async function pendingIssues(context) {
62
+ const { repo, config, ports } = context;
63
+ const issues = await ports.openIssues(repo, config.issueLimit);
64
+ const listed = new Set(issues.map((issue) => issue.number));
65
+ for (const number of await savedIssues(repo)) {
66
+ if (listed.has(number)) {
67
+ continue;
68
+ }
69
+ const saved = await readState(repo, number).catch(() => undefined);
70
+ if (saved?.pr !== undefined && (await ports.prMerged(repo, saved.pr))) {
71
+ issues.push(await ports.viewIssue(repo, number));
72
+ }
73
+ }
74
+ return issues.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
75
+ }
58
76
  export async function processIssue(context, issue) {
59
77
  const started = Date.now();
60
78
  const log = context.recorder.scope({ issue: issue.number });
@@ -182,9 +200,29 @@ async function work(context, issue, log, state) {
182
200
  if (context.stopAfterRound()) {
183
201
  return stop(job);
184
202
  }
185
- await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
186
- state.phase = "review";
187
- await openPr(job);
203
+ if (state.baseFix !== undefined) {
204
+ job.mainBroken = true;
205
+ const fixed = await fixBase(job);
206
+ if (fixed.done) {
207
+ return fixed.report;
208
+ }
209
+ }
210
+ else if (state.phase === "review" && state.pr !== undefined && (await ports.prMerged(repo, state.pr))) {
211
+ log.event("pull-request.merged", { pr: state.pr }, "quiet");
212
+ if (!context.merge) {
213
+ await setStatus(context, issue.number, state.pr, config.labels.done);
214
+ return finish(job, "done");
215
+ }
216
+ const landed = await afterMerge(job);
217
+ if (landed.done) {
218
+ return landed.report;
219
+ }
220
+ }
221
+ else {
222
+ await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
223
+ state.phase = "review";
224
+ await openPr(job);
225
+ }
188
226
  for (;;) {
189
227
  if (context.stopAfterRound()) {
190
228
  return stop(job);
@@ -320,13 +358,7 @@ async function reviewRound(job) {
320
358
  await escalate(job, "The merge of the pull request failed.");
321
359
  return { done: true, report: await finish(job, "needs-human", "merge failed") };
322
360
  }
323
- const watched = await watchBase(job);
324
- if (watched.done) {
325
- return { done: true, report: watched.report };
326
- }
327
- if (watched.fixed) {
328
- return { done: false };
329
- }
361
+ return afterMerge(job);
330
362
  }
331
363
  await setStatus(context, issue.number, pr, config.labels.done);
332
364
  return { done: true, report: await finish(job, "done") };
@@ -356,8 +388,20 @@ async function reviewRound(job) {
356
388
  }
357
389
  return { done: false };
358
390
  }
391
+ async function afterMerge(job) {
392
+ const { context, issue, state } = job;
393
+ const watched = await watchBase(job);
394
+ if (watched.done) {
395
+ return { done: true, report: watched.report };
396
+ }
397
+ if (watched.fixed) {
398
+ return { done: false };
399
+ }
400
+ await setStatus(context, issue.number, state.pr, context.config.labels.done);
401
+ return { done: true, report: await finish(job, "done") };
402
+ }
359
403
  async function watchBase(job) {
360
- const { context, issue, log, state, worktree } = job;
404
+ const { context, log, state, worktree } = job;
361
405
  const { repo, config, ports } = context;
362
406
  const sha = await ports.mergeCommit(repo, state.pr, {
363
407
  intervalSeconds: config.checkIntervalSeconds,
@@ -385,12 +429,19 @@ async function watchBase(job) {
385
429
  await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
386
430
  return { done: true, report: await finish(job, "needs-human", "check budget") };
387
431
  }
432
+ await ports.resetToBase(worktree, context.base, config.remote);
388
433
  state.ciFixes += 1;
434
+ state.baseFix = { sha, head: await ports.revision(worktree) };
389
435
  await writeState(repo, state);
390
- await ports.resetToBase(worktree, context.base, config.remote);
436
+ return fixBase(job);
437
+ }
438
+ async function fixBase(job) {
439
+ const { context, issue, log, state } = job;
440
+ const { repo, config, ports } = context;
441
+ const { sha, head } = state.baseFix;
391
442
  const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
392
443
  log.event("main-checks.logs", { chars: logs.length });
393
- const fix = await fixRound(job, "The checks on main failed after the merge.", logs, [], "checks", true);
444
+ const fix = await fixRound(job, "The checks on main failed after the merge.", logs, [], "checks", true, head);
394
445
  if (!fix.committed) {
395
446
  const note = fix.unrelated;
396
447
  await escalate(job, note === undefined
@@ -404,6 +455,7 @@ async function watchBase(job) {
404
455
  await ports.reopenIssue(repo, issue.number);
405
456
  state.reviewRounds = 0;
406
457
  state.reviewLog = [];
458
+ state.baseFix = undefined;
407
459
  await openPr(job);
408
460
  return { done: false, fixed: true };
409
461
  }
@@ -443,10 +495,10 @@ async function finish(job, outcome, reason) {
443
495
  ...(job.mainBroken && outcome !== "done" ? { halt: true } : {}),
444
496
  };
445
497
  }
446
- async function fixRound(job, reason, detail, earlier, kind, force = false) {
498
+ async function fixRound(job, reason, detail, earlier, kind, force = false, from) {
447
499
  const { context, issue, log, worktree, branch } = job;
448
500
  const { ports } = context;
449
- const before = await ports.revision(worktree);
501
+ const before = from ?? (await ports.revision(worktree));
450
502
  const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
451
503
  name: "fixer",
452
504
  cwd: worktree,
package/dist/state.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  export const STATE_DIR = ".next-issue";
4
4
  function dir(repo) {
@@ -26,6 +26,22 @@ export async function readState(repo, issue) {
26
26
  throw new Error(`the state file ${path} is not valid JSON`);
27
27
  }
28
28
  }
29
+ export async function savedIssues(repo) {
30
+ let names;
31
+ try {
32
+ names = await readdir(dir(repo));
33
+ }
34
+ catch (error) {
35
+ if (error.code === "ENOENT") {
36
+ return [];
37
+ }
38
+ throw error;
39
+ }
40
+ return names.flatMap((name) => {
41
+ const match = /^(\d+)\.json$/.exec(name);
42
+ return match === null ? [] : [Number(match[1])];
43
+ });
44
+ }
29
45
  export function resetState(state) {
30
46
  return { ...state, ciFixes: 0, commitFixes: 0, reviewRounds: 0, reviewLog: [] };
31
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",