next-issue 0.17.1 → 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 +29 -13
- package/dist/agents.mjs +14 -0
- package/dist/cli.mjs +14 -3
- package/dist/exec.mjs +7 -6
- package/dist/git.mjs +4 -0
- package/dist/github.mjs +41 -4
- package/dist/interrupt.mjs +22 -0
- package/dist/loop.mjs +1 -2
- package/dist/pipeline.mjs +99 -13
- package/dist/state.mjs +0 -9
- package/dist/summary.mjs +1 -1
- 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,
|
|
@@ -113,17 +122,24 @@ not the author, tells them apart. The agents read the comments of the issue, not
|
|
|
113
122
|
of the pull request, so a comment of an agent never returns to a prompt: the
|
|
114
123
|
dispute of the fixer reaches the reviewer through the state file.
|
|
115
124
|
|
|
116
|
-
## Stop
|
|
125
|
+
## Stop a run
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
only between two issues, finishes the issue that it holds, deletes the file and
|
|
120
|
-
then writes the summary as usual. A file from an earlier run is dropped at the
|
|
121
|
-
start, so it stops nothing.
|
|
127
|
+
Press `Ctrl-C` to stop the run step by step:
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
1. The first press stops the run after the current issue. The harness finishes
|
|
130
|
+
the issue that it holds and then writes the summary as usual.
|
|
131
|
+
2. The second press stops the run after the current round: the implementation,
|
|
132
|
+
one checks round (wait, then fix) or one review round (review, then fix). The
|
|
133
|
+
issue ends as `stopped`. Its state, worktree and labels stay, so the next run
|
|
134
|
+
carries on from that point.
|
|
135
|
+
3. The third press kills the command that runs and every child of it, so the run
|
|
136
|
+
dies in the middle of a step. The saved state lets the next run continue the
|
|
137
|
+
issue, but the issue keeps `status:in-progress` and the run writes no
|
|
138
|
+
summary.
|
|
139
|
+
|
|
140
|
+
Each of the first two presses prints a line that says what happens next. The
|
|
141
|
+
agents and commands run in a process group of their own, so a press reaches only
|
|
142
|
+
the harness.
|
|
127
143
|
|
|
128
144
|
A status label that the harness puts on the issue goes on the pull request too,
|
|
129
145
|
but only if the pull request exists at that moment. The `status:in-progress`
|
|
@@ -342,7 +358,7 @@ that a label still holds back stays as it was.
|
|
|
342
358
|
| `--once` | Stop after the first handled issue |
|
|
343
359
|
| `--max <n>` | Handle at most n issues |
|
|
344
360
|
| `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
|
|
345
|
-
| `--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 |
|
|
346
362
|
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
347
363
|
| `--json` | Print the machine summary instead of the text one |
|
|
348
364
|
| `--verbose` | Show every command and all agent output |
|
package/dist/agents.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { track } from "./exec.mjs";
|
|
2
4
|
import { emptyUsage } from "./observe.mjs";
|
|
3
5
|
import { runPi } from "./pi.mjs";
|
|
4
6
|
const REVIEWING = {
|
|
@@ -45,6 +47,7 @@ export async function runAgent(recorder, request) {
|
|
|
45
47
|
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
46
48
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
47
49
|
abortController: controller,
|
|
50
|
+
spawnClaudeCodeProcess: spawnClaude,
|
|
48
51
|
outputFormat: request.outputSchema === undefined
|
|
49
52
|
? undefined
|
|
50
53
|
: { type: "json_schema", schema: request.outputSchema },
|
|
@@ -149,3 +152,14 @@ export function toolFields(name, input) {
|
|
|
149
152
|
const { command } = input;
|
|
150
153
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
151
154
|
}
|
|
155
|
+
export function spawnClaude(options) {
|
|
156
|
+
const child = spawn(options.command, options.args, {
|
|
157
|
+
cwd: options.cwd,
|
|
158
|
+
env: options.env,
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
detached: true,
|
|
161
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
162
|
+
});
|
|
163
|
+
track(child);
|
|
164
|
+
return child;
|
|
165
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -6,10 +6,11 @@ import { loadConfig, writeDefaultConfig } from "./config.mjs";
|
|
|
6
6
|
import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
|
|
8
8
|
import { defaultBranch, openIssues } from "./github.mjs";
|
|
9
|
+
import { listenForInterrupts } from "./interrupt.mjs";
|
|
9
10
|
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
11
|
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
11
12
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
12
|
-
import { STATE_DIR
|
|
13
|
+
import { STATE_DIR } from "./state.mjs";
|
|
13
14
|
import { formatSummary } from "./summary.mjs";
|
|
14
15
|
import { packageVersion } from "./version.mjs";
|
|
15
16
|
function write(stream, text) {
|
|
@@ -61,8 +62,18 @@ async function init(root, force) {
|
|
|
61
62
|
return 0;
|
|
62
63
|
}
|
|
63
64
|
async function handle(args, config, repo, recorder) {
|
|
65
|
+
const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
|
|
64
66
|
const base = await defaultBranch(repo);
|
|
65
|
-
const context = {
|
|
67
|
+
const context = {
|
|
68
|
+
repo,
|
|
69
|
+
config,
|
|
70
|
+
base,
|
|
71
|
+
reset: args.reset,
|
|
72
|
+
merge: args.merge,
|
|
73
|
+
stopAfterRound: interrupts.afterRound,
|
|
74
|
+
recorder,
|
|
75
|
+
ports: PORTS,
|
|
76
|
+
};
|
|
66
77
|
recorder.event("run.start", {
|
|
67
78
|
version: await packageVersion(),
|
|
68
79
|
repo: `${repo.owner}/${repo.name}`,
|
|
@@ -74,7 +85,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
74
85
|
const { reports, stoppedAt } = await runIssues(args, {
|
|
75
86
|
issues,
|
|
76
87
|
process: (issue) => processIssue(context, issue),
|
|
77
|
-
stop:
|
|
88
|
+
stop: interrupts.afterIssue,
|
|
78
89
|
});
|
|
79
90
|
if (stoppedAt !== undefined) {
|
|
80
91
|
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
package/dist/exec.mjs
CHANGED
|
@@ -3,7 +3,7 @@ let observer;
|
|
|
3
3
|
let defaultTimeoutMs = 10 * 60_000;
|
|
4
4
|
const STDERR_TAIL = 200_000;
|
|
5
5
|
const groups = new Set();
|
|
6
|
-
function
|
|
6
|
+
export function stopChildren(signal) {
|
|
7
7
|
for (const pid of groups) {
|
|
8
8
|
try {
|
|
9
9
|
process.kill(-pid, signal);
|
|
@@ -11,18 +11,15 @@ function forward(signal) {
|
|
|
11
11
|
catch { }
|
|
12
12
|
}
|
|
13
13
|
groups.clear();
|
|
14
|
-
process.off("SIGINT", onInterrupt);
|
|
15
14
|
process.off("SIGTERM", onTerminate);
|
|
16
15
|
process.kill(process.pid, signal);
|
|
17
16
|
}
|
|
18
|
-
const
|
|
19
|
-
const onTerminate = () => forward("SIGTERM");
|
|
17
|
+
const onTerminate = () => stopChildren("SIGTERM");
|
|
20
18
|
function watch(pid) {
|
|
21
19
|
if (pid === undefined) {
|
|
22
20
|
return;
|
|
23
21
|
}
|
|
24
22
|
if (groups.size === 0) {
|
|
25
|
-
process.on("SIGINT", onInterrupt);
|
|
26
23
|
process.on("SIGTERM", onTerminate);
|
|
27
24
|
}
|
|
28
25
|
groups.add(pid);
|
|
@@ -33,10 +30,14 @@ function unwatch(pid) {
|
|
|
33
30
|
}
|
|
34
31
|
groups.delete(pid);
|
|
35
32
|
if (groups.size === 0) {
|
|
36
|
-
process.off("SIGINT", onInterrupt);
|
|
37
33
|
process.off("SIGTERM", onTerminate);
|
|
38
34
|
}
|
|
39
35
|
}
|
|
36
|
+
export function track(child) {
|
|
37
|
+
watch(child.pid);
|
|
38
|
+
child.on("close", () => unwatch(child.pid));
|
|
39
|
+
child.on("error", () => unwatch(child.pid));
|
|
40
|
+
}
|
|
40
41
|
export function setCommandObserver(next) {
|
|
41
42
|
observer = next;
|
|
42
43
|
}
|
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
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { stopChildren } from "./exec.mjs";
|
|
2
|
+
export function listenForInterrupts(say) {
|
|
3
|
+
let presses = 0;
|
|
4
|
+
const press = () => {
|
|
5
|
+
presses += 1;
|
|
6
|
+
if (presses === 1) {
|
|
7
|
+
say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
|
|
8
|
+
}
|
|
9
|
+
else if (presses === 2) {
|
|
10
|
+
say("Stopping after this round. Press Ctrl-C again to stop now.");
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
process.off("SIGINT", press);
|
|
14
|
+
stopChildren("SIGINT");
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
process.on("SIGINT", press);
|
|
18
|
+
return {
|
|
19
|
+
afterIssue: () => presses >= 1,
|
|
20
|
+
afterRound: () => presses >= 2,
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/loop.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export async function runIssues(args, loop) {
|
|
2
|
-
await loop.stop();
|
|
3
2
|
const reports = [];
|
|
4
3
|
for (const issue of loop.issues) {
|
|
5
4
|
if (args.max !== undefined && handled(reports) >= args.max) {
|
|
@@ -7,7 +6,7 @@ export async function runIssues(args, loop) {
|
|
|
7
6
|
}
|
|
8
7
|
const report = await loop.process(issue);
|
|
9
8
|
reports.push(report);
|
|
10
|
-
if (
|
|
9
|
+
if (loop.stop() || report.halt === true) {
|
|
11
10
|
return { reports, stoppedAt: issue.number };
|
|
12
11
|
}
|
|
13
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) {
|
|
@@ -167,17 +173,16 @@ async function work(context, issue, log, state) {
|
|
|
167
173
|
state.phase = "implemented";
|
|
168
174
|
await writeState(repo, state);
|
|
169
175
|
}
|
|
176
|
+
if (context.stopAfterRound()) {
|
|
177
|
+
return stop(job);
|
|
178
|
+
}
|
|
170
179
|
await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
|
|
171
|
-
const pr = await log.step("pull-request", { branch }, async () => {
|
|
172
|
-
const existing = await ports.findPr(repo, branch);
|
|
173
|
-
return (existing ??
|
|
174
|
-
ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
175
|
-
});
|
|
176
|
-
state.pr = pr;
|
|
177
180
|
state.phase = "review";
|
|
178
|
-
await
|
|
179
|
-
log.event("pull-request.ready", { pr }, "quiet");
|
|
181
|
+
await openPr(job);
|
|
180
182
|
for (;;) {
|
|
183
|
+
if (context.stopAfterRound()) {
|
|
184
|
+
return stop(job);
|
|
185
|
+
}
|
|
181
186
|
const gate = await checkGate(job);
|
|
182
187
|
if (gate.done) {
|
|
183
188
|
return gate.report;
|
|
@@ -185,12 +190,27 @@ async function work(context, issue, log, state) {
|
|
|
185
190
|
if (gate.fixed) {
|
|
186
191
|
continue;
|
|
187
192
|
}
|
|
193
|
+
if (context.stopAfterRound()) {
|
|
194
|
+
return stop(job);
|
|
195
|
+
}
|
|
188
196
|
const round = await reviewRound(job);
|
|
189
197
|
if (round.done) {
|
|
190
198
|
return round.report;
|
|
191
199
|
}
|
|
192
200
|
}
|
|
193
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
|
+
}
|
|
194
214
|
async function checkGate(job) {
|
|
195
215
|
const { context, log, state, branch, worktree } = job;
|
|
196
216
|
const { repo, config, ports } = context;
|
|
@@ -289,9 +309,18 @@ async function reviewRound(job) {
|
|
|
289
309
|
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
290
310
|
log.event("pull-request.undraft.fail", { pr }, "quiet");
|
|
291
311
|
}
|
|
292
|
-
if (context.merge
|
|
293
|
-
await
|
|
294
|
-
|
|
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
|
+
}
|
|
295
324
|
}
|
|
296
325
|
await setStatus(context, issue.number, pr, config.labels.done);
|
|
297
326
|
return { done: true, report: await finish(job, "done") };
|
|
@@ -321,9 +350,65 @@ async function reviewRound(job) {
|
|
|
321
350
|
}
|
|
322
351
|
return { done: false };
|
|
323
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
|
+
}
|
|
324
397
|
function escalate(job, reason) {
|
|
325
398
|
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
326
399
|
}
|
|
400
|
+
function stop(job) {
|
|
401
|
+
const { issue, log, state } = job;
|
|
402
|
+
log.event("issue.stop", { phase: state.phase }, "quiet");
|
|
403
|
+
return {
|
|
404
|
+
issue: issue.number,
|
|
405
|
+
outcome: "stopped",
|
|
406
|
+
pr: state.pr,
|
|
407
|
+
ciFixes: state.ciFixes,
|
|
408
|
+
reviewRounds: state.reviewRounds,
|
|
409
|
+
ms: Date.now() - job.started,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
327
412
|
async function finish(job, outcome, reason) {
|
|
328
413
|
const { context, issue, log, state, branch } = job;
|
|
329
414
|
const { repo, ports } = context;
|
|
@@ -342,6 +427,7 @@ async function finish(job, outcome, reason) {
|
|
|
342
427
|
ciFixes: state.ciFixes,
|
|
343
428
|
reviewRounds: state.reviewRounds,
|
|
344
429
|
ms: Date.now() - job.started,
|
|
430
|
+
...(job.mainBroken && outcome !== "done" ? { halt: true } : {}),
|
|
345
431
|
};
|
|
346
432
|
}
|
|
347
433
|
async function fixRound(job, reason, detail, earlier, kind) {
|
package/dist/state.mjs
CHANGED
|
@@ -22,15 +22,6 @@ export async function writeState(repo, state) {
|
|
|
22
22
|
await mkdir(dir(repo), { recursive: true });
|
|
23
23
|
await writeFile(file(repo, state.issue), `${JSON.stringify(state, null, 2)}\n`);
|
|
24
24
|
}
|
|
25
|
-
export async function takeStop(repo) {
|
|
26
|
-
try {
|
|
27
|
-
await rm(join(dir(repo), "stop"));
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
25
|
export async function dropState(repo, issue) {
|
|
35
26
|
await rm(file(repo, issue), { force: true });
|
|
36
27
|
}
|
package/dist/summary.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const OUTCOMES = ["done", "needs-human", "error", "skipped"];
|
|
1
|
+
const OUTCOMES = ["done", "needs-human", "error", "skipped", "stopped"];
|
|
2
2
|
export function formatSummary(summary) {
|
|
3
3
|
const tally = OUTCOMES.map((outcome) => `${summary.issues.filter((report) => report.outcome === outcome).length} ${outcome}`).join(", ");
|
|
4
4
|
const lines = [`run ${summary.runId}`, `issues ${summary.issues.length}: ${tally}`];
|