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 +17 -9
- package/dist/cli.mjs +14 -6
- package/dist/git.mjs +17 -12
- package/dist/loop.mjs +12 -2
- package/dist/pipeline.mjs +9 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,11 @@ npx -y next-issue@latest
|
|
|
9
9
|
|
|
10
10
|
## What it does
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
233
|
-
already; `--force` replaces it. The file holds no
|
|
234
|
-
harness deduces one from the lock file of the
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
62
|
+
export async function findWorktree(repo, branch) {
|
|
62
63
|
const list = await must("git", ["worktree", "list", "--porcelain"], { cwd: repo.root });
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
560
|
-
const path = worktreePath(context.repo, issue);
|
|
559
|
+
async function cleanUp(context, log, branch) {
|
|
561
560
|
try {
|
|
562
|
-
|
|
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,
|
|
565
|
+
if (!(await context.ports.removeWorktree(context.repo, path))) {
|
|
566
566
|
log.event("worktree.kept", { path }, "quiet");
|
|
567
567
|
}
|
|
568
568
|
}
|