next-issue 0.12.5 → 0.14.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 +43 -23
- package/dist/cli.mjs +3 -4
- package/dist/config.mjs +3 -3
- package/dist/github.mjs +3 -10
- package/dist/pipeline.mjs +181 -140
- package/dist/prompts.mjs +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,13 +18,12 @@ next-issue
|
|
|
18
18
|
|
|
19
19
|
For each open issue, oldest first:
|
|
20
20
|
|
|
21
|
-
1. Claim the issue only when it holds the `status:todo` label
|
|
22
|
-
`status:in-progress` or `status:in-review` label
|
|
23
|
-
`status:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
2. Assign the issue to you and add `status:in-progress`.
|
|
21
|
+
1. Claim the issue only when it holds the `status:todo` label and holds no
|
|
22
|
+
`status:in-progress` or `status:in-review` label. A `status:done`,
|
|
23
|
+
`status:needs-human` or `status:blocked` label always stops the claim, a
|
|
24
|
+
resume too. An issue with saved state under `.next-issue/` resumes instead,
|
|
25
|
+
whatever its other labels.
|
|
26
|
+
2. Add `status:in-progress` to the issue.
|
|
28
27
|
3. Fetch the remote, fast-forward the local copy of the default branch when
|
|
29
28
|
that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
|
|
30
29
|
`issue-<n>` branch. A branch that exists on the remote is the start, and a
|
|
@@ -61,7 +60,12 @@ For each open issue, oldest first:
|
|
|
61
60
|
reviewer leaves out of the findings thus gets an approval: only the findings
|
|
62
61
|
count.
|
|
63
62
|
11. With an open finding, run the fixer agent and go to step 6 again. The
|
|
64
|
-
budget is `maxReviewRounds`.
|
|
63
|
+
budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
|
|
64
|
+
changes nothing and gives its reason instead. The reason goes on the pull
|
|
65
|
+
request and into the next round, where it answers every finding of its own
|
|
66
|
+
round, so the reviewer withdraws them all or repeats them all. With only
|
|
67
|
+
some findings wrong, the fixer fixes the correct ones, and the next round
|
|
68
|
+
holds the rest.
|
|
65
69
|
|
|
66
70
|
## Stops for a loop
|
|
67
71
|
|
|
@@ -69,20 +73,35 @@ The harness hands the issue to a person, with the `status:needs-human` label,
|
|
|
69
73
|
when:
|
|
70
74
|
|
|
71
75
|
- the check budget or the review budget runs out;
|
|
72
|
-
- a fixer round adds no commit, so there is no progress;
|
|
76
|
+
- a fixer round adds no commit and gives no reason, so there is no progress;
|
|
73
77
|
- the fixer reports that the checks fail for a reason the change did not cause,
|
|
74
78
|
such as a test already broken on the base branch or one that fails only under
|
|
75
79
|
load. It changes nothing and the reason reaches the pull request, rather than
|
|
76
80
|
the fixer spending its whole budget on a failure it cannot fix;
|
|
81
|
+
- a fixer disputes the findings and leaves a change behind, either in the
|
|
82
|
+
worktree or in a commit of its own. A dispute carries a reason, not a diff,
|
|
83
|
+
so the harness throws none of it away: an unclean worktree stays on the
|
|
84
|
+
disk, and a commit of the fixer stays on the local branch. Neither is
|
|
85
|
+
pushed, so the pull request shows no such change. It holds the reason of the
|
|
86
|
+
dispute, and the change is on the machine that ran the harness;
|
|
77
87
|
- the reviewer repeats a finding set from an earlier round, which shows a
|
|
78
|
-
ping-pong between the fixer and the reviewer
|
|
88
|
+
ping-pong between the fixer and the reviewer, or a finding that the fixer
|
|
89
|
+
disputed and the reviewer holds to. The person then decides;
|
|
79
90
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
80
91
|
- an agent does not finish in `agentTimeoutMinutes`;
|
|
81
92
|
- the setup command fails, or does not finish in `setupTimeoutMinutes`;
|
|
82
93
|
- a step of the run fails with an error.
|
|
83
94
|
|
|
84
|
-
The fixer sees the findings of all earlier rounds, not only the last one
|
|
85
|
-
round two, the reviewer judges only the
|
|
95
|
+
The fixer sees the findings of all earlier rounds, not only the last one, with
|
|
96
|
+
its own disputes next to them. From round two, the reviewer judges only the
|
|
97
|
+
earlier findings and any regression.
|
|
98
|
+
|
|
99
|
+
Each agent that writes to the pull request names itself in the first line of the
|
|
100
|
+
comment: `### Review: approved`, `### Review: changes requested` and
|
|
101
|
+
`### Fixer: no change`. The account is the same for all of them, so the heading,
|
|
102
|
+
not the author, tells them apart. The agents read the comments of the issue, not
|
|
103
|
+
of the pull request, so a comment of an agent never returns to a prompt: the
|
|
104
|
+
dispute of the fixer reaches the reviewer through the state file.
|
|
86
105
|
|
|
87
106
|
## Stop after the current issue
|
|
88
107
|
|
|
@@ -96,10 +115,11 @@ and to every child of it, so the run dies in the middle of a step. The saved
|
|
|
96
115
|
state lets the next run continue the issue, but the issue keeps
|
|
97
116
|
`status:in-progress` and the run writes no summary.
|
|
98
117
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
that
|
|
118
|
+
A status label that the harness puts on the issue goes on the pull request too,
|
|
119
|
+
but only if the pull request exists at that moment. The `status:in-progress`
|
|
120
|
+
label of a fresh issue comes before the pull request, and stays on the issue
|
|
121
|
+
alone. Thus a draft pull request shows work that the harness has not finished,
|
|
122
|
+
and a `status:needs-human` pull request shows work that waits for a person.
|
|
103
123
|
|
|
104
124
|
The harness runs all git and `gh` commands itself. The commit holds all the
|
|
105
125
|
changes in the worktree, so a setup command that writes a file outside
|
|
@@ -188,8 +208,8 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
188
208
|
{
|
|
189
209
|
"remote": "origin",
|
|
190
210
|
"issueLimit": 100,
|
|
191
|
-
"maxCiFixes":
|
|
192
|
-
"maxReviewRounds":
|
|
211
|
+
"maxCiFixes": 5,
|
|
212
|
+
"maxReviewRounds": 5,
|
|
193
213
|
"checkIntervalSeconds": 15,
|
|
194
214
|
"checkGraceSeconds": 60,
|
|
195
215
|
"checkTimeoutMinutes": 60,
|
|
@@ -220,8 +240,8 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
220
240
|
| --- | --- | --- |
|
|
221
241
|
| `remote` | `origin` | The git remote for the repository and the branches |
|
|
222
242
|
| `issueLimit` | `100` | The maximum number of open issues to read |
|
|
223
|
-
| `maxCiFixes` | `
|
|
224
|
-
| `maxReviewRounds` | `
|
|
243
|
+
| `maxCiFixes` | `5` | The budget for check fixes per issue |
|
|
244
|
+
| `maxReviewRounds` | `5` | The budget for review rounds per issue |
|
|
225
245
|
| `checkIntervalSeconds` | `15` | The time between two check states |
|
|
226
246
|
| `checkGraceSeconds` | `60` | The time to wait for the first check to show |
|
|
227
247
|
| `checkTimeoutMinutes` | `60` | The limit for one check wait |
|
|
@@ -237,10 +257,10 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
237
257
|
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
238
258
|
|
|
239
259
|
An empty `labels.ready` turns the ready requirement off. The harness then
|
|
240
|
-
claims every open issue that no other label
|
|
260
|
+
claims every open issue that no other label holds back.
|
|
241
261
|
|
|
242
|
-
The log gives the reason for a skipped issue as `stop-label`, `not-ready
|
|
243
|
-
`in-flight
|
|
262
|
+
The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
|
|
263
|
+
`in-flight`.
|
|
244
264
|
|
|
245
265
|
A role set to `null`, or with no entry in `models`, uses the default model of
|
|
246
266
|
the SDK.
|
package/dist/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ 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 {
|
|
8
|
+
import { defaultBranch, openIssues } from "./github.mjs";
|
|
9
9
|
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
10
|
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
11
11
|
import { PORTS, processIssue } from "./pipeline.mjs";
|
|
@@ -60,13 +60,12 @@ async function init(root, force) {
|
|
|
60
60
|
return 0;
|
|
61
61
|
}
|
|
62
62
|
async function handle(args, config, repo, recorder) {
|
|
63
|
-
const
|
|
64
|
-
const context = { repo, config, base,
|
|
63
|
+
const base = await defaultBranch(repo);
|
|
64
|
+
const context = { repo, config, base, reset: args.reset, recorder, ports: PORTS };
|
|
65
65
|
recorder.event("run.start", {
|
|
66
66
|
version: await packageVersion(),
|
|
67
67
|
repo: `${repo.owner}/${repo.name}`,
|
|
68
68
|
base,
|
|
69
|
-
login,
|
|
70
69
|
log: recorder.logFile,
|
|
71
70
|
}, "quiet");
|
|
72
71
|
const issues = await openIssues(repo, config.issueLimit);
|
package/dist/config.mjs
CHANGED
|
@@ -4,8 +4,8 @@ import { message } from "./observe.mjs";
|
|
|
4
4
|
const DEFAULTS = {
|
|
5
5
|
remote: "origin",
|
|
6
6
|
issueLimit: 100,
|
|
7
|
-
maxCiFixes:
|
|
8
|
-
maxReviewRounds:
|
|
7
|
+
maxCiFixes: 5,
|
|
8
|
+
maxReviewRounds: 5,
|
|
9
9
|
checkIntervalSeconds: 15,
|
|
10
10
|
checkGraceSeconds: 60,
|
|
11
11
|
checkTimeoutMinutes: 60,
|
|
@@ -83,7 +83,7 @@ export async function loadConfig(root) {
|
|
|
83
83
|
}
|
|
84
84
|
return parseConfig(parsed);
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
function parseConfig(value) {
|
|
87
87
|
const file = record(value, "the file");
|
|
88
88
|
for (const key of Object.keys(file)) {
|
|
89
89
|
if (!FIELDS.includes(key)) {
|
package/dist/github.mjs
CHANGED
|
@@ -15,9 +15,6 @@ export async function defaultBranch(repo) {
|
|
|
15
15
|
});
|
|
16
16
|
return JSON.parse(raw).defaultBranchRef.name;
|
|
17
17
|
}
|
|
18
|
-
export async function currentLogin(repo) {
|
|
19
|
-
return must("gh", ["api", "user", "--jq", ".login"], { cwd: repo.root });
|
|
20
|
-
}
|
|
21
18
|
export async function openIssues(repo, limit) {
|
|
22
19
|
const raw = await gh(repo, [
|
|
23
20
|
"issue",
|
|
@@ -29,7 +26,7 @@ export async function openIssues(repo, limit) {
|
|
|
29
26
|
"--limit",
|
|
30
27
|
String(limit),
|
|
31
28
|
"--json",
|
|
32
|
-
"number,title,body,createdAt,labels
|
|
29
|
+
"number,title,body,createdAt,labels",
|
|
33
30
|
]);
|
|
34
31
|
const parsed = JSON.parse(raw);
|
|
35
32
|
return parsed
|
|
@@ -39,7 +36,6 @@ export async function openIssues(repo, limit) {
|
|
|
39
36
|
body: item.body ?? "",
|
|
40
37
|
createdAt: item.createdAt,
|
|
41
38
|
labels: item.labels.map((label) => label.name),
|
|
42
|
-
assignees: item.assignees.map((assignee) => assignee.login),
|
|
43
39
|
}))
|
|
44
40
|
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
45
41
|
}
|
|
@@ -48,10 +44,7 @@ export async function issueComments(repo, issue) {
|
|
|
48
44
|
const parsed = JSON.parse(raw);
|
|
49
45
|
return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
|
|
50
46
|
}
|
|
51
|
-
|
|
52
|
-
await gh(repo, ["issue", "edit", String(issue), "--add-assignee", login]);
|
|
53
|
-
}
|
|
54
|
-
export async function labelsOf(repo, kind, number) {
|
|
47
|
+
async function labelsOf(repo, kind, number) {
|
|
55
48
|
const raw = await gh(repo, [kind, "view", String(number), "--json", "labels"]);
|
|
56
49
|
const parsed = JSON.parse(raw);
|
|
57
50
|
return parsed.labels.map((label) => label.name);
|
|
@@ -68,7 +61,7 @@ export async function setLabel(repo, kind, number, add, remove) {
|
|
|
68
61
|
const current = await labelsOf(repo, kind, number);
|
|
69
62
|
await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
|
|
70
63
|
}
|
|
71
|
-
|
|
64
|
+
async function ensureLabel(repo, label) {
|
|
72
65
|
let known = KNOWN_LABELS.get(slug(repo));
|
|
73
66
|
if (known === undefined) {
|
|
74
67
|
const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
|
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, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
|
|
5
|
-
import {
|
|
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, setLabel, 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";
|
|
@@ -10,7 +10,6 @@ import { dropState, readState, resetState, 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,
|
|
13
|
-
assignIssue,
|
|
14
13
|
commentOnPr,
|
|
15
14
|
commitAll,
|
|
16
15
|
createPr,
|
|
@@ -21,6 +20,7 @@ export const PORTS = {
|
|
|
21
20
|
failedCheckLogs,
|
|
22
21
|
findPr,
|
|
23
22
|
hasWorktree,
|
|
23
|
+
isDirty,
|
|
24
24
|
issueComments,
|
|
25
25
|
markPrReady,
|
|
26
26
|
push,
|
|
@@ -31,14 +31,14 @@ export const PORTS = {
|
|
|
31
31
|
setLabel,
|
|
32
32
|
waitForChecks,
|
|
33
33
|
};
|
|
34
|
-
export function skipReason(issue, config,
|
|
34
|
+
export function skipReason(issue, config, resuming) {
|
|
35
35
|
const labels = new Set(issue.labels);
|
|
36
36
|
const names = config.labels;
|
|
37
37
|
if (labels.has(names.done) || labels.has(names.needsHuman) || labels.has(names.skip)) {
|
|
38
38
|
return "stop-label";
|
|
39
39
|
}
|
|
40
40
|
if (resuming) {
|
|
41
|
-
return
|
|
41
|
+
return undefined;
|
|
42
42
|
}
|
|
43
43
|
if (names.ready.length > 0 && !labels.has(names.ready)) {
|
|
44
44
|
return "not-ready";
|
|
@@ -46,13 +46,13 @@ export function skipReason(issue, config, login, resuming) {
|
|
|
46
46
|
if (labels.has(names.inProgress) || labels.has(names.inReview)) {
|
|
47
47
|
return "in-flight";
|
|
48
48
|
}
|
|
49
|
-
return
|
|
49
|
+
return undefined;
|
|
50
50
|
}
|
|
51
51
|
export async function processIssue(context, issue) {
|
|
52
52
|
const started = Date.now();
|
|
53
53
|
const log = context.recorder.scope({ issue: issue.number });
|
|
54
54
|
const saved = await readState(context.repo, issue.number);
|
|
55
|
-
const skip = skipReason(issue, context.config,
|
|
55
|
+
const skip = skipReason(issue, context.config, saved !== undefined);
|
|
56
56
|
if (skip !== undefined) {
|
|
57
57
|
log.event("issue.skip", { title: issue.title, reason: skip });
|
|
58
58
|
return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
@@ -97,7 +97,6 @@ async function work(context, issue, log, state) {
|
|
|
97
97
|
const { repo, config, ports } = context;
|
|
98
98
|
const branch = state.branch;
|
|
99
99
|
await log.step("claim", { label: config.labels.inProgress }, async () => {
|
|
100
|
-
await ports.assignIssue(repo, issue.number, context.login);
|
|
101
100
|
await setStatus(context, issue.number, state.pr, config.labels.inProgress);
|
|
102
101
|
await writeState(repo, state);
|
|
103
102
|
});
|
|
@@ -107,25 +106,16 @@ async function work(context, issue, log, state) {
|
|
|
107
106
|
if (!added.baseUpdated) {
|
|
108
107
|
log.event("base.behind", { base: context.base }, "quiet");
|
|
109
108
|
}
|
|
110
|
-
const job = {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
issue: issue.number,
|
|
122
|
-
outcome,
|
|
123
|
-
reason,
|
|
124
|
-
pr: state.pr,
|
|
125
|
-
ciFixes: state.ciFixes,
|
|
126
|
-
reviewRounds: state.reviewRounds,
|
|
127
|
-
ms: Date.now() - started,
|
|
128
|
-
};
|
|
109
|
+
const job = {
|
|
110
|
+
context,
|
|
111
|
+
issue,
|
|
112
|
+
log,
|
|
113
|
+
state,
|
|
114
|
+
branch,
|
|
115
|
+
worktree,
|
|
116
|
+
comments,
|
|
117
|
+
started,
|
|
118
|
+
noChecks: false,
|
|
129
119
|
};
|
|
130
120
|
const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
|
|
131
121
|
if (setupCommand !== undefined) {
|
|
@@ -133,10 +123,10 @@ async function work(context, issue, log, state) {
|
|
|
133
123
|
if (setup.code !== 0) {
|
|
134
124
|
const late = setup.timedOut === true;
|
|
135
125
|
log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
|
|
136
|
-
await escalate(late
|
|
126
|
+
await escalate(job, late
|
|
137
127
|
? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
|
|
138
128
|
: "The setup command failed in the worktree.");
|
|
139
|
-
return finish("needs-human", late ? "setup timeout" : "setup failed");
|
|
129
|
+
return finish(job, "needs-human", late ? "setup timeout" : "setup failed");
|
|
140
130
|
}
|
|
141
131
|
}
|
|
142
132
|
if (state.phase === "claimed") {
|
|
@@ -151,138 +141,183 @@ async function work(context, issue, log, state) {
|
|
|
151
141
|
}));
|
|
152
142
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
153
143
|
if (!(await commitWork(job, before, subject))) {
|
|
154
|
-
await escalate("The implementer produced no change.");
|
|
155
|
-
return finish("needs-human", "no implementation commit");
|
|
144
|
+
await escalate(job, "The implementer produced no change.");
|
|
145
|
+
return finish(job, "needs-human", "no implementation commit");
|
|
156
146
|
}
|
|
157
147
|
state.phase = "implemented";
|
|
158
148
|
await writeState(repo, state);
|
|
159
149
|
}
|
|
160
150
|
await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
|
|
161
|
-
|
|
151
|
+
const pr = await log.step("pull-request", { branch }, async () => {
|
|
162
152
|
const existing = await ports.findPr(repo, branch);
|
|
163
153
|
return (existing ??
|
|
164
154
|
ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
165
155
|
});
|
|
156
|
+
state.pr = pr;
|
|
166
157
|
state.phase = "review";
|
|
167
158
|
await writeState(repo, state);
|
|
168
|
-
log.event("pull-request.ready", { pr
|
|
169
|
-
let noChecks = false;
|
|
170
|
-
const gate = async () => {
|
|
171
|
-
const head = await ports.revision(worktree);
|
|
172
|
-
const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
|
|
173
|
-
head,
|
|
174
|
-
intervalSeconds: config.checkIntervalSeconds,
|
|
175
|
-
graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
176
|
-
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
177
|
-
show: log.level === "verbose",
|
|
178
|
-
}));
|
|
179
|
-
log.event("checks.state", { state: checks });
|
|
180
|
-
noChecks ||= checks === "none";
|
|
181
|
-
if (checks === "timeout") {
|
|
182
|
-
await escalate("The checks did not finish in time.");
|
|
183
|
-
return { done: true, report: await finish("needs-human", "checks timeout") };
|
|
184
|
-
}
|
|
185
|
-
if (checks !== "fail") {
|
|
186
|
-
return { done: false, fixed: false };
|
|
187
|
-
}
|
|
188
|
-
if (state.ciFixes >= config.maxCiFixes) {
|
|
189
|
-
await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
190
|
-
return { done: true, report: await finish("needs-human", "check budget") };
|
|
191
|
-
}
|
|
192
|
-
state.ciFixes += 1;
|
|
193
|
-
await writeState(repo, state);
|
|
194
|
-
const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
|
|
195
|
-
log.event("checks.logs", { chars: logs.length });
|
|
196
|
-
const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
|
|
197
|
-
if (!fix.committed) {
|
|
198
|
-
const note = fix.unrelated;
|
|
199
|
-
await escalate(note === undefined
|
|
200
|
-
? "The fixer added no commit for the failed checks."
|
|
201
|
-
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
202
|
-
return {
|
|
203
|
-
done: true,
|
|
204
|
-
report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return { done: false, fixed: true };
|
|
208
|
-
};
|
|
159
|
+
log.event("pull-request.ready", { pr }, "quiet");
|
|
209
160
|
for (;;) {
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
return
|
|
161
|
+
const gate = await checkGate(job);
|
|
162
|
+
if (gate.done) {
|
|
163
|
+
return gate.report;
|
|
213
164
|
}
|
|
214
|
-
if (
|
|
165
|
+
if (gate.fixed) {
|
|
215
166
|
continue;
|
|
216
167
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
168
|
+
const round = await reviewRound(job);
|
|
169
|
+
if (round.done) {
|
|
170
|
+
return round.report;
|
|
220
171
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function checkGate(job) {
|
|
175
|
+
const { context, log, state, branch, worktree } = job;
|
|
176
|
+
const { repo, config, ports } = context;
|
|
177
|
+
const head = await ports.revision(worktree);
|
|
178
|
+
const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
|
|
179
|
+
head,
|
|
180
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
181
|
+
graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
182
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
183
|
+
show: log.level === "verbose",
|
|
184
|
+
}));
|
|
185
|
+
log.event("checks.state", { state: checks });
|
|
186
|
+
job.noChecks ||= checks === "none";
|
|
187
|
+
if (checks === "timeout") {
|
|
188
|
+
await escalate(job, "The checks did not finish in time.");
|
|
189
|
+
return { done: true, report: await finish(job, "needs-human", "checks timeout") };
|
|
190
|
+
}
|
|
191
|
+
if (checks !== "fail") {
|
|
192
|
+
return { done: false, fixed: false };
|
|
193
|
+
}
|
|
194
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
195
|
+
await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
196
|
+
return { done: true, report: await finish(job, "needs-human", "check budget") };
|
|
197
|
+
}
|
|
198
|
+
state.ciFixes += 1;
|
|
199
|
+
await writeState(repo, state);
|
|
200
|
+
const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
|
|
201
|
+
log.event("checks.logs", { chars: logs.length });
|
|
202
|
+
const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
|
|
203
|
+
if (!fix.committed) {
|
|
204
|
+
const note = fix.unrelated;
|
|
205
|
+
await escalate(job, note === undefined
|
|
206
|
+
? "The fixer added no commit for the failed checks."
|
|
207
|
+
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
208
|
+
return {
|
|
209
|
+
done: true,
|
|
210
|
+
report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
|
|
230
211
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
|
|
212
|
+
}
|
|
213
|
+
return { done: false, fixed: true };
|
|
214
|
+
}
|
|
215
|
+
async function reviewRound(job) {
|
|
216
|
+
const { context, issue, log, state, worktree, comments } = job;
|
|
217
|
+
const { repo, config, ports } = context;
|
|
218
|
+
const pr = state.pr;
|
|
219
|
+
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
220
|
+
await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
|
|
221
|
+
return { done: true, report: await finish(job, "needs-human", "review budget") };
|
|
222
|
+
}
|
|
223
|
+
state.reviewRounds += 1;
|
|
224
|
+
await writeState(repo, state);
|
|
225
|
+
await setStatus(context, issue.number, pr, config.labels.inReview);
|
|
226
|
+
const full = await ports.diff(worktree, context.base, config.remote);
|
|
227
|
+
const patch = capDiff(full, config.diffMaxChars);
|
|
228
|
+
const fields = {
|
|
229
|
+
round: state.reviewRounds,
|
|
230
|
+
diffChars: full.length,
|
|
231
|
+
cut: full.length > config.diffMaxChars,
|
|
232
|
+
};
|
|
233
|
+
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
234
|
+
name: "reviewer",
|
|
235
|
+
cwd: worktree,
|
|
236
|
+
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
237
|
+
profile: REVIEWING,
|
|
238
|
+
model: config.models.reviewer,
|
|
239
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
240
|
+
outputSchema: VERDICT_SCHEMA,
|
|
241
|
+
}));
|
|
242
|
+
if (await ports.discardChanges(worktree)) {
|
|
243
|
+
log.event("review.discard", { round: state.reviewRounds }, "quiet");
|
|
244
|
+
}
|
|
245
|
+
const verdict = readVerdict(review.structured);
|
|
246
|
+
if (verdict === undefined) {
|
|
247
|
+
await escalate(job, "The reviewer gave no verdict.");
|
|
248
|
+
return { done: true, report: await finish(job, "needs-human", "no verdict") };
|
|
249
|
+
}
|
|
250
|
+
const blocking = blockingFindings(verdict);
|
|
251
|
+
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
252
|
+
log.event("verdict", {
|
|
253
|
+
round: state.reviewRounds,
|
|
254
|
+
blocking: blocking.length,
|
|
255
|
+
minor: verdict.findings.length - blocking.length,
|
|
256
|
+
open: open.length,
|
|
257
|
+
summary: verdict.summary,
|
|
258
|
+
}, "quiet");
|
|
259
|
+
await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
|
|
260
|
+
if (open.length === 0) {
|
|
261
|
+
const gate = await checkGate(job);
|
|
262
|
+
if (gate.done) {
|
|
263
|
+
return { done: true, report: gate.report };
|
|
264
|
+
}
|
|
265
|
+
if (gate.fixed) {
|
|
266
|
+
return { done: false };
|
|
242
267
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
await escalate("The reviewer gave no verdict.");
|
|
246
|
-
return finish("needs-human", "no verdict");
|
|
268
|
+
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
269
|
+
log.event("pull-request.undraft.fail", { pr }, "quiet");
|
|
247
270
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
|
|
267
|
-
log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
|
|
268
|
-
}
|
|
269
|
-
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
270
|
-
return finish("done");
|
|
271
|
+
await setStatus(context, issue.number, pr, config.labels.done);
|
|
272
|
+
return { done: true, report: await finish(job, "done") };
|
|
273
|
+
}
|
|
274
|
+
const mark = fingerprint(open);
|
|
275
|
+
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
276
|
+
await escalate(job, "The reviewer repeated findings that an earlier round did not fix.");
|
|
277
|
+
return { done: true, report: await finish(job, "needs-human", "repeated findings") };
|
|
278
|
+
}
|
|
279
|
+
const earlier = history(state.reviewLog);
|
|
280
|
+
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
|
|
281
|
+
await writeState(repo, state);
|
|
282
|
+
const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
|
|
283
|
+
if (!fix.committed) {
|
|
284
|
+
const note = fix.unrelated;
|
|
285
|
+
if (note === undefined) {
|
|
286
|
+
await escalate(job, "The fixer added no commit for the review findings.");
|
|
287
|
+
return { done: true, report: await finish(job, "needs-human", "no fix commit") };
|
|
271
288
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return finish("needs-human", "repeated findings");
|
|
289
|
+
if (fix.stray === true) {
|
|
290
|
+
await escalate(job, `The fixer disputed the findings and left a change behind: ${note}`);
|
|
291
|
+
return { done: true, report: await finish(job, "needs-human", "dispute with changes") };
|
|
276
292
|
}
|
|
277
|
-
|
|
278
|
-
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
|
|
293
|
+
state.reviewLog.at(-1).dispute = note;
|
|
279
294
|
await writeState(repo, state);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
295
|
+
await ports.commentOnPr(repo, pr, `### Fixer: no change\n\n${note}`);
|
|
296
|
+
}
|
|
297
|
+
return { done: false };
|
|
298
|
+
}
|
|
299
|
+
function escalate(job, reason) {
|
|
300
|
+
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
301
|
+
}
|
|
302
|
+
async function finish(job, outcome, reason) {
|
|
303
|
+
const { context, issue, log, state, branch } = job;
|
|
304
|
+
const { repo, ports } = context;
|
|
305
|
+
await cleanUp(context, log, issue.number);
|
|
306
|
+
if (outcome === "done") {
|
|
307
|
+
if (!(await ports.deleteBranch(repo, branch))) {
|
|
308
|
+
log.event("branch.kept", { branch }, "quiet");
|
|
284
309
|
}
|
|
310
|
+
await dropState(repo, issue.number);
|
|
285
311
|
}
|
|
312
|
+
return {
|
|
313
|
+
issue: issue.number,
|
|
314
|
+
outcome,
|
|
315
|
+
reason,
|
|
316
|
+
pr: state.pr,
|
|
317
|
+
ciFixes: state.ciFixes,
|
|
318
|
+
reviewRounds: state.reviewRounds,
|
|
319
|
+
ms: Date.now() - job.started,
|
|
320
|
+
};
|
|
286
321
|
}
|
|
287
322
|
async function fixRound(job, reason, detail, earlier, kind) {
|
|
288
323
|
const { context, issue, log, worktree, branch } = job;
|
|
@@ -298,8 +333,9 @@ async function fixRound(job, reason, detail, earlier, kind) {
|
|
|
298
333
|
}));
|
|
299
334
|
const unrelated = parseUnrelated(result.text);
|
|
300
335
|
if (unrelated !== undefined) {
|
|
301
|
-
|
|
302
|
-
|
|
336
|
+
const stray = (await ports.revision(worktree)) !== before || (await ports.isDirty(worktree));
|
|
337
|
+
log.event("fix.unrelated", { reason, note: unrelated, stray }, "quiet");
|
|
338
|
+
return { committed: false, unrelated, stray };
|
|
303
339
|
}
|
|
304
340
|
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
305
341
|
if (!(await commitWork(job, before, subject))) {
|
|
@@ -346,5 +382,10 @@ async function setStatus(context, issue, pr, label) {
|
|
|
346
382
|
}
|
|
347
383
|
}
|
|
348
384
|
function history(rounds) {
|
|
349
|
-
return rounds.map((round) =>
|
|
385
|
+
return rounds.map((round) => {
|
|
386
|
+
const head = `Round ${round.round}:\n${round.findings}`;
|
|
387
|
+
return round.dispute === undefined
|
|
388
|
+
? head
|
|
389
|
+
: `${head}\nThe fixer disputed every finding of this round: ${round.dispute}`;
|
|
390
|
+
});
|
|
350
391
|
}
|
package/dist/prompts.mjs
CHANGED
|
@@ -33,6 +33,8 @@ export function reviewPrompt(issue, comments, diff, earlier) {
|
|
|
33
33
|
"This is a later round. Judge two things only:",
|
|
34
34
|
"whether the earlier findings below are now fixed, and whether the new commits broke something.",
|
|
35
35
|
"Do not raise a new point of taste. Do not repeat a finding that is fixed.",
|
|
36
|
+
"A `The fixer disputed every finding of this round` line answers all of the findings of that round, not one of them.",
|
|
37
|
+
"Withdraw every finding of that round if the dispute is correct. Repeat them all if the dispute is wrong.",
|
|
36
38
|
`## Earlier findings\n${earlier.join("\n")}`,
|
|
37
39
|
].join("\n");
|
|
38
40
|
return [
|
|
@@ -56,6 +58,12 @@ const UNRELATED_RULE = [
|
|
|
56
58
|
"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>`.",
|
|
57
59
|
"A person then reads it. Do not weaken, skip or re-record a test to make the checks pass.",
|
|
58
60
|
].join("\n");
|
|
61
|
+
const DISPUTE_RULE = [
|
|
62
|
+
"A finding can be wrong: it can miss a convention of this repository, ask for work that the issue does not want, or describe behaviour that the code does not have.",
|
|
63
|
+
"Establish that before you change anything. If every finding is wrong, change nothing and end your final message with one line in the form `unrelated: <one sentence on why the findings are wrong>`.",
|
|
64
|
+
"If only some of the findings are wrong, fix the correct ones and change nothing for the wrong ones. Write no `unrelated` line in that round. The next round holds only the findings that are left, and you can dispute them there.",
|
|
65
|
+
"The reviewer reads that line in the next round. Do not make a change that you hold to be wrong only to end the round.",
|
|
66
|
+
].join("\n");
|
|
59
67
|
export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
60
68
|
return [
|
|
61
69
|
`You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
|
|
@@ -70,7 +78,7 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
|
70
78
|
: "",
|
|
71
79
|
"## Rules",
|
|
72
80
|
"Fix the cause, not the symptom. Keep the change minimal.",
|
|
73
|
-
kind === "checks" ? UNRELATED_RULE :
|
|
81
|
+
kind === "checks" ? UNRELATED_RULE : DISPUTE_RULE,
|
|
74
82
|
COMMIT_RULES,
|
|
75
83
|
]
|
|
76
84
|
.filter((part) => part.length > 0)
|