next-issue 0.7.0 → 0.8.1
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 +28 -18
- package/dist/cli.mjs +10 -20
- package/dist/exec.mjs +1 -1
- package/dist/git.mjs +22 -10
- package/dist/github.mjs +49 -10
- package/dist/loop.mjs +27 -0
- package/dist/pipeline.mjs +51 -23
- package/dist/prompts.mjs +14 -3
- package/dist/state.mjs +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,22 +24,27 @@ For each open issue, oldest first:
|
|
|
24
24
|
the claim. An issue with saved state under `.next-issue/` resumes instead,
|
|
25
25
|
whatever its labels, unless another person is now the assignee.
|
|
26
26
|
2. Assign the issue to you and add `status:in-progress`.
|
|
27
|
-
3. Fetch the
|
|
28
|
-
safe, then add a git worktree
|
|
27
|
+
3. Fetch the remote, fast-forward the local copy of the default branch when
|
|
28
|
+
that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
|
|
29
|
+
`issue-<n>` branch. A branch that exists on the remote is the start, and a
|
|
30
|
+
local branch that fell behind the remote is fast-forwarded.
|
|
29
31
|
4. Let the implementer agent do the work, then commit and push.
|
|
30
32
|
5. Open a draft pull request that closes the issue.
|
|
31
|
-
6. Wait for the checks
|
|
32
|
-
`checkTimeoutMinutes`. A
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
6. Wait for the checks, once the pull request shows the pushed commit. A
|
|
34
|
+
pending state waits again, up to `checkTimeoutMinutes`. A read that fails
|
|
35
|
+
is tried three times before the run stops. A repository that reports no
|
|
36
|
+
check keeps asking for `checkGraceSeconds`, because a new pull request often
|
|
37
|
+
shows no check yet, and only then goes straight to the review. The later
|
|
38
|
+
gates of the same issue ask once.
|
|
35
39
|
7. On a red build, give the failed job logs to the fixer agent, then go to step
|
|
36
40
|
6 again. The budget is `maxCiFixes`.
|
|
37
41
|
8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
|
|
38
42
|
reviewer returns a structured verdict that rates each finding `blocking` or
|
|
39
43
|
`minor`. The harness puts the result on the pull request.
|
|
40
44
|
9. No blocking finding means approval: mark the pull request ready for review
|
|
41
|
-
and set `status:done`. The merge stays with you.
|
|
42
|
-
|
|
45
|
+
and set `status:done`. The merge stays with you. The worktree, the local
|
|
46
|
+
branch and the state file go away. The remote branch stays. A blocking
|
|
47
|
+
finding always stops the approval, even when the reviewer also says approve.
|
|
43
48
|
10. With a blocking finding, run the fixer agent and go to step 6 again. The
|
|
44
49
|
budget is `maxReviewRounds`.
|
|
45
50
|
|
|
@@ -50,6 +55,10 @@ when:
|
|
|
50
55
|
|
|
51
56
|
- the check budget or the review budget runs out;
|
|
52
57
|
- a fixer round adds no commit, so there is no progress;
|
|
58
|
+
- the fixer reports that the checks fail for a reason the change did not cause,
|
|
59
|
+
such as a test already broken on the base branch or one that fails only under
|
|
60
|
+
load. It changes nothing and the reason reaches the pull request, rather than
|
|
61
|
+
the fixer spending its whole budget on a failure it cannot fix;
|
|
53
62
|
- the reviewer repeats a finding set from an earlier round, which shows a
|
|
54
63
|
ping-pong between the fixer and the reviewer;
|
|
55
64
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
@@ -106,14 +115,14 @@ A short text summary of the same data goes to standard output. Use `--json` to
|
|
|
106
115
|
get the full summary object there instead, so the harness fits in a pipe. All
|
|
107
116
|
progress goes to standard error.
|
|
108
117
|
|
|
109
|
-
Recorded for each step: `claim`, `comments`, `worktree`, `
|
|
110
|
-
`pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
111
|
-
`.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
112
|
-
with its exit code and duration. A `tool` event holds the tool
|
|
113
|
-
`Bash` tool the command too. Each agent call adds a `usage`
|
|
114
|
-
tokens, the cost estimate in dollars, the turn count, the
|
|
115
|
-
model and the session id, so you can open the session
|
|
116
|
-
transcript.
|
|
118
|
+
Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
|
|
119
|
+
`push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
120
|
+
`.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
121
|
+
is recorded with its exit code and duration. A `tool` event holds the tool
|
|
122
|
+
name, and for a `Bash` tool the command too. Each agent call adds a `usage`
|
|
123
|
+
event with the tokens, the cost estimate in dollars, the turn count, the
|
|
124
|
+
tool-call count, the model and the session id, so you can open the session
|
|
125
|
+
again and read the full transcript.
|
|
117
126
|
|
|
118
127
|
```bash
|
|
119
128
|
jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
|
|
@@ -130,7 +139,7 @@ Three levels of console output:
|
|
|
130
139
|
|
|
131
140
|
## Requirements
|
|
132
141
|
|
|
133
|
-
- Node 24 or later
|
|
142
|
+
- Node 24 or later
|
|
134
143
|
- `gh`, authenticated with write access to the repository
|
|
135
144
|
- Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
|
|
136
145
|
Claude subscription login
|
|
@@ -217,7 +226,8 @@ A hand-over leaves the state file with the used budgets in it, so the next run
|
|
|
217
226
|
would hand the issue over again at once. To give the issue another try, take the
|
|
218
227
|
`status:needs-human` label off and run with `--reset`. The flag puts the budgets
|
|
219
228
|
and the findings back to zero and keeps the branch, the pull request and the
|
|
220
|
-
step that the last run reached, so the work carries on where it stopped.
|
|
229
|
+
step that the last run reached, so the work carries on where it stopped. A
|
|
230
|
+
commit that you pushed to the pull request in the meantime is fetched first.
|
|
221
231
|
|
|
222
232
|
## Commands and options
|
|
223
233
|
|
package/dist/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@ 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 { currentLogin, defaultBranch, openIssues } from "./github.mjs";
|
|
9
|
+
import { exitCode, runIssues } from "./loop.mjs";
|
|
9
10
|
import { message, Recorder } from "./observe.mjs";
|
|
10
11
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
11
12
|
import { resetState, STATE_DIR, takeStop } from "./state.mjs";
|
|
@@ -59,25 +60,14 @@ async function handle(args, config, repo, recorder) {
|
|
|
59
60
|
recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
|
|
60
61
|
const issues = await openIssues(repo, config.issueLimit);
|
|
61
62
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
62
|
-
await
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
await resetState(repo, issue.number);
|
|
71
|
-
}
|
|
72
|
-
const report = await processIssue(context, issue);
|
|
73
|
-
reports.push(report);
|
|
74
|
-
if (await takeStop(repo)) {
|
|
75
|
-
recorder.event("run.stop", { issue: issue.number }, "quiet");
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
if (args.once && report.outcome !== "skipped") {
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
63
|
+
const { reports, stoppedAt } = await runIssues(args, {
|
|
64
|
+
issues,
|
|
65
|
+
process: (issue) => processIssue(context, issue),
|
|
66
|
+
reset: (issue) => resetState(repo, issue),
|
|
67
|
+
stop: () => takeStop(repo),
|
|
68
|
+
});
|
|
69
|
+
if (stoppedAt !== undefined) {
|
|
70
|
+
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
81
71
|
}
|
|
82
72
|
const summary = { ...recorder.summary(), issues: reports };
|
|
83
73
|
recorder.event("run.end", {
|
|
@@ -90,7 +80,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
90
80
|
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
91
81
|
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
92
82
|
await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
|
93
|
-
return
|
|
83
|
+
return exitCode(reports);
|
|
94
84
|
}
|
|
95
85
|
function count(reports, outcome) {
|
|
96
86
|
return reports.filter((report) => report.outcome === outcome).length;
|
package/dist/exec.mjs
CHANGED
package/dist/git.mjs
CHANGED
|
@@ -30,21 +30,26 @@ export function worktreePath(repo, issue) {
|
|
|
30
30
|
export async function addWorktree(repo, issue, base, remote) {
|
|
31
31
|
const path = worktreePath(repo, issue);
|
|
32
32
|
const branch = branchName(issue);
|
|
33
|
-
|
|
33
|
+
const upstream = `${remote}/${branch}`;
|
|
34
|
+
await must("git", ["fetch", remote], { cwd: repo.root });
|
|
34
35
|
await updateBase(repo, base, remote);
|
|
35
36
|
await must("git", ["worktree", "prune"], { cwd: repo.root });
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
const pushed = await hasBranch(repo, upstream, "--remotes");
|
|
38
|
+
if (!(await hasWorktree(repo, path))) {
|
|
39
|
+
const args = (await hasBranch(repo, branch))
|
|
40
|
+
? ["worktree", "add", path, branch]
|
|
41
|
+
: ["worktree", "add", "-b", branch, path, pushed ? upstream : `${remote}/${base}`];
|
|
42
|
+
await must("git", args, { cwd: repo.root });
|
|
43
|
+
}
|
|
44
|
+
if (pushed) {
|
|
45
|
+
await run("git", ["merge", "--ff-only", upstream], { cwd: path });
|
|
38
46
|
}
|
|
39
|
-
const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
40
|
-
cwd: repo.root,
|
|
41
|
-
});
|
|
42
|
-
const args = existing.code === 0
|
|
43
|
-
? ["worktree", "add", path, branch]
|
|
44
|
-
: ["worktree", "add", "-b", branch, path, `${remote}/${base}`];
|
|
45
|
-
await must("git", args, { cwd: repo.root });
|
|
46
47
|
return path;
|
|
47
48
|
}
|
|
49
|
+
async function hasBranch(repo, name, ...flags) {
|
|
50
|
+
const list = await must("git", ["branch", "--list", ...flags, name], { cwd: repo.root });
|
|
51
|
+
return list.length > 0;
|
|
52
|
+
}
|
|
48
53
|
export async function updateBase(repo, base, remote) {
|
|
49
54
|
const head = await run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repo.root });
|
|
50
55
|
const args = head.code === 0 && head.stdout.trim() === base
|
|
@@ -98,3 +103,10 @@ export async function commitAll(cwd, message) {
|
|
|
98
103
|
export async function push(cwd, remote, branch) {
|
|
99
104
|
await must("git", ["push", "-u", remote, branch], { cwd });
|
|
100
105
|
}
|
|
106
|
+
export async function revision(cwd) {
|
|
107
|
+
return must("git", ["rev-parse", "HEAD"], { cwd });
|
|
108
|
+
}
|
|
109
|
+
export async function deleteBranch(repo, branch) {
|
|
110
|
+
const result = await run("git", ["branch", "-D", branch], { cwd: repo.root });
|
|
111
|
+
return result.code === 0;
|
|
112
|
+
}
|
package/dist/github.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { must, run } from "./exec.mjs";
|
|
2
2
|
const CHECK_PENDING_CODE = 8;
|
|
3
|
+
const READ_TRIES = 3;
|
|
4
|
+
const KNOWN_LABELS = new Map();
|
|
3
5
|
const NO_CHECKS = /no checks reported/i;
|
|
4
6
|
function slug(repo) {
|
|
5
7
|
return `${repo.owner}/${repo.name}`;
|
|
@@ -22,6 +24,8 @@ export async function openIssues(repo, limit) {
|
|
|
22
24
|
"list",
|
|
23
25
|
"--state",
|
|
24
26
|
"open",
|
|
27
|
+
"--search",
|
|
28
|
+
"sort:created-asc",
|
|
25
29
|
"--limit",
|
|
26
30
|
String(limit),
|
|
27
31
|
"--json",
|
|
@@ -65,7 +69,17 @@ export async function setLabel(repo, kind, number, add, remove) {
|
|
|
65
69
|
await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
|
|
66
70
|
}
|
|
67
71
|
export async function ensureLabel(repo, label) {
|
|
72
|
+
let known = KNOWN_LABELS.get(slug(repo));
|
|
73
|
+
if (known === undefined) {
|
|
74
|
+
const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
|
|
75
|
+
known = new Set(JSON.parse(raw).map((item) => item.name));
|
|
76
|
+
KNOWN_LABELS.set(slug(repo), known);
|
|
77
|
+
}
|
|
78
|
+
if (known.has(label)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
68
81
|
await run("gh", ["label", "create", label, "--repo", slug(repo)], { cwd: repo.root });
|
|
82
|
+
known.add(label);
|
|
69
83
|
}
|
|
70
84
|
export async function findPr(repo, branch) {
|
|
71
85
|
const result = await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--repo", slug(repo)], { cwd: repo.root });
|
|
@@ -95,6 +109,9 @@ export async function createPr(repo, branch, base, title, body, draft) {
|
|
|
95
109
|
}
|
|
96
110
|
return number;
|
|
97
111
|
}
|
|
112
|
+
async function prHead(repo, branch) {
|
|
113
|
+
return gh(repo, ["pr", "view", branch, "--json", "headRefOid", "--jq", ".headRefOid"]);
|
|
114
|
+
}
|
|
98
115
|
async function readChecks(repo, branch) {
|
|
99
116
|
const result = await run("gh", ["pr", "checks", branch, "--json", "name,bucket,link,description", "--repo", slug(repo)], { cwd: repo.root });
|
|
100
117
|
const output = result.stdout.trim();
|
|
@@ -107,8 +124,8 @@ async function readChecks(repo, branch) {
|
|
|
107
124
|
const checks = JSON.parse(output);
|
|
108
125
|
return checks.length === 0 ? "none" : checks;
|
|
109
126
|
}
|
|
110
|
-
async function watchOnce(repo, branch, intervalSeconds, timeoutMs,
|
|
111
|
-
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root,
|
|
127
|
+
async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
|
|
128
|
+
const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, show, timeoutMs });
|
|
112
129
|
if (watch.timedOut) {
|
|
113
130
|
return "timeout";
|
|
114
131
|
}
|
|
@@ -125,31 +142,53 @@ const REAL_CLOCK = {
|
|
|
125
142
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
126
143
|
};
|
|
127
144
|
export async function pollChecks(probe, options, clock = REAL_CLOCK) {
|
|
128
|
-
const
|
|
129
|
-
const deadline = started + options.timeoutMs;
|
|
145
|
+
const deadline = clock.now() + options.timeoutMs;
|
|
130
146
|
const rest = () => Math.max(0, deadline - clock.now());
|
|
147
|
+
let shown;
|
|
148
|
+
let failures = 0;
|
|
131
149
|
for (;;) {
|
|
132
150
|
if (rest() === 0) {
|
|
133
151
|
return "timeout";
|
|
134
152
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
const state = await read(probe, options.head, rest(), () => (shown ??= clock.now()));
|
|
154
|
+
if (state instanceof Error) {
|
|
155
|
+
failures += 1;
|
|
156
|
+
if (failures >= READ_TRIES) {
|
|
157
|
+
throw state;
|
|
138
158
|
}
|
|
139
159
|
}
|
|
140
160
|
else {
|
|
141
|
-
|
|
142
|
-
if (state
|
|
161
|
+
failures = 0;
|
|
162
|
+
if (state === "none" && clock.now() - (shown ?? 0) >= options.graceMs) {
|
|
163
|
+
return "none";
|
|
164
|
+
}
|
|
165
|
+
if (state !== "none" && state !== "behind" && state !== "pending") {
|
|
143
166
|
return state;
|
|
144
167
|
}
|
|
145
168
|
}
|
|
146
169
|
await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
|
|
147
170
|
}
|
|
148
171
|
}
|
|
172
|
+
async function read(probe, head, timeoutMs, shown) {
|
|
173
|
+
try {
|
|
174
|
+
if (head !== undefined && (await probe.head()) !== head) {
|
|
175
|
+
return "behind";
|
|
176
|
+
}
|
|
177
|
+
shown();
|
|
178
|
+
if ((await probe.list()) === "none") {
|
|
179
|
+
return "none";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
184
|
+
}
|
|
185
|
+
return probe.watch(timeoutMs);
|
|
186
|
+
}
|
|
149
187
|
export function waitForChecks(repo, branch, options) {
|
|
150
188
|
return pollChecks({
|
|
189
|
+
head: () => prHead(repo, branch),
|
|
151
190
|
list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
|
|
152
|
-
watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.
|
|
191
|
+
watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.show),
|
|
153
192
|
}, options);
|
|
154
193
|
}
|
|
155
194
|
export async function failedCheckLogs(repo, branch, maxChars) {
|
package/dist/loop.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export async function runIssues(args, loop) {
|
|
2
|
+
await loop.stop();
|
|
3
|
+
const reports = [];
|
|
4
|
+
for (const issue of loop.issues) {
|
|
5
|
+
if (args.max !== undefined && handled(reports) >= args.max) {
|
|
6
|
+
break;
|
|
7
|
+
}
|
|
8
|
+
if (args.reset) {
|
|
9
|
+
await loop.reset(issue.number);
|
|
10
|
+
}
|
|
11
|
+
const report = await loop.process(issue);
|
|
12
|
+
reports.push(report);
|
|
13
|
+
if (await loop.stop()) {
|
|
14
|
+
return { reports, stoppedAt: issue.number };
|
|
15
|
+
}
|
|
16
|
+
if (args.once && report.outcome !== "skipped") {
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return { reports };
|
|
21
|
+
}
|
|
22
|
+
export function exitCode(reports) {
|
|
23
|
+
return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
|
|
24
|
+
}
|
|
25
|
+
function handled(reports) {
|
|
26
|
+
return reports.filter((report) => report.outcome !== "skipped").length;
|
|
27
|
+
}
|
package/dist/pipeline.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { CODING, READ_ONLY, runAgent } from "./agents.mjs";
|
|
2
2
|
import { managedLabels } from "./config.mjs";
|
|
3
3
|
import { run } from "./exec.mjs";
|
|
4
|
-
import { addWorktree, branchName, commitAll, diff, hasWorktree, push, removeWorktree, worktreePath, } from "./git.mjs";
|
|
4
|
+
import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
|
|
5
5
|
import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
|
|
6
6
|
import { message } from "./observe.mjs";
|
|
7
|
-
import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
|
|
8
|
-
import { readState, writeState } from "./state.mjs";
|
|
7
|
+
import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
|
|
8
|
+
import { dropState, readState, writeState } from "./state.mjs";
|
|
9
9
|
import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
|
|
10
10
|
export const PORTS = {
|
|
11
11
|
addWorktree,
|
|
@@ -13,6 +13,7 @@ export const PORTS = {
|
|
|
13
13
|
commentOnPr,
|
|
14
14
|
commitAll,
|
|
15
15
|
createPr,
|
|
16
|
+
deleteBranch,
|
|
16
17
|
diff,
|
|
17
18
|
failedCheckLogs,
|
|
18
19
|
findPr,
|
|
@@ -21,6 +22,7 @@ export const PORTS = {
|
|
|
21
22
|
markPrReady,
|
|
22
23
|
push,
|
|
23
24
|
removeWorktree,
|
|
25
|
+
revision,
|
|
24
26
|
runAgent,
|
|
25
27
|
runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
|
|
26
28
|
setLabel,
|
|
@@ -101,6 +103,12 @@ async function work(context, issue, log, state) {
|
|
|
101
103
|
const escalate = (reason) => handOver(context, log, issue.number, state, reason);
|
|
102
104
|
const finish = async (outcome, reason) => {
|
|
103
105
|
await cleanUp(context, log, issue.number);
|
|
106
|
+
if (outcome === "done") {
|
|
107
|
+
if (!(await ports.deleteBranch(repo, branch))) {
|
|
108
|
+
log.event("branch.kept", { branch }, "quiet");
|
|
109
|
+
}
|
|
110
|
+
await dropState(repo, issue.number);
|
|
111
|
+
}
|
|
104
112
|
return {
|
|
105
113
|
issue: issue.number,
|
|
106
114
|
outcome,
|
|
@@ -123,6 +131,7 @@ async function work(context, issue, log, state) {
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
if (state.phase === "claimed") {
|
|
134
|
+
const before = await ports.revision(worktree);
|
|
126
135
|
const result = await log.step("implement", {}, () => ports.runAgent(log, {
|
|
127
136
|
name: "implementer",
|
|
128
137
|
cwd: worktree,
|
|
@@ -132,11 +141,10 @@ async function work(context, issue, log, state) {
|
|
|
132
141
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
133
142
|
}));
|
|
134
143
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
135
|
-
if (!(await
|
|
144
|
+
if (!(await commitWork(job, before, subject))) {
|
|
136
145
|
await escalate("The implementer produced no change.");
|
|
137
146
|
return finish("needs-human", "no implementation commit");
|
|
138
147
|
}
|
|
139
|
-
log.event("commit", { subject });
|
|
140
148
|
state.phase = "implemented";
|
|
141
149
|
await writeState(repo, state);
|
|
142
150
|
}
|
|
@@ -149,14 +157,18 @@ async function work(context, issue, log, state) {
|
|
|
149
157
|
state.phase = "review";
|
|
150
158
|
await writeState(repo, state);
|
|
151
159
|
log.event("pull-request.ready", { pr: state.pr }, "quiet");
|
|
160
|
+
let noChecks = false;
|
|
152
161
|
const gate = async () => {
|
|
153
|
-
const
|
|
162
|
+
const head = await ports.revision(worktree);
|
|
163
|
+
const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
|
|
164
|
+
head,
|
|
154
165
|
intervalSeconds: config.checkIntervalSeconds,
|
|
155
|
-
graceMs: config.checkGraceSeconds * 1000,
|
|
166
|
+
graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
156
167
|
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
157
|
-
|
|
168
|
+
show: log.level === "verbose",
|
|
158
169
|
}));
|
|
159
170
|
log.event("checks.state", { state: checks });
|
|
171
|
+
noChecks ||= checks === "none";
|
|
160
172
|
if (checks === "timeout") {
|
|
161
173
|
await escalate("The checks did not finish in time.");
|
|
162
174
|
return { done: true, report: await finish("needs-human", "checks timeout") };
|
|
@@ -172,10 +184,16 @@ async function work(context, issue, log, state) {
|
|
|
172
184
|
await writeState(repo, state);
|
|
173
185
|
const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
|
|
174
186
|
log.event("checks.logs", { chars: logs.length });
|
|
175
|
-
const
|
|
176
|
-
if (!committed) {
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
|
|
188
|
+
if (!fix.committed) {
|
|
189
|
+
const note = fix.unrelated;
|
|
190
|
+
await escalate(note === undefined
|
|
191
|
+
? "The fixer added no commit for the failed checks."
|
|
192
|
+
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
193
|
+
return {
|
|
194
|
+
done: true,
|
|
195
|
+
report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
|
|
196
|
+
};
|
|
179
197
|
}
|
|
180
198
|
return { done: false, fixed: true };
|
|
181
199
|
};
|
|
@@ -204,7 +222,7 @@ async function work(context, issue, log, state) {
|
|
|
204
222
|
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
205
223
|
name: "reviewer",
|
|
206
224
|
cwd: worktree,
|
|
207
|
-
prompt: reviewPrompt(issue, comments, patch,
|
|
225
|
+
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
208
226
|
profile: READ_ONLY,
|
|
209
227
|
model: config.models.reviewer,
|
|
210
228
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
@@ -236,8 +254,6 @@ async function work(context, issue, log, state) {
|
|
|
236
254
|
log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
|
|
237
255
|
}
|
|
238
256
|
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
239
|
-
state.phase = "done";
|
|
240
|
-
await writeState(repo, state);
|
|
241
257
|
return finish("done");
|
|
242
258
|
}
|
|
243
259
|
const mark = fingerprint(blocking);
|
|
@@ -248,32 +264,44 @@ async function work(context, issue, log, state) {
|
|
|
248
264
|
const earlier = history(state.reviewLog);
|
|
249
265
|
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(blocking) });
|
|
250
266
|
await writeState(repo, state);
|
|
251
|
-
const
|
|
252
|
-
if (!committed) {
|
|
267
|
+
const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier, "review");
|
|
268
|
+
if (!fix.committed) {
|
|
253
269
|
await escalate("The fixer added no commit for the review findings.");
|
|
254
270
|
return finish("needs-human", "no fix commit");
|
|
255
271
|
}
|
|
256
272
|
}
|
|
257
273
|
}
|
|
258
|
-
async function fixRound(job, reason, detail, earlier) {
|
|
274
|
+
async function fixRound(job, reason, detail, earlier, kind) {
|
|
259
275
|
const { context, issue, log, worktree, branch } = job;
|
|
260
276
|
const { ports } = context;
|
|
277
|
+
const before = await ports.revision(worktree);
|
|
261
278
|
const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
|
|
262
279
|
name: "fixer",
|
|
263
280
|
cwd: worktree,
|
|
264
|
-
prompt: fixPrompt(issue, reason, detail, earlier),
|
|
281
|
+
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
265
282
|
profile: CODING,
|
|
266
283
|
model: context.config.models.fixer,
|
|
267
284
|
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
|
268
285
|
}));
|
|
286
|
+
const unrelated = parseUnrelated(result.text);
|
|
287
|
+
if (unrelated !== undefined) {
|
|
288
|
+
log.event("fix.unrelated", { reason, note: unrelated }, "quiet");
|
|
289
|
+
return { committed: false, unrelated };
|
|
290
|
+
}
|
|
269
291
|
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
270
|
-
if (!(await
|
|
292
|
+
if (!(await commitWork(job, before, subject))) {
|
|
271
293
|
log.event("commit.empty", { reason }, "quiet");
|
|
272
|
-
return false;
|
|
294
|
+
return { committed: false };
|
|
273
295
|
}
|
|
274
|
-
log.event("commit", { subject });
|
|
275
296
|
await ports.push(worktree, context.config.remote, branch);
|
|
276
|
-
return true;
|
|
297
|
+
return { committed: true };
|
|
298
|
+
}
|
|
299
|
+
async function commitWork(job, before, subject) {
|
|
300
|
+
const { context, issue, log, worktree } = job;
|
|
301
|
+
if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
302
|
+
log.event("commit", { subject });
|
|
303
|
+
}
|
|
304
|
+
return (await context.ports.revision(worktree)) !== before;
|
|
277
305
|
}
|
|
278
306
|
async function cleanUp(context, log, issue) {
|
|
279
307
|
const path = worktreePath(context.repo, issue);
|
package/dist/prompts.mjs
CHANGED
|
@@ -26,8 +26,8 @@ export function capDiff(diff, maxChars) {
|
|
|
26
26
|
? diff
|
|
27
27
|
: `${diff.slice(0, maxChars)}\n... The diff is cut here, after ${maxChars} characters.`;
|
|
28
28
|
}
|
|
29
|
-
export function reviewPrompt(issue, comments, diff,
|
|
30
|
-
const scope =
|
|
29
|
+
export function reviewPrompt(issue, comments, diff, earlier) {
|
|
30
|
+
const scope = earlier.length === 0
|
|
31
31
|
? "Check correctness, scope, repository conventions and test cover."
|
|
32
32
|
: [
|
|
33
33
|
"This is a later round. Judge two things only:",
|
|
@@ -49,7 +49,12 @@ export function reviewPrompt(issue, comments, diff, round, earlier) {
|
|
|
49
49
|
"Give the result as the structured output: the verdict, a short summary and one entry per finding.",
|
|
50
50
|
].join("\n\n");
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
const UNRELATED_RULE = [
|
|
53
|
+
"The checks can fail for a reason this change did not cause: a test that is already broken on the base branch, one that exercises code the diff does not touch, or one that fails only under load.",
|
|
54
|
+
"Establish that before you fix anything. If the failure is not yours, change nothing and end your final message with one line in the form `unrelated: <one sentence on what fails and why it is not this change>`.",
|
|
55
|
+
"A person then reads it. Do not weaken, skip or re-record a test to make the checks pass.",
|
|
56
|
+
].join("\n");
|
|
57
|
+
export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
53
58
|
return [
|
|
54
59
|
`You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
|
|
55
60
|
`## Reason\n${reason}`,
|
|
@@ -63,11 +68,17 @@ export function fixPrompt(issue, reason, detail, earlier) {
|
|
|
63
68
|
: "",
|
|
64
69
|
"## Rules",
|
|
65
70
|
"Fix the cause, not the symptom. Keep the change minimal.",
|
|
71
|
+
kind === "checks" ? UNRELATED_RULE : "",
|
|
66
72
|
COMMIT_RULES,
|
|
67
73
|
]
|
|
68
74
|
.filter((part) => part.length > 0)
|
|
69
75
|
.join("\n\n");
|
|
70
76
|
}
|
|
77
|
+
export function parseUnrelated(text) {
|
|
78
|
+
const match = /^unrelated:\s*(.+)$/im.exec(text);
|
|
79
|
+
const note = match?.[1]?.trim();
|
|
80
|
+
return note !== undefined && note.length > 0 ? note : undefined;
|
|
81
|
+
}
|
|
71
82
|
export function parseCommitSubject(text, fallback) {
|
|
72
83
|
const match = /^commit:\s*(.+)$/im.exec(text);
|
|
73
84
|
const subject = match?.[1]?.trim();
|
package/dist/state.mjs
CHANGED