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 +13 -4
- package/dist/git.mjs +4 -0
- package/dist/github.mjs +41 -4
- package/dist/loop.mjs +1 -1
- package/dist/pipeline.mjs +78 -13
- package/package.json +1 -1
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
|
|
63
|
-
branch
|
|
64
|
-
|
|
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,
|
|
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
|
|
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
|
|
235
|
-
parts.push(
|
|
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
|
|
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
|
|
302
|
-
await
|
|
303
|
-
|
|
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) {
|