next-issue 1.0.2 → 1.0.4
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 +9 -3
- package/dist/cli.mjs +3 -3
- package/dist/github.mjs +21 -15
- package/dist/pipeline.mjs +69 -17
- package/dist/state.mjs +17 -1
- package/package.json +1 -1
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.
|
|
70
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
+
ISSUE_FIELDS,
|
|
49
59
|
]);
|
|
50
60
|
const parsed = JSON.parse(raw);
|
|
51
|
-
return parsed
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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"]);
|
|
@@ -111,11 +117,8 @@ async function ensureLabel(repo, label) {
|
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
119
|
export async function findPr(repo, branch) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
return undefined;
|
|
117
|
-
}
|
|
118
|
-
const parsed = JSON.parse(result.stdout);
|
|
120
|
+
const raw = await gh(repo, ["pr", "list", "--head", branch, "--state", "open", "--json", "number"]);
|
|
121
|
+
const parsed = JSON.parse(raw);
|
|
119
122
|
return parsed[0]?.number;
|
|
120
123
|
}
|
|
121
124
|
export async function createPr(repo, branch, base, title, body, draft) {
|
|
@@ -297,6 +300,9 @@ export async function mergePr(repo, pr) {
|
|
|
297
300
|
const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
|
|
298
301
|
return result.code === 0;
|
|
299
302
|
}
|
|
303
|
+
export async function prMerged(repo, pr) {
|
|
304
|
+
return (await gh(repo, ["pr", "view", String(pr), "--json", "state", "--jq", ".state"])).trim() === "MERGED";
|
|
305
|
+
}
|
|
300
306
|
export async function mergeCommit(repo, pr, options, clock = REAL_CLOCK) {
|
|
301
307
|
const deadline = clock.now() + options.timeoutMs;
|
|
302
308
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|