next-issue 0.12.5 → 0.13.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 +18 -18
- package/dist/cli.mjs +3 -4
- package/dist/config.mjs +3 -3
- package/dist/github.mjs +3 -10
- package/dist/pipeline.mjs +162 -138
- 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
|
|
@@ -96,10 +95,11 @@ and to every child of it, so the run dies in the middle of a step. The saved
|
|
|
96
95
|
state lets the next run continue the issue, but the issue keeps
|
|
97
96
|
`status:in-progress` and the run writes no summary.
|
|
98
97
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
that
|
|
98
|
+
A status label that the harness puts on the issue goes on the pull request too,
|
|
99
|
+
but only if the pull request exists at that moment. The `status:in-progress`
|
|
100
|
+
label of a fresh issue comes before the pull request, and stays on the issue
|
|
101
|
+
alone. Thus a draft pull request shows work that the harness has not finished,
|
|
102
|
+
and a `status:needs-human` pull request shows work that waits for a person.
|
|
103
103
|
|
|
104
104
|
The harness runs all git and `gh` commands itself. The commit holds all the
|
|
105
105
|
changes in the worktree, so a setup command that writes a file outside
|
|
@@ -188,8 +188,8 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
188
188
|
{
|
|
189
189
|
"remote": "origin",
|
|
190
190
|
"issueLimit": 100,
|
|
191
|
-
"maxCiFixes":
|
|
192
|
-
"maxReviewRounds":
|
|
191
|
+
"maxCiFixes": 5,
|
|
192
|
+
"maxReviewRounds": 5,
|
|
193
193
|
"checkIntervalSeconds": 15,
|
|
194
194
|
"checkGraceSeconds": 60,
|
|
195
195
|
"checkTimeoutMinutes": 60,
|
|
@@ -220,8 +220,8 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
220
220
|
| --- | --- | --- |
|
|
221
221
|
| `remote` | `origin` | The git remote for the repository and the branches |
|
|
222
222
|
| `issueLimit` | `100` | The maximum number of open issues to read |
|
|
223
|
-
| `maxCiFixes` | `
|
|
224
|
-
| `maxReviewRounds` | `
|
|
223
|
+
| `maxCiFixes` | `5` | The budget for check fixes per issue |
|
|
224
|
+
| `maxReviewRounds` | `5` | The budget for review rounds per issue |
|
|
225
225
|
| `checkIntervalSeconds` | `15` | The time between two check states |
|
|
226
226
|
| `checkGraceSeconds` | `60` | The time to wait for the first check to show |
|
|
227
227
|
| `checkTimeoutMinutes` | `60` | The limit for one check wait |
|
|
@@ -237,10 +237,10 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
237
237
|
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
238
238
|
|
|
239
239
|
An empty `labels.ready` turns the ready requirement off. The harness then
|
|
240
|
-
claims every open issue that no other label
|
|
240
|
+
claims every open issue that no other label holds back.
|
|
241
241
|
|
|
242
|
-
The log gives the reason for a skipped issue as `stop-label`, `not-ready
|
|
243
|
-
`in-flight
|
|
242
|
+
The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
|
|
243
|
+
`in-flight`.
|
|
244
244
|
|
|
245
245
|
A role set to `null`, or with no entry in `models`, uses the default model of
|
|
246
246
|
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
|
@@ -2,7 +2,7 @@ import { CODING, REVIEWING, runAgent } from "./agents.mjs";
|
|
|
2
2
|
import { managedLabels } from "./config.mjs";
|
|
3
3
|
import { run } from "./exec.mjs";
|
|
4
4
|
import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
|
|
5
|
-
import {
|
|
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,
|
|
@@ -31,14 +30,14 @@ export const PORTS = {
|
|
|
31
30
|
setLabel,
|
|
32
31
|
waitForChecks,
|
|
33
32
|
};
|
|
34
|
-
export function skipReason(issue, config,
|
|
33
|
+
export function skipReason(issue, config, resuming) {
|
|
35
34
|
const labels = new Set(issue.labels);
|
|
36
35
|
const names = config.labels;
|
|
37
36
|
if (labels.has(names.done) || labels.has(names.needsHuman) || labels.has(names.skip)) {
|
|
38
37
|
return "stop-label";
|
|
39
38
|
}
|
|
40
39
|
if (resuming) {
|
|
41
|
-
return
|
|
40
|
+
return undefined;
|
|
42
41
|
}
|
|
43
42
|
if (names.ready.length > 0 && !labels.has(names.ready)) {
|
|
44
43
|
return "not-ready";
|
|
@@ -46,13 +45,13 @@ export function skipReason(issue, config, login, resuming) {
|
|
|
46
45
|
if (labels.has(names.inProgress) || labels.has(names.inReview)) {
|
|
47
46
|
return "in-flight";
|
|
48
47
|
}
|
|
49
|
-
return
|
|
48
|
+
return undefined;
|
|
50
49
|
}
|
|
51
50
|
export async function processIssue(context, issue) {
|
|
52
51
|
const started = Date.now();
|
|
53
52
|
const log = context.recorder.scope({ issue: issue.number });
|
|
54
53
|
const saved = await readState(context.repo, issue.number);
|
|
55
|
-
const skip = skipReason(issue, context.config,
|
|
54
|
+
const skip = skipReason(issue, context.config, saved !== undefined);
|
|
56
55
|
if (skip !== undefined) {
|
|
57
56
|
log.event("issue.skip", { title: issue.title, reason: skip });
|
|
58
57
|
return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
@@ -97,7 +96,6 @@ async function work(context, issue, log, state) {
|
|
|
97
96
|
const { repo, config, ports } = context;
|
|
98
97
|
const branch = state.branch;
|
|
99
98
|
await log.step("claim", { label: config.labels.inProgress }, async () => {
|
|
100
|
-
await ports.assignIssue(repo, issue.number, context.login);
|
|
101
99
|
await setStatus(context, issue.number, state.pr, config.labels.inProgress);
|
|
102
100
|
await writeState(repo, state);
|
|
103
101
|
});
|
|
@@ -107,25 +105,16 @@ async function work(context, issue, log, state) {
|
|
|
107
105
|
if (!added.baseUpdated) {
|
|
108
106
|
log.event("base.behind", { base: context.base }, "quiet");
|
|
109
107
|
}
|
|
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
|
-
};
|
|
108
|
+
const job = {
|
|
109
|
+
context,
|
|
110
|
+
issue,
|
|
111
|
+
log,
|
|
112
|
+
state,
|
|
113
|
+
branch,
|
|
114
|
+
worktree,
|
|
115
|
+
comments,
|
|
116
|
+
started,
|
|
117
|
+
noChecks: false,
|
|
129
118
|
};
|
|
130
119
|
const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
|
|
131
120
|
if (setupCommand !== undefined) {
|
|
@@ -133,10 +122,10 @@ async function work(context, issue, log, state) {
|
|
|
133
122
|
if (setup.code !== 0) {
|
|
134
123
|
const late = setup.timedOut === true;
|
|
135
124
|
log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
|
|
136
|
-
await escalate(late
|
|
125
|
+
await escalate(job, late
|
|
137
126
|
? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
|
|
138
127
|
: "The setup command failed in the worktree.");
|
|
139
|
-
return finish("needs-human", late ? "setup timeout" : "setup failed");
|
|
128
|
+
return finish(job, "needs-human", late ? "setup timeout" : "setup failed");
|
|
140
129
|
}
|
|
141
130
|
}
|
|
142
131
|
if (state.phase === "claimed") {
|
|
@@ -151,138 +140,173 @@ async function work(context, issue, log, state) {
|
|
|
151
140
|
}));
|
|
152
141
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
153
142
|
if (!(await commitWork(job, before, subject))) {
|
|
154
|
-
await escalate("The implementer produced no change.");
|
|
155
|
-
return finish("needs-human", "no implementation commit");
|
|
143
|
+
await escalate(job, "The implementer produced no change.");
|
|
144
|
+
return finish(job, "needs-human", "no implementation commit");
|
|
156
145
|
}
|
|
157
146
|
state.phase = "implemented";
|
|
158
147
|
await writeState(repo, state);
|
|
159
148
|
}
|
|
160
149
|
await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
|
|
161
|
-
|
|
150
|
+
const pr = await log.step("pull-request", { branch }, async () => {
|
|
162
151
|
const existing = await ports.findPr(repo, branch);
|
|
163
152
|
return (existing ??
|
|
164
153
|
ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
165
154
|
});
|
|
155
|
+
state.pr = pr;
|
|
166
156
|
state.phase = "review";
|
|
167
157
|
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
|
-
};
|
|
158
|
+
log.event("pull-request.ready", { pr }, "quiet");
|
|
209
159
|
for (;;) {
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
return
|
|
160
|
+
const gate = await checkGate(job);
|
|
161
|
+
if (gate.done) {
|
|
162
|
+
return gate.report;
|
|
213
163
|
}
|
|
214
|
-
if (
|
|
164
|
+
if (gate.fixed) {
|
|
215
165
|
continue;
|
|
216
166
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
167
|
+
const round = await reviewRound(job);
|
|
168
|
+
if (round.done) {
|
|
169
|
+
return round.report;
|
|
220
170
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function checkGate(job) {
|
|
174
|
+
const { context, log, state, branch, worktree } = job;
|
|
175
|
+
const { repo, config, ports } = context;
|
|
176
|
+
const head = await ports.revision(worktree);
|
|
177
|
+
const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
|
|
178
|
+
head,
|
|
179
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
180
|
+
graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
181
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
182
|
+
show: log.level === "verbose",
|
|
183
|
+
}));
|
|
184
|
+
log.event("checks.state", { state: checks });
|
|
185
|
+
job.noChecks ||= checks === "none";
|
|
186
|
+
if (checks === "timeout") {
|
|
187
|
+
await escalate(job, "The checks did not finish in time.");
|
|
188
|
+
return { done: true, report: await finish(job, "needs-human", "checks timeout") };
|
|
189
|
+
}
|
|
190
|
+
if (checks !== "fail") {
|
|
191
|
+
return { done: false, fixed: false };
|
|
192
|
+
}
|
|
193
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
194
|
+
await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
195
|
+
return { done: true, report: await finish(job, "needs-human", "check budget") };
|
|
196
|
+
}
|
|
197
|
+
state.ciFixes += 1;
|
|
198
|
+
await writeState(repo, state);
|
|
199
|
+
const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
|
|
200
|
+
log.event("checks.logs", { chars: logs.length });
|
|
201
|
+
const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
|
|
202
|
+
if (!fix.committed) {
|
|
203
|
+
const note = fix.unrelated;
|
|
204
|
+
await escalate(job, note === undefined
|
|
205
|
+
? "The fixer added no commit for the failed checks."
|
|
206
|
+
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
207
|
+
return {
|
|
208
|
+
done: true,
|
|
209
|
+
report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
|
|
230
210
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
211
|
+
}
|
|
212
|
+
return { done: false, fixed: true };
|
|
213
|
+
}
|
|
214
|
+
async function reviewRound(job) {
|
|
215
|
+
const { context, issue, log, state, worktree, comments } = job;
|
|
216
|
+
const { repo, config, ports } = context;
|
|
217
|
+
const pr = state.pr;
|
|
218
|
+
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
219
|
+
await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
|
|
220
|
+
return { done: true, report: await finish(job, "needs-human", "review budget") };
|
|
221
|
+
}
|
|
222
|
+
state.reviewRounds += 1;
|
|
223
|
+
await writeState(repo, state);
|
|
224
|
+
await setStatus(context, issue.number, pr, config.labels.inReview);
|
|
225
|
+
const full = await ports.diff(worktree, context.base, config.remote);
|
|
226
|
+
const patch = capDiff(full, config.diffMaxChars);
|
|
227
|
+
const fields = {
|
|
228
|
+
round: state.reviewRounds,
|
|
229
|
+
diffChars: full.length,
|
|
230
|
+
cut: full.length > config.diffMaxChars,
|
|
231
|
+
};
|
|
232
|
+
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
233
|
+
name: "reviewer",
|
|
234
|
+
cwd: worktree,
|
|
235
|
+
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
236
|
+
profile: REVIEWING,
|
|
237
|
+
model: config.models.reviewer,
|
|
238
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
239
|
+
outputSchema: VERDICT_SCHEMA,
|
|
240
|
+
}));
|
|
241
|
+
if (await ports.discardChanges(worktree)) {
|
|
242
|
+
log.event("review.discard", { round: state.reviewRounds }, "quiet");
|
|
243
|
+
}
|
|
244
|
+
const verdict = readVerdict(review.structured);
|
|
245
|
+
if (verdict === undefined) {
|
|
246
|
+
await escalate(job, "The reviewer gave no verdict.");
|
|
247
|
+
return { done: true, report: await finish(job, "needs-human", "no verdict") };
|
|
248
|
+
}
|
|
249
|
+
const blocking = blockingFindings(verdict);
|
|
250
|
+
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
251
|
+
log.event("verdict", {
|
|
252
|
+
round: state.reviewRounds,
|
|
253
|
+
blocking: blocking.length,
|
|
254
|
+
minor: verdict.findings.length - blocking.length,
|
|
255
|
+
open: open.length,
|
|
256
|
+
summary: verdict.summary,
|
|
257
|
+
}, "quiet");
|
|
258
|
+
await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
|
|
259
|
+
if (open.length === 0) {
|
|
260
|
+
const gate = await checkGate(job);
|
|
261
|
+
if (gate.done) {
|
|
262
|
+
return { done: true, report: gate.report };
|
|
247
263
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
log.event("verdict", {
|
|
251
|
-
round: state.reviewRounds,
|
|
252
|
-
blocking: blocking.length,
|
|
253
|
-
minor: verdict.findings.length - blocking.length,
|
|
254
|
-
open: open.length,
|
|
255
|
-
summary: verdict.summary,
|
|
256
|
-
}, "quiet");
|
|
257
|
-
await ports.commentOnPr(repo, state.pr, formatVerdict(verdict, open));
|
|
258
|
-
if (open.length === 0) {
|
|
259
|
-
const after = await gate();
|
|
260
|
-
if (after.done) {
|
|
261
|
-
return after.report;
|
|
262
|
-
}
|
|
263
|
-
if (after.fixed) {
|
|
264
|
-
continue;
|
|
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");
|
|
264
|
+
if (gate.fixed) {
|
|
265
|
+
return { done: false };
|
|
271
266
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
await escalate("The reviewer repeated findings that an earlier round did not fix.");
|
|
275
|
-
return finish("needs-human", "repeated findings");
|
|
267
|
+
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
268
|
+
log.event("pull-request.undraft.fail", { pr }, "quiet");
|
|
276
269
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
270
|
+
await setStatus(context, issue.number, pr, config.labels.done);
|
|
271
|
+
return { done: true, report: await finish(job, "done") };
|
|
272
|
+
}
|
|
273
|
+
const mark = fingerprint(open);
|
|
274
|
+
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
275
|
+
await escalate(job, "The reviewer repeated findings that an earlier round did not fix.");
|
|
276
|
+
return { done: true, report: await finish(job, "needs-human", "repeated findings") };
|
|
277
|
+
}
|
|
278
|
+
const earlier = history(state.reviewLog);
|
|
279
|
+
state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
|
|
280
|
+
await writeState(repo, state);
|
|
281
|
+
const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
|
|
282
|
+
if (!fix.committed) {
|
|
283
|
+
await escalate(job, "The fixer added no commit for the review findings.");
|
|
284
|
+
return { done: true, report: await finish(job, "needs-human", "no fix commit") };
|
|
285
|
+
}
|
|
286
|
+
return { done: false };
|
|
287
|
+
}
|
|
288
|
+
function escalate(job, reason) {
|
|
289
|
+
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
290
|
+
}
|
|
291
|
+
async function finish(job, outcome, reason) {
|
|
292
|
+
const { context, issue, log, state, branch } = job;
|
|
293
|
+
const { repo, ports } = context;
|
|
294
|
+
await cleanUp(context, log, issue.number);
|
|
295
|
+
if (outcome === "done") {
|
|
296
|
+
if (!(await ports.deleteBranch(repo, branch))) {
|
|
297
|
+
log.event("branch.kept", { branch }, "quiet");
|
|
284
298
|
}
|
|
299
|
+
await dropState(repo, issue.number);
|
|
285
300
|
}
|
|
301
|
+
return {
|
|
302
|
+
issue: issue.number,
|
|
303
|
+
outcome,
|
|
304
|
+
reason,
|
|
305
|
+
pr: state.pr,
|
|
306
|
+
ciFixes: state.ciFixes,
|
|
307
|
+
reviewRounds: state.reviewRounds,
|
|
308
|
+
ms: Date.now() - job.started,
|
|
309
|
+
};
|
|
286
310
|
}
|
|
287
311
|
async function fixRound(job, reason, detail, earlier, kind) {
|
|
288
312
|
const { context, issue, log, worktree, branch } = job;
|