next-issue 1.0.6 → 1.0.8

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
@@ -9,7 +9,11 @@ npx -y next-issue@latest
9
9
 
10
10
  ## What it does
11
11
 
12
- For each open issue, oldest first:
12
+ Before each issue, fetch the open issues again and take the oldest one not yet
13
+ seen in this run. An issue seen once in a run, also a skipped or failed one, is
14
+ not tried again in that run. The run ends when no unseen issue is left. When
15
+ the fetch fails, the run ends there, writes the summary of the issues it handled
16
+ and exits with code 1. For each issue:
13
17
 
14
18
  1. Claim the issue only when it holds the `status:todo` label and holds no
15
19
  `status:in-progress` or `status:in-review` label. A `status:done`,
@@ -21,11 +25,14 @@ For each open issue, oldest first:
21
25
  closed, if its pull request is merged.
22
26
  2. Add `status:in-progress` to the issue.
23
27
  3. Fetch the remote, fast-forward the local copy of the default branch when
24
- that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
25
- `issue-<n>` branch. A branch that exists on the remote is the start, and a
26
- local branch that fell behind the remote is fast-forwarded. A fast-forward
27
- of the base that is not safe leaves the local base branch as it is and
28
- writes a `base.behind` event. A new branch then starts from the remote base.
28
+ that is safe, then add a git worktree at `.worktrees/<n>` in the
29
+ repository root on the `<n>` branch. An issue that resumes from saved
30
+ state keeps the branch name that the state holds, and a worktree that
31
+ holds that branch already stays in use. A branch that exists on the
32
+ remote is the start, and a local branch that fell behind the remote is
33
+ fast-forwarded. A fast-forward of the base that is not safe leaves the
34
+ local base branch as it is and writes a `base.behind` event. A new branch
35
+ then starts from the remote base.
29
36
  4. Let the implementer agent do the work, then commit and push. When a git
30
37
  hook stops a commit, the agent that made the change gets the hook output,
31
38
  fixes it, and the harness commits again. A `commit:` line in the reply of
