next-issue 2.4.0 → 2.4.2
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 +68 -68
- package/dist/agents.mjs +32 -31
- package/dist/args.mjs +17 -17
- package/dist/cli.mjs +16 -14
- package/dist/config.mjs +42 -42
- package/dist/exec.mjs +26 -26
- package/dist/git.mjs +40 -40
- package/dist/github.mjs +110 -79
- package/dist/interrupt.mjs +7 -7
- package/dist/loop.mjs +5 -3
- package/dist/observe.mjs +19 -19
- package/dist/pipeline.mjs +138 -116
- package/dist/progress.mjs +22 -22
- package/dist/prompts.mjs +52 -52
- package/dist/run.mjs +28 -28
- package/dist/serve.mjs +49 -45
- package/dist/setup.mjs +13 -13
- package/dist/state.mjs +20 -20
- package/dist/summary.mjs +7 -7
- package/dist/verdict.mjs +30 -25
- package/dist/version.mjs +3 -3
- package/package.json +7 -1
package/dist/pipeline.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { CODING, REVIEWING, runAgent } from
|
|
2
|
-
import { managedLabels } from
|
|
3
|
-
import { run } from
|
|
4
|
-
import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, findWorktree, isDirty, push, removeWorktree, resetToBase, revision, } from
|
|
5
|
-
import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from
|
|
6
|
-
import { message } from
|
|
7
|
-
import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from
|
|
8
|
-
import { detectSetupCommand } from
|
|
9
|
-
import { dropState, readState, resetState, savedIssues, writeState, } from
|
|
10
|
-
import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from
|
|
1
|
+
import { CODING, REVIEWING, runAgent } from './agents.mjs';
|
|
2
|
+
import { managedLabels } from './config.mjs';
|
|
3
|
+
import { run } from './exec.mjs';
|
|
4
|
+
import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, findWorktree, isDirty, push, removeWorktree, resetToBase, revision, } from './git.mjs';
|
|
5
|
+
import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
|
|
6
|
+
import { message } from './observe.mjs';
|
|
7
|
+
import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from './prompts.mjs';
|
|
8
|
+
import { detectSetupCommand } from './setup.mjs';
|
|
9
|
+
import { dropState, readState, resetState, savedIssues, writeState, } from './state.mjs';
|
|
10
|
+
import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from './verdict.mjs';
|
|
11
11
|
export const PORTS = {
|
|
12
12
|
addWorktree,
|
|
13
13
|
commentOnPr,
|
|
@@ -35,7 +35,7 @@ export const PORTS = {
|
|
|
35
35
|
resetToBase,
|
|
36
36
|
revision,
|
|
37
37
|
runAgent,
|
|
38
|
-
runSetup: async (command, cwd, timeoutMs) => run(
|
|
38
|
+
runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
|
|
39
39
|
setLabel,
|
|
40
40
|
viewIssue,
|
|
41
41
|
waitForBaseChecks,
|
|
@@ -45,30 +45,36 @@ export function skipReason(issue, config, resuming) {
|
|
|
45
45
|
const labels = new Set(issue.labels);
|
|
46
46
|
const names = config.labels;
|
|
47
47
|
if (labels.has(names.done) || labels.has(names.needsHuman) || labels.has(names.skip)) {
|
|
48
|
-
return
|
|
48
|
+
return 'stop-label';
|
|
49
49
|
}
|
|
50
50
|
if (resuming) {
|
|
51
51
|
return undefined;
|
|
52
52
|
}
|
|
53
53
|
if (names.ready.length > 0 && !labels.has(names.ready)) {
|
|
54
|
-
return
|
|
54
|
+
return 'not-ready';
|
|
55
55
|
}
|
|
56
56
|
if (labels.has(names.inProgress) || labels.has(names.inReview)) {
|
|
57
|
-
return
|
|
57
|
+
return 'in-flight';
|
|
58
58
|
}
|
|
59
59
|
return undefined;
|
|
60
60
|
}
|
|
61
61
|
export async function pendingIssues(context) {
|
|
62
62
|
const { repo, config, ports } = context;
|
|
63
|
-
const
|
|
63
|
+
const { ready, inProgress, inReview } = config.labels;
|
|
64
|
+
const labels = ready.length === 0 ? [] : [ready, inProgress, inReview];
|
|
65
|
+
const issues = await ports.openIssues(repo, config.issueLimit, labels);
|
|
64
66
|
const listed = new Set(issues.map((issue) => issue.number));
|
|
65
67
|
for (const number of await savedIssues(repo)) {
|
|
66
68
|
if (listed.has(number)) {
|
|
67
69
|
continue;
|
|
68
70
|
}
|
|
69
71
|
const saved = await readState(repo, number).catch(() => undefined);
|
|
70
|
-
if (saved
|
|
71
|
-
|
|
72
|
+
if (saved === undefined) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const { open, ...issue } = await ports.viewIssue(repo, number);
|
|
76
|
+
if (open || (saved.pr !== undefined && (await ports.prMerged(repo, saved.pr)))) {
|
|
77
|
+
issues.push(issue);
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
return issues.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
@@ -81,12 +87,17 @@ export async function processIssue(context, issue) {
|
|
|
81
87
|
saved = await readState(context.repo, issue.number);
|
|
82
88
|
}
|
|
83
89
|
catch (error) {
|
|
84
|
-
log.event(
|
|
85
|
-
return {
|
|
90
|
+
log.event('issue.error', { error: message(error), ms: Date.now() - started }, 'quiet');
|
|
91
|
+
return {
|
|
92
|
+
...skipped(issue),
|
|
93
|
+
outcome: 'error',
|
|
94
|
+
reason: message(error),
|
|
95
|
+
ms: Date.now() - started,
|
|
96
|
+
};
|
|
86
97
|
}
|
|
87
98
|
const skip = skipReason(issue, context.config, saved !== undefined);
|
|
88
99
|
if (skip !== undefined) {
|
|
89
|
-
log.event(
|
|
100
|
+
log.event('issue.skip', { title: issue.title, reason: skip });
|
|
90
101
|
return skipped(issue);
|
|
91
102
|
}
|
|
92
103
|
if (saved === undefined) {
|
|
@@ -95,18 +106,23 @@ export async function processIssue(context, issue) {
|
|
|
95
106
|
blockers = await context.ports.openBlockers(context.repo, issue.number);
|
|
96
107
|
}
|
|
97
108
|
catch (error) {
|
|
98
|
-
log.event(
|
|
99
|
-
return {
|
|
109
|
+
log.event('issue.error', { error: message(error), ms: Date.now() - started }, 'quiet');
|
|
110
|
+
return {
|
|
111
|
+
...skipped(issue),
|
|
112
|
+
outcome: 'error',
|
|
113
|
+
reason: message(error),
|
|
114
|
+
ms: Date.now() - started,
|
|
115
|
+
};
|
|
100
116
|
}
|
|
101
117
|
if (blockers.length > 0) {
|
|
102
|
-
log.event(
|
|
118
|
+
log.event('issue.skip', { title: issue.title, reason: 'blocked', blockers });
|
|
103
119
|
return skipped(issue);
|
|
104
120
|
}
|
|
105
121
|
}
|
|
106
|
-
log.event(
|
|
122
|
+
log.event('issue.start', { title: issue.title, createdAt: issue.createdAt }, 'quiet');
|
|
107
123
|
const fresh = {
|
|
108
124
|
issue: issue.number,
|
|
109
|
-
phase:
|
|
125
|
+
phase: 'claimed',
|
|
110
126
|
branch: branchName(issue.number),
|
|
111
127
|
ciFixes: 0,
|
|
112
128
|
reviewRounds: 0,
|
|
@@ -115,21 +131,21 @@ export async function processIssue(context, issue) {
|
|
|
115
131
|
const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
|
|
116
132
|
try {
|
|
117
133
|
const report = await work(context, issue, log, state);
|
|
118
|
-
log.event(
|
|
134
|
+
log.event('issue.end', { outcome: report.outcome, reason: report.reason, ms: report.ms }, 'quiet');
|
|
119
135
|
return report;
|
|
120
136
|
}
|
|
121
137
|
catch (error) {
|
|
122
|
-
log.event(
|
|
138
|
+
log.event('issue.error', { error: message(error), ms: Date.now() - started }, 'quiet');
|
|
123
139
|
try {
|
|
124
140
|
await handOver(context, log, issue.number, state, `The run failed: ${message(error)}`);
|
|
125
141
|
}
|
|
126
142
|
catch (failure) {
|
|
127
|
-
log.event(
|
|
143
|
+
log.event('hand-over.error', { error: message(failure) }, 'quiet');
|
|
128
144
|
}
|
|
129
145
|
await cleanUp(context, log, state.branch);
|
|
130
146
|
return {
|
|
131
147
|
issue: issue.number,
|
|
132
|
-
outcome:
|
|
148
|
+
outcome: 'error',
|
|
133
149
|
reason: message(error),
|
|
134
150
|
pr: state.pr,
|
|
135
151
|
ciFixes: state.ciFixes,
|
|
@@ -139,21 +155,21 @@ export async function processIssue(context, issue) {
|
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
function skipped(issue) {
|
|
142
|
-
return { issue: issue.number, outcome:
|
|
158
|
+
return { issue: issue.number, outcome: 'skipped', ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
143
159
|
}
|
|
144
160
|
async function work(context, issue, log, state) {
|
|
145
161
|
const started = Date.now();
|
|
146
162
|
const { repo, config, ports } = context;
|
|
147
163
|
const branch = state.branch;
|
|
148
|
-
await log.step(
|
|
164
|
+
await log.step('claim', { label: config.labels.inProgress }, async () => {
|
|
149
165
|
await setStatus(context, issue.number, state.pr, config.labels.inProgress);
|
|
150
166
|
await writeState(repo, state);
|
|
151
167
|
});
|
|
152
|
-
const comments = await log.step(
|
|
153
|
-
const added = await log.step(
|
|
168
|
+
const comments = await log.step('comments', {}, () => ports.issueComments(repo, issue.number));
|
|
169
|
+
const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote));
|
|
154
170
|
const worktree = added.path;
|
|
155
171
|
if (!added.baseUpdated) {
|
|
156
|
-
log.event(
|
|
172
|
+
log.event('base.behind', { base: context.base }, 'quiet');
|
|
157
173
|
}
|
|
158
174
|
const job = {
|
|
159
175
|
context,
|
|
@@ -169,20 +185,20 @@ async function work(context, issue, log, state) {
|
|
|
169
185
|
};
|
|
170
186
|
const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
|
|
171
187
|
if (setupCommand !== undefined) {
|
|
172
|
-
const setup = await log.step(
|
|
188
|
+
const setup = await log.step('setup', { cmd: setupCommand, found: config.setupCommand === undefined }, () => ports.runSetup(setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
|
|
173
189
|
if (setup.code !== 0) {
|
|
174
190
|
const late = setup.timedOut === true;
|
|
175
|
-
log.event(
|
|
191
|
+
log.event('setup.fail', { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, 'quiet');
|
|
176
192
|
await escalate(job, late
|
|
177
193
|
? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
|
|
178
|
-
:
|
|
179
|
-
return finish(job,
|
|
194
|
+
: 'The setup command failed in the worktree.');
|
|
195
|
+
return finish(job, 'needs-human', late ? 'setup timeout' : 'setup failed');
|
|
180
196
|
}
|
|
181
197
|
}
|
|
182
|
-
if (state.phase ===
|
|
198
|
+
if (state.phase === 'claimed') {
|
|
183
199
|
const before = await ports.revision(worktree);
|
|
184
|
-
const result = await log.step(
|
|
185
|
-
name:
|
|
200
|
+
const result = await log.step('implement', {}, () => ports.runAgent(log, {
|
|
201
|
+
name: 'implementer',
|
|
186
202
|
cwd: worktree,
|
|
187
203
|
prompt: implementPrompt(issue, comments),
|
|
188
204
|
profile: CODING,
|
|
@@ -190,11 +206,11 @@ async function work(context, issue, log, state) {
|
|
|
190
206
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
191
207
|
}));
|
|
192
208
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
193
|
-
if (!(await commitWork(job, before, subject,
|
|
194
|
-
await escalate(job,
|
|
195
|
-
return finish(job,
|
|
209
|
+
if (!(await commitWork(job, before, subject, 'implementer'))) {
|
|
210
|
+
await escalate(job, 'The implementer produced no change.');
|
|
211
|
+
return finish(job, 'needs-human', 'no implementation commit');
|
|
196
212
|
}
|
|
197
|
-
state.phase =
|
|
213
|
+
state.phase = 'implemented';
|
|
198
214
|
await writeState(repo, state);
|
|
199
215
|
}
|
|
200
216
|
if (context.stopAfterRound()) {
|
|
@@ -207,11 +223,13 @@ async function work(context, issue, log, state) {
|
|
|
207
223
|
return fixed.report;
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
|
-
else if (state.phase ===
|
|
211
|
-
|
|
226
|
+
else if (state.phase === 'review' &&
|
|
227
|
+
state.pr !== undefined &&
|
|
228
|
+
(await ports.prMerged(repo, state.pr))) {
|
|
229
|
+
log.event('pull-request.merged', { pr: state.pr }, 'quiet');
|
|
212
230
|
if (!context.merge) {
|
|
213
231
|
await setStatus(context, issue.number, state.pr, config.labels.done);
|
|
214
|
-
return finish(job,
|
|
232
|
+
return finish(job, 'done');
|
|
215
233
|
}
|
|
216
234
|
const landed = await afterMerge(job);
|
|
217
235
|
if (landed.done) {
|
|
@@ -219,8 +237,8 @@ async function work(context, issue, log, state) {
|
|
|
219
237
|
}
|
|
220
238
|
}
|
|
221
239
|
else {
|
|
222
|
-
await log.step(
|
|
223
|
-
state.phase =
|
|
240
|
+
await log.step('push', { branch }, () => ports.push(worktree, config.remote, branch));
|
|
241
|
+
state.phase = 'review';
|
|
224
242
|
await openPr(job);
|
|
225
243
|
}
|
|
226
244
|
for (;;) {
|
|
@@ -246,52 +264,52 @@ async function work(context, issue, log, state) {
|
|
|
246
264
|
async function openPr(job) {
|
|
247
265
|
const { context, issue, log, state, branch } = job;
|
|
248
266
|
const { repo, config, ports } = context;
|
|
249
|
-
const pr = await log.step(
|
|
267
|
+
const pr = await log.step('pull-request', { branch }, async () => {
|
|
250
268
|
const existing = await ports.findPr(repo, branch);
|
|
251
269
|
return (existing ??
|
|
252
270
|
ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
253
271
|
});
|
|
254
272
|
state.pr = pr;
|
|
255
273
|
await writeState(repo, state);
|
|
256
|
-
log.event(
|
|
274
|
+
log.event('pull-request.ready', { pr }, 'quiet');
|
|
257
275
|
}
|
|
258
276
|
async function checkGate(job) {
|
|
259
277
|
const { context, log, state, worktree } = job;
|
|
260
278
|
const { repo, config, ports } = context;
|
|
261
279
|
const head = await ports.revision(worktree);
|
|
262
|
-
const checks = await log.step(
|
|
280
|
+
const checks = await log.step('checks', { pr: state.pr, head }, () => ports.waitForChecks(repo, state.pr, {
|
|
263
281
|
head,
|
|
264
282
|
intervalSeconds: config.checkIntervalSeconds,
|
|
265
283
|
graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
266
284
|
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
267
|
-
show: log.level ===
|
|
285
|
+
show: log.level === 'verbose',
|
|
268
286
|
}));
|
|
269
|
-
log.event(
|
|
270
|
-
job.noChecks ||= checks ===
|
|
271
|
-
if (checks ===
|
|
272
|
-
await escalate(job,
|
|
273
|
-
return { done: true, report: await finish(job,
|
|
287
|
+
log.event('checks.state', { state: checks });
|
|
288
|
+
job.noChecks ||= checks === 'none';
|
|
289
|
+
if (checks === 'timeout') {
|
|
290
|
+
await escalate(job, 'The checks did not finish in time.');
|
|
291
|
+
return { done: true, report: await finish(job, 'needs-human', 'checks timeout') };
|
|
274
292
|
}
|
|
275
|
-
if (checks !==
|
|
293
|
+
if (checks !== 'fail') {
|
|
276
294
|
return { done: false, fixed: false };
|
|
277
295
|
}
|
|
278
296
|
if (state.ciFixes >= config.maxCiFixes) {
|
|
279
297
|
await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
280
|
-
return { done: true, report: await finish(job,
|
|
298
|
+
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
281
299
|
}
|
|
282
300
|
state.ciFixes += 1;
|
|
283
301
|
await writeState(repo, state);
|
|
284
302
|
const logs = await ports.failedCheckLogs(repo, state.pr, config.logMaxChars);
|
|
285
|
-
log.event(
|
|
286
|
-
const fix = await fixRound(job,
|
|
303
|
+
log.event('checks.logs', { chars: logs.length });
|
|
304
|
+
const fix = await fixRound(job, 'The continuous integration checks failed.', logs, history(state.reviewLog), 'checks');
|
|
287
305
|
if (!fix.committed) {
|
|
288
306
|
const note = fix.unrelated;
|
|
289
307
|
await escalate(job, note === undefined
|
|
290
|
-
?
|
|
308
|
+
? 'The fixer added no commit for the failed checks.'
|
|
291
309
|
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
292
310
|
return {
|
|
293
311
|
done: true,
|
|
294
|
-
report: await finish(job,
|
|
312
|
+
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
295
313
|
};
|
|
296
314
|
}
|
|
297
315
|
return { done: false, fixed: true };
|
|
@@ -302,7 +320,7 @@ async function reviewRound(job) {
|
|
|
302
320
|
const pr = state.pr;
|
|
303
321
|
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
304
322
|
await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
|
|
305
|
-
return { done: true, report: await finish(job,
|
|
323
|
+
return { done: true, report: await finish(job, 'needs-human', 'review budget') };
|
|
306
324
|
}
|
|
307
325
|
state.reviewRounds += 1;
|
|
308
326
|
await writeState(repo, state);
|
|
@@ -315,8 +333,8 @@ async function reviewRound(job) {
|
|
|
315
333
|
cut: full.length > config.diffMaxChars,
|
|
316
334
|
};
|
|
317
335
|
const before = await ports.revision(worktree);
|
|
318
|
-
const review = await log.step(
|
|
319
|
-
name:
|
|
336
|
+
const review = await log.step('review', fields, () => ports.runAgent(log, {
|
|
337
|
+
name: 'reviewer',
|
|
320
338
|
cwd: worktree,
|
|
321
339
|
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
322
340
|
profile: REVIEWING,
|
|
@@ -325,22 +343,22 @@ async function reviewRound(job) {
|
|
|
325
343
|
outputSchema: VERDICT_SCHEMA,
|
|
326
344
|
}));
|
|
327
345
|
if (await ports.discardChanges(worktree, before)) {
|
|
328
|
-
log.event(
|
|
346
|
+
log.event('review.discard', { round: state.reviewRounds }, 'quiet');
|
|
329
347
|
}
|
|
330
348
|
const verdict = readVerdict(review.structured);
|
|
331
349
|
if (verdict === undefined) {
|
|
332
|
-
await escalate(job,
|
|
333
|
-
return { done: true, report: await finish(job,
|
|
350
|
+
await escalate(job, 'The reviewer gave no verdict.');
|
|
351
|
+
return { done: true, report: await finish(job, 'needs-human', 'no verdict') };
|
|
334
352
|
}
|
|
335
353
|
const blocking = blockingFindings(verdict);
|
|
336
354
|
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
337
|
-
log.event(
|
|
355
|
+
log.event('verdict', {
|
|
338
356
|
round: state.reviewRounds,
|
|
339
357
|
blocking: blocking.length,
|
|
340
358
|
minor: verdict.findings.length - blocking.length,
|
|
341
359
|
open: open.length,
|
|
342
360
|
summary: verdict.summary,
|
|
343
|
-
},
|
|
361
|
+
}, 'quiet');
|
|
344
362
|
await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
|
|
345
363
|
if (open.length === 0) {
|
|
346
364
|
const gate = await checkGate(job);
|
|
@@ -351,36 +369,40 @@ async function reviewRound(job) {
|
|
|
351
369
|
return { done: false };
|
|
352
370
|
}
|
|
353
371
|
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
354
|
-
log.event(
|
|
372
|
+
log.event('pull-request.undraft.fail', { pr }, 'quiet');
|
|
355
373
|
}
|
|
356
374
|
if (context.merge) {
|
|
357
375
|
if (!(await ports.mergePr(repo, pr))) {
|
|
358
|
-
await escalate(job,
|
|
359
|
-
return { done: true, report: await finish(job,
|
|
376
|
+
await escalate(job, 'The merge of the pull request failed.');
|
|
377
|
+
return { done: true, report: await finish(job, 'needs-human', 'merge failed') };
|
|
360
378
|
}
|
|
361
379
|
return afterMerge(job);
|
|
362
380
|
}
|
|
363
381
|
await setStatus(context, issue.number, pr, config.labels.done);
|
|
364
|
-
return { done: true, report: await finish(job,
|
|
382
|
+
return { done: true, report: await finish(job, 'done') };
|
|
365
383
|
}
|
|
366
384
|
const mark = fingerprint(open);
|
|
367
385
|
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
368
|
-
await escalate(job,
|
|
369
|
-
return { done: true, report: await finish(job,
|
|
386
|
+
await escalate(job, 'The reviewer repeated findings that an earlier round did not fix.');
|
|
387
|
+
return { done: true, report: await finish(job, 'needs-human', 'repeated findings') };
|
|
370
388
|
}
|
|
371
389
|
const earlier = history(state.reviewLog);
|
|
372
|
-
state.reviewLog.push({
|
|
390
|
+
state.reviewLog.push({
|
|
391
|
+
round: state.reviewRounds,
|
|
392
|
+
fingerprint: mark,
|
|
393
|
+
findings: formatFindings(open),
|
|
394
|
+
});
|
|
373
395
|
await writeState(repo, state);
|
|
374
|
-
const fix = await fixRound(job,
|
|
396
|
+
const fix = await fixRound(job, 'The reviewer requested changes.', formatFindings(open), earlier, 'review');
|
|
375
397
|
if (!fix.committed) {
|
|
376
398
|
const note = fix.unrelated;
|
|
377
399
|
if (note === undefined) {
|
|
378
|
-
await escalate(job,
|
|
379
|
-
return { done: true, report: await finish(job,
|
|
400
|
+
await escalate(job, 'The fixer added no commit for the review findings.');
|
|
401
|
+
return { done: true, report: await finish(job, 'needs-human', 'no fix commit') };
|
|
380
402
|
}
|
|
381
403
|
if (fix.stray === true) {
|
|
382
404
|
await escalate(job, `The fixer disputed the findings and left a change behind: ${note}`);
|
|
383
|
-
return { done: true, report: await finish(job,
|
|
405
|
+
return { done: true, report: await finish(job, 'needs-human', 'dispute with changes') };
|
|
384
406
|
}
|
|
385
407
|
state.reviewLog.at(-1).dispute = note;
|
|
386
408
|
await writeState(repo, state);
|
|
@@ -398,7 +420,7 @@ async function afterMerge(job) {
|
|
|
398
420
|
return { done: false };
|
|
399
421
|
}
|
|
400
422
|
await setStatus(context, issue.number, state.pr, context.config.labels.done);
|
|
401
|
-
return { done: true, report: await finish(job,
|
|
423
|
+
return { done: true, report: await finish(job, 'done') };
|
|
402
424
|
}
|
|
403
425
|
async function watchBase(job) {
|
|
404
426
|
const { context, log, state, worktree } = job;
|
|
@@ -408,26 +430,26 @@ async function watchBase(job) {
|
|
|
408
430
|
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
409
431
|
});
|
|
410
432
|
if (sha === undefined) {
|
|
411
|
-
await escalate(job,
|
|
412
|
-
return { done: true, report: await finish(job,
|
|
433
|
+
await escalate(job, 'The pull request did not merge in time, or it closed without a merge.');
|
|
434
|
+
return { done: true, report: await finish(job, 'needs-human', 'not merged') };
|
|
413
435
|
}
|
|
414
|
-
const checks = await log.step(
|
|
436
|
+
const checks = await log.step('main-checks', { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
|
|
415
437
|
intervalSeconds: config.checkIntervalSeconds,
|
|
416
438
|
graceMs: config.checkGraceSeconds * 1000,
|
|
417
439
|
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
418
440
|
}));
|
|
419
|
-
log.event(
|
|
420
|
-
if (checks ===
|
|
441
|
+
log.event('main-checks.state', { state: checks });
|
|
442
|
+
if (checks === 'pass' || checks === 'none') {
|
|
421
443
|
return { done: false, fixed: false };
|
|
422
444
|
}
|
|
423
445
|
job.mainBroken = true;
|
|
424
|
-
if (checks !==
|
|
425
|
-
await escalate(job,
|
|
426
|
-
return { done: true, report: await finish(job,
|
|
446
|
+
if (checks !== 'fail') {
|
|
447
|
+
await escalate(job, 'The checks on main did not finish in time.');
|
|
448
|
+
return { done: true, report: await finish(job, 'needs-human', 'main checks timeout') };
|
|
427
449
|
}
|
|
428
450
|
if (state.ciFixes >= config.maxCiFixes) {
|
|
429
451
|
await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
430
|
-
return { done: true, report: await finish(job,
|
|
452
|
+
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
431
453
|
}
|
|
432
454
|
await ports.resetToBase(worktree, context.base, config.remote);
|
|
433
455
|
state.ciFixes += 1;
|
|
@@ -440,16 +462,16 @@ async function fixBase(job) {
|
|
|
440
462
|
const { repo, config, ports } = context;
|
|
441
463
|
const { sha, head } = state.baseFix;
|
|
442
464
|
const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
|
|
443
|
-
log.event(
|
|
444
|
-
const fix = await fixRound(job,
|
|
465
|
+
log.event('main-checks.logs', { chars: logs.length });
|
|
466
|
+
const fix = await fixRound(job, 'The checks on main failed after the merge.', logs, [], 'checks', true, head);
|
|
445
467
|
if (!fix.committed) {
|
|
446
468
|
const note = fix.unrelated;
|
|
447
469
|
await escalate(job, note === undefined
|
|
448
|
-
?
|
|
470
|
+
? 'The fixer added no commit for the failed checks on main.'
|
|
449
471
|
: `The checks on main fail for a reason this change did not cause: ${note}`);
|
|
450
472
|
return {
|
|
451
473
|
done: true,
|
|
452
|
-
report: await finish(job,
|
|
474
|
+
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
453
475
|
};
|
|
454
476
|
}
|
|
455
477
|
await ports.reopenIssue(repo, issue.number);
|
|
@@ -464,10 +486,10 @@ function escalate(job, reason) {
|
|
|
464
486
|
}
|
|
465
487
|
function stop(job) {
|
|
466
488
|
const { issue, log, state } = job;
|
|
467
|
-
log.event(
|
|
489
|
+
log.event('issue.stop', { phase: state.phase }, 'quiet');
|
|
468
490
|
return {
|
|
469
491
|
issue: issue.number,
|
|
470
|
-
outcome:
|
|
492
|
+
outcome: 'stopped',
|
|
471
493
|
pr: state.pr,
|
|
472
494
|
ciFixes: state.ciFixes,
|
|
473
495
|
reviewRounds: state.reviewRounds,
|
|
@@ -478,9 +500,9 @@ async function finish(job, outcome, reason) {
|
|
|
478
500
|
const { context, issue, log, state, branch } = job;
|
|
479
501
|
const { repo, ports } = context;
|
|
480
502
|
await cleanUp(context, log, branch);
|
|
481
|
-
if (outcome ===
|
|
503
|
+
if (outcome === 'done') {
|
|
482
504
|
if (!(await ports.deleteBranch(repo, branch))) {
|
|
483
|
-
log.event(
|
|
505
|
+
log.event('branch.kept', { branch }, 'quiet');
|
|
484
506
|
}
|
|
485
507
|
await dropState(repo, issue.number);
|
|
486
508
|
}
|
|
@@ -492,15 +514,15 @@ async function finish(job, outcome, reason) {
|
|
|
492
514
|
ciFixes: state.ciFixes,
|
|
493
515
|
reviewRounds: state.reviewRounds,
|
|
494
516
|
ms: Date.now() - job.started,
|
|
495
|
-
...(job.mainBroken && outcome !==
|
|
517
|
+
...(job.mainBroken && outcome !== 'done' ? { halt: true } : {}),
|
|
496
518
|
};
|
|
497
519
|
}
|
|
498
520
|
async function fixRound(job, reason, detail, earlier, kind, force = false, from) {
|
|
499
521
|
const { context, issue, log, worktree, branch } = job;
|
|
500
522
|
const { ports } = context;
|
|
501
523
|
const before = from ?? (await ports.revision(worktree));
|
|
502
|
-
const result = await log.step(
|
|
503
|
-
name:
|
|
524
|
+
const result = await log.step('fix', { reason }, () => ports.runAgent(log, {
|
|
525
|
+
name: 'fixer',
|
|
504
526
|
cwd: worktree,
|
|
505
527
|
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
506
528
|
profile: CODING,
|
|
@@ -510,12 +532,12 @@ async function fixRound(job, reason, detail, earlier, kind, force = false, from)
|
|
|
510
532
|
const unrelated = parseUnrelated(result.text);
|
|
511
533
|
if (unrelated !== undefined) {
|
|
512
534
|
const stray = (await ports.revision(worktree)) !== before || (await ports.isDirty(worktree));
|
|
513
|
-
log.event(
|
|
535
|
+
log.event('fix.unrelated', { reason, note: unrelated, stray }, 'quiet');
|
|
514
536
|
return { committed: false, unrelated, stray };
|
|
515
537
|
}
|
|
516
538
|
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
517
|
-
if (!(await commitWork(job, before, subject,
|
|
518
|
-
log.event(
|
|
539
|
+
if (!(await commitWork(job, before, subject, 'fixer'))) {
|
|
540
|
+
log.event('commit.empty', { reason }, 'quiet');
|
|
519
541
|
return { committed: false };
|
|
520
542
|
}
|
|
521
543
|
await ports.push(worktree, context.config.remote, branch, force);
|
|
@@ -527,7 +549,7 @@ async function commitWork(job, before, subject, role) {
|
|
|
527
549
|
for (;;) {
|
|
528
550
|
try {
|
|
529
551
|
if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
530
|
-
log.event(
|
|
552
|
+
log.event('commit', { subject });
|
|
531
553
|
}
|
|
532
554
|
if ((await ports.revision(worktree)) === before) {
|
|
533
555
|
return false;
|
|
@@ -543,8 +565,8 @@ async function commitWork(job, before, subject, role) {
|
|
|
543
565
|
state.commitFixes = used + 1;
|
|
544
566
|
await writeState(repo, state);
|
|
545
567
|
const output = message(error).slice(-config.logMaxChars);
|
|
546
|
-
log.event(
|
|
547
|
-
const fix = await log.step(
|
|
568
|
+
log.event('commit.fail', { role, output }, 'quiet');
|
|
569
|
+
const fix = await log.step('commit-fix', { role }, () => ports.runAgent(log, {
|
|
548
570
|
name: role,
|
|
549
571
|
cwd: worktree,
|
|
550
572
|
prompt: hookPrompt(issue, comments, role, output),
|
|
@@ -563,15 +585,15 @@ async function cleanUp(context, log, branch) {
|
|
|
563
585
|
return;
|
|
564
586
|
}
|
|
565
587
|
if (!(await context.ports.removeWorktree(context.repo, path))) {
|
|
566
|
-
log.event(
|
|
588
|
+
log.event('worktree.kept', { path }, 'quiet');
|
|
567
589
|
}
|
|
568
590
|
}
|
|
569
591
|
catch (error) {
|
|
570
|
-
log.event(
|
|
592
|
+
log.event('worktree.error', { error: message(error) }, 'quiet');
|
|
571
593
|
}
|
|
572
594
|
}
|
|
573
595
|
async function handOver(context, log, issue, state, reason) {
|
|
574
|
-
log.event(
|
|
596
|
+
log.event('hand-over', { reason }, 'quiet');
|
|
575
597
|
await writeState(context.repo, state);
|
|
576
598
|
await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
|
|
577
599
|
if (state.pr !== undefined) {
|
|
@@ -580,9 +602,9 @@ async function handOver(context, log, issue, state, reason) {
|
|
|
580
602
|
}
|
|
581
603
|
async function setStatus(context, issue, pr, label) {
|
|
582
604
|
const managed = managedLabels(context.config);
|
|
583
|
-
await context.ports.setLabel(context.repo,
|
|
605
|
+
await context.ports.setLabel(context.repo, 'issue', issue, label, managed);
|
|
584
606
|
if (pr !== undefined) {
|
|
585
|
-
await context.ports.setLabel(context.repo,
|
|
607
|
+
await context.ports.setLabel(context.repo, 'pr', pr, label, managed);
|
|
586
608
|
}
|
|
587
609
|
}
|
|
588
610
|
function history(rounds) {
|