@@ -229,9 +236,10 @@ npx -y next-issue@latest init
229
236
  ```
230
237
 
231
238
  The command writes `next-issue.config.json` in the root of the repository and
232
- adds `.next-issue/` to `.gitignore`. It stops when the config file is there
233
- already; `--force` replaces it. The file holds no `setupCommand`, because the
234
- harness deduces one from the lock file of the project.
239
+ adds `.next-issue/` and `.worktrees/` to `.gitignore`. It stops when the
240
+ config file is there already; `--force` replaces it. The file holds no
241
+ `setupCommand`, because the harness deduces one from the lock file of the
242
+ project.
235
243
 
236
244
  With no file, every field keeps its default. A field that is not in the file
237
245
  keeps its default. A file that cannot be read, a file that is not valid
package/dist/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { parseArgs, USAGE } from "./args.mjs";
5
5
  import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
6
  import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
- import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
7
+ import { detectRepo, ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
8
8
  import { defaultBranch } from "./github.mjs";
9
9
  import { listenForInterrupts } from "./interrupt.mjs";
10
10
  import { exitCode, runIssues } from "./loop.mjs";
@@ -58,6 +58,9 @@ async function init(root, force) {
58
58
  if (await ensureIgnored(root, `${STATE_DIR}/`)) {
59
59
  await write(process.stdout, `added ${STATE_DIR}/ to .gitignore\n`);
60
60
  }
61
+ if (await ensureIgnored(root, `${WORKTREE_DIR}/`)) {
62
+ await write(process.stdout, `added ${WORKTREE_DIR}/ to .gitignore\n`);
63
+ }
61
64
  return 0;
62
65
  }
63
66
  async function handle(args, config, repo, recorder) {
@@ -79,16 +82,21 @@ async function handle(args, config, repo, recorder) {
79
82
  base,
80
83
  log: recorder.logFile,
81
84
  }, "quiet");
82
- const issues = await pendingIssues(context);
83
- recorder.event("issues", { open: issues.length }, "quiet");
84
- const { reports, stoppedAt } = await runIssues(args, {
85
- issues,
85
+ const { reports, stoppedAt, error } = await runIssues(args, {
86
+ issues: async () => {
87
+ const issues = await pendingIssues(context);
88
+ recorder.event("issues", { open: issues.length }, "quiet");
89
+ return issues;
90
+ },
86
91
  process: (issue) => processIssue(context, issue),
87
92
  stop: interrupts.afterIssue,
88
93
  });
89
94
  if (stoppedAt !== undefined) {
90
95
  recorder.event("run.stop", { issue: stoppedAt }, "quiet");
91
96
  }
97
+ if (error !== undefined) {
98
+ recorder.event("issues.fail", { error: message(error) }, "quiet");
99
+ }
92
100
  recorder.event("run.end", {
93
101
  done: count(reports, "done"),
94
102
  needsHuman: count(reports, "needs-human"),
@@ -100,7 +108,7 @@ async function handle(args, config, repo, recorder) {
100
108
  const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
101
109
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
102
110
  await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
103
- return exitCode(reports);
111
+ return error === undefined ? exitCode(reports) : 1;
104
112
  }
105
113
  function count(reports, outcome) {
106
114
  return reports.filter((report) => report.outcome === outcome).length;
package/dist/git.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { appendFile, readFile } from "node:fs/promises";
2
- import { basename, dirname, join } from "node:path";
2
+ import { join } from "node:path";
3
3
  import { must, run } from "./exec.mjs";
4
4
  const REMOTE_PATTERNS = [
5
5
  /^git@[^:]+:(?<owner>[^/]+)\/(?<name>[^/]+?)(?:\.git)?$/,
@@ -24,18 +24,19 @@ export async function detectRepo(cwd, remote) {
24
24
  const { owner, name } = parseRemote(url);
25
25
  return { owner, name, root };
26
26
  }
27
+ export const WORKTREE_DIR = ".worktrees";
27
28
  export function worktreePath(repo, issue) {
28
- return join(dirname(repo.root), `${basename(repo.root)}-issue-${issue}`);
29
+ return join(repo.root, WORKTREE_DIR, String(issue));
29
30
  }
30
- export async function addWorktree(repo, issue, base, remote) {
31
- const path = worktreePath(repo, issue);
32
- const branch = branchName(issue);
31
+ export async function addWorktree(repo, issue, branch, base, remote) {
33
32
  const upstream = `${remote}/${branch}`;
34
33
  await must("git", ["fetch", remote], { cwd: repo.root });
35
34
  const baseUpdated = await updateBase(repo, base, remote);
36
35
  await must("git", ["worktree", "prune"], { cwd: repo.root });
37
36
  const pushed = await hasBranch(repo, upstream, "--remotes");
38
- if (!(await hasWorktree(repo, path))) {
37
+ const existing = await findWorktree(repo, branch);
38
+ const path = existing ?? worktreePath(repo, issue);
39
+ if (existing === undefined) {
39
40
  const args = (await hasBranch(repo, branch))
40
41
  ? ["worktree", "add", path, branch]
41
42
  : ["worktree", "add", "-b", branch, path, pushed ? upstream : `${remote}/${base}`];
@@ -58,12 +59,16 @@ export async function updateBase(repo, base, remote) {
58
59
  const result = await run("git", args, { cwd: repo.root });
59
60
  return result.code === 0;
60
61
  }
61
- export async function hasWorktree(repo, path) {
62
+ export async function findWorktree(repo, branch) {
62
63
  const list = await must("git", ["worktree", "list", "--porcelain"], { cwd: repo.root });
63
- return list.split("\n").some((line) => line.trim() === `worktree ${path}`);
64
- }
65
- export async function removeWorktree(repo, issue) {
66
- const result = await run("git", ["worktree", "remove", worktreePath(repo, issue)], { cwd: repo.root });
64
+ const entry = list
65
+ .split("\n\n")
66
+ .map((block) => block.split("\n"))
67
+ .find((lines) => lines.includes(`branch refs/heads/${branch}`));
68
+ return entry?.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
69
+ }
70
+ export async function removeWorktree(repo, path) {
71
+ const result = await run("git", ["worktree", "remove", path], { cwd: repo.root });
67
72
  return result.code === 0;
68
73
  }
69
74
  export async function ensureIgnored(root, entry) {
@@ -83,7 +88,7 @@ export async function ensureIgnored(root, entry) {
83
88
  return true;
84
89
  }
85
90
  export function branchName(issue) {
86
- return `issue-${issue}`;
91
+ return String(issue);
87
92
  }
88
93
  export async function isDirty(cwd) {
89
94
  const status = await must("git", ["status", "--porcelain"], { cwd });
package/dist/loop.mjs CHANGED
@@ -1,9 +1,19 @@
1
1
  export async function runIssues(args, loop) {
2
2
  const reports = [];
3
- for (const issue of loop.issues) {
4
- if (args.max !== undefined && handled(reports) >= args.max) {
3
+ const seen = new Set();
4
+ while (args.max === undefined || handled(reports) < args.max) {
5
+ let issues;
6
+ try {
7
+ issues = await loop.issues();
8
+ }
9
+ catch (error) {
10
+ return { reports, error };
11
+ }
12
+ const issue = issues.find((candidate) => !seen.has(candidate.number));
13
+ if (issue === undefined) {
5
14
  break;
6
15
  }
16
+ seen.add(issue.number);
7
17
  const report = await loop.process(issue);
8
18
  reports.push(report);
9
19
  if (loop.stop() || report.halt === true) {
package/dist/pipeline.mjs CHANGED
@@ -1,7 +1,7 @@
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, resetToBase, revision, worktreePath, } from "./git.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, findWorktree, isDirty, push, removeWorktree, resetToBase, revision, } from "./git.mjs";
5
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";
@@ -20,7 +20,7 @@ export const PORTS = {
20
20
  failedBaseLogs,
21
21
  failedCheckLogs,
22
22
  findPr,
23
- hasWorktree,
23
+ findWorktree,
24
24
  isDirty,
25
25
  issueComments,
26
26
  markPrReady,
@@ -126,7 +126,7 @@ export async function processIssue(context, issue) {
126
126
  catch (failure) {
127
127
  log.event("hand-over.error", { error: message(failure) }, "quiet");
128
128
  }
129
- await cleanUp(context, log, issue.number);
129
+ await cleanUp(context, log, state.branch);
130
130
  return {
131
131
  issue: issue.number,
132
132
  outcome: "error",
@@ -150,7 +150,7 @@ async function work(context, issue, log, state) {
150
150
  await writeState(repo, state);
151
151
  });
152
152
  const comments = await log.step("comments", {}, () => ports.issueComments(repo, issue.number));
153
- const added = await log.step("worktree", { branch }, () => ports.addWorktree(repo, issue.number, context.base, config.remote));
153
+ const added = await log.step("worktree", { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote));
154
154
  const worktree = added.path;
155
155
  if (!added.baseUpdated) {
156
156
  log.event("base.behind", { base: context.base }, "quiet");
@@ -477,7 +477,7 @@ function stop(job) {
477
477
  async function finish(job, outcome, reason) {
478
478
  const { context, issue, log, state, branch } = job;
479
479
  const { repo, ports } = context;
480
- await cleanUp(context, log, issue.number);
480
+ await cleanUp(context, log, branch);
481
481
  if (outcome === "done") {
482
482
  if (!(await ports.deleteBranch(repo, branch))) {
483
483
  log.event("branch.kept", { branch }, "quiet");
@@ -556,13 +556,13 @@ async function commitWork(job, before, subject, role) {
556
556
  }
557
557
  }
558
558
  }
559
- async function cleanUp(context, log, issue) {
560
- const path = worktreePath(context.repo, issue);
559
+ async function cleanUp(context, log, branch) {
561
560
  try {
562
- if (!(await context.ports.hasWorktree(context.repo, path))) {
561
+ const path = await context.ports.findWorktree(context.repo, branch);
562
+ if (path === undefined) {
563
563
  return;
564
564
  }
565
- if (!(await context.ports.removeWorktree(context.repo, issue))) {
565
+ if (!(await context.ports.removeWorktree(context.repo, path))) {
566
566
  log.event("worktree.kept", { path }, "quiet");
567
567
  }
568
568
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",