next-issue 2.5.10 → 2.7.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 +12 -0
- package/dist/fix.mjs +73 -0
- package/dist/gate.mjs +57 -0
- package/dist/handover.mjs +70 -0
- package/dist/issue-format.mjs +7 -0
- package/dist/job.mjs +29 -0
- package/dist/merge.mjs +75 -0
- package/dist/pipeline.mjs +11 -428
- package/dist/ports.mjs +43 -0
- package/dist/review.mjs +104 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,18 @@ and exits with code 1. For each issue:
|
|
|
102
102
|
some findings wrong, the fixer fixes the correct ones, and the next round
|
|
103
103
|
holds the rest.
|
|
104
104
|
|
|
105
|
+
## Issue format
|
|
106
|
+
|
|
107
|
+
Every agent prompt holds the body and the comments of the issue. An issue reads best with these sections, in this order:
|
|
108
|
+
|
|
109
|
+
1. `Problem`
|
|
110
|
+
2. `Context and findings`
|
|
111
|
+
3. `Acceptance criteria`
|
|
112
|
+
4. `Out of scope`
|
|
113
|
+
5. `Open questions`, empty when the issue is created
|
|
114
|
+
|
|
115
|
+
The list lives in `src/issue-format.mts`. The `create-issue` skill of [`@ravshansbox/pi-next-issue`](../pi-next-issue) writes an issue in this format, and a contract test fails when its template and the list disagree. The harness does not reject an issue without the format.
|
|
116
|
+
|
|
105
117
|
## Stops for a loop
|
|
106
118
|
|
|
107
119
|
The harness hands the issue to a person, with the `status:needs-human` label,
|
package/dist/fix.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { CODING } from './agents.mjs';
|
|
2
|
+
import { HookError } from './git.mjs';
|
|
3
|
+
import { message } from './observe.mjs';
|
|
4
|
+
import { fixPrompt, hookPrompt, parseCommitSubject, parseUnrelated, } from './prompts.mjs';
|
|
5
|
+
import { writeState } from './state.mjs';
|
|
6
|
+
export async function fixRound(job, reason, detail, earlier, kind, force = false, from) {
|
|
7
|
+
const { context, issue, log, worktree, branch } = job;
|
|
8
|
+
const { ports } = context;
|
|
9
|
+
const before = from ?? (await ports.revision(worktree));
|
|
10
|
+
const result = await log.step('fix', { reason }, () => ports.runAgent(log, {
|
|
11
|
+
name: 'fixer',
|
|
12
|
+
cwd: worktree,
|
|
13
|
+
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
14
|
+
profile: CODING,
|
|
15
|
+
model: context.config.models.fixer,
|
|
16
|
+
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
|
17
|
+
}));
|
|
18
|
+
const unrelated = parseUnrelated(result.text);
|
|
19
|
+
if (unrelated !== undefined) {
|
|
20
|
+
const disputed = await ports.revision(worktree);
|
|
21
|
+
const moved = disputed !== before;
|
|
22
|
+
if (moved) {
|
|
23
|
+
const aside = `${branch}-dispute-${disputed.slice(0, 7)}`;
|
|
24
|
+
await ports.setAside(worktree, before, aside);
|
|
25
|
+
log.event('fix.aside', { branch: aside }, 'quiet');
|
|
26
|
+
}
|
|
27
|
+
const stray = moved || (await ports.isDirty(worktree));
|
|
28
|
+
log.event('fix.unrelated', { reason, note: unrelated, stray }, 'quiet');
|
|
29
|
+
return { committed: false, unrelated, stray };
|
|
30
|
+
}
|
|
31
|
+
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
32
|
+
if (!(await commitWork(job, before, subject, 'fixer'))) {
|
|
33
|
+
log.event('commit.empty', { reason }, 'quiet');
|
|
34
|
+
return { committed: false };
|
|
35
|
+
}
|
|
36
|
+
await ports.push(worktree, context.config.remote, branch, force);
|
|
37
|
+
return { committed: true };
|
|
38
|
+
}
|
|
39
|
+
export async function commitWork(job, before, subject, role) {
|
|
40
|
+
const { context, issue, log, state, worktree, comments } = job;
|
|
41
|
+
const { repo, config, ports } = context;
|
|
42
|
+
for (;;) {
|
|
43
|
+
try {
|
|
44
|
+
if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
45
|
+
log.event('commit', { subject });
|
|
46
|
+
}
|
|
47
|
+
if ((await ports.revision(worktree)) === before) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
state.title = subject;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const used = state.commitFixes ?? 0;
|
|
55
|
+
if (!(error instanceof HookError) || used >= config.maxCommitFixes) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
state.commitFixes = used + 1;
|
|
59
|
+
await writeState(repo, state);
|
|
60
|
+
const output = message(error).slice(-config.logMaxChars);
|
|
61
|
+
log.event('commit.fail', { role, output }, 'quiet');
|
|
62
|
+
const fix = await log.step('commit-fix', { role }, () => ports.runAgent(log, {
|
|
63
|
+
name: role,
|
|
64
|
+
cwd: worktree,
|
|
65
|
+
prompt: hookPrompt(issue, comments, role, output),
|
|
66
|
+
profile: CODING,
|
|
67
|
+
model: config.models[role],
|
|
68
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
69
|
+
}));
|
|
70
|
+
subject = parseCommitSubject(fix.text, subject);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/gate.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fixRound } from './fix.mjs';
|
|
2
|
+
import { escalate, finish } from './handover.mjs';
|
|
3
|
+
import { history } from './job.mjs';
|
|
4
|
+
import { writeState } from './state.mjs';
|
|
5
|
+
export async function checkGate(job) {
|
|
6
|
+
const { context, log, state, worktree } = job;
|
|
7
|
+
const { repo, config, ports } = context;
|
|
8
|
+
const head = await ports.revision(worktree);
|
|
9
|
+
const checks = await log.step('checks', { pr: state.pr, head }, () => ports.waitForChecks(repo, state.pr, {
|
|
10
|
+
head,
|
|
11
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
12
|
+
graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
13
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
14
|
+
show: log.level === 'verbose',
|
|
15
|
+
}));
|
|
16
|
+
log.event('checks.state', { state: checks });
|
|
17
|
+
job.noChecks ||= checks === 'none';
|
|
18
|
+
if (checks === 'timeout') {
|
|
19
|
+
await escalate(job, 'The checks did not finish in time.');
|
|
20
|
+
return { done: true, report: await finish(job, 'needs-human', 'checks timeout') };
|
|
21
|
+
}
|
|
22
|
+
if (checks === 'none' &&
|
|
23
|
+
(await ports.prConflicts(repo, state.pr, {
|
|
24
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
25
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
26
|
+
}))) {
|
|
27
|
+
await escalate(job, 'The pull request conflicts with its base, so no checks ran.');
|
|
28
|
+
return { done: true, report: await finish(job, 'needs-human', 'merge conflict') };
|
|
29
|
+
}
|
|
30
|
+
if (checks === 'moved') {
|
|
31
|
+
await escalate(job, 'Someone pushed to the pull request while the harness waited for the checks.');
|
|
32
|
+
return { done: true, report: await finish(job, 'needs-human', 'pull request moved') };
|
|
33
|
+
}
|
|
34
|
+
if (checks !== 'fail') {
|
|
35
|
+
return { done: false, fixed: false };
|
|
36
|
+
}
|
|
37
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
38
|
+
await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
39
|
+
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
40
|
+
}
|
|
41
|
+
state.ciFixes += 1;
|
|
42
|
+
await writeState(repo, state);
|
|
43
|
+
const logs = await ports.failedCheckLogs(repo, state.pr, config.logMaxChars);
|
|
44
|
+
log.event('checks.logs', { chars: logs.length });
|
|
45
|
+
const fix = await fixRound(job, 'The continuous integration checks failed.', logs, history(state.reviewLog), 'checks');
|
|
46
|
+
if (!fix.committed) {
|
|
47
|
+
const note = fix.unrelated;
|
|
48
|
+
await escalate(job, note === undefined
|
|
49
|
+
? 'The fixer added no commit for the failed checks.'
|
|
50
|
+
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
51
|
+
return {
|
|
52
|
+
done: true,
|
|
53
|
+
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { done: false, fixed: true };
|
|
57
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { setStatus } from './job.mjs';
|
|
2
|
+
import { message } from './observe.mjs';
|
|
3
|
+
import { dropState, writeState } from './state.mjs';
|
|
4
|
+
export function escalate(job, reason) {
|
|
5
|
+
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
6
|
+
}
|
|
7
|
+
export function stop(job) {
|
|
8
|
+
const { issue, log, state } = job;
|
|
9
|
+
log.event('issue.stop', { phase: state.phase }, 'quiet');
|
|
10
|
+
return {
|
|
11
|
+
issue: issue.number,
|
|
12
|
+
outcome: 'stopped',
|
|
13
|
+
pr: state.pr,
|
|
14
|
+
ciFixes: state.ciFixes,
|
|
15
|
+
reviewRounds: state.reviewRounds,
|
|
16
|
+
ms: Date.now() - job.started,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export async function finish(job, outcome, reason) {
|
|
20
|
+
const { context, issue, log, state, branch } = job;
|
|
21
|
+
const { repo, ports } = context;
|
|
22
|
+
await cleanUp(context, log, branch);
|
|
23
|
+
if (outcome === 'done') {
|
|
24
|
+
for (const name of [branch, ...(await ports.disputeBranches(repo, branch))]) {
|
|
25
|
+
if (!(await ports.deleteBranch(repo, name))) {
|
|
26
|
+
log.event('branch.kept', { branch: name }, 'quiet');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await dropState(repo, issue.number);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
issue: issue.number,
|
|
33
|
+
outcome,
|
|
34
|
+
reason,
|
|
35
|
+
pr: state.pr,
|
|
36
|
+
ciFixes: state.ciFixes,
|
|
37
|
+
reviewRounds: state.reviewRounds,
|
|
38
|
+
ms: Date.now() - job.started,
|
|
39
|
+
...(job.mainBroken && outcome !== 'done' ? { halt: true } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export async function cleanUp(context, log, branch) {
|
|
43
|
+
try {
|
|
44
|
+
const path = await context.ports.findWorktree(context.repo, branch);
|
|
45
|
+
if (path === undefined) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!(await context.ports.removeWorktree(context.repo, path))) {
|
|
49
|
+
log.event('worktree.kept', { path }, 'quiet');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
log.event('worktree.error', { error: message(error) }, 'quiet');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function handOver(context, log, issue, state, reason) {
|
|
57
|
+
log.event('hand-over', { reason }, 'quiet');
|
|
58
|
+
await writeState(context.repo, state);
|
|
59
|
+
await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
|
|
60
|
+
const body = `next-issue needs help: ${reason}`;
|
|
61
|
+
if (state.pr === undefined) {
|
|
62
|
+
await context.ports.commentOnIssue(context.repo, issue, body);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
await context.ports.commentOnPr(context.repo, state.pr, body);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function skipped(issue) {
|
|
69
|
+
return { issue: issue.number, outcome: 'skipped', ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
70
|
+
}
|
package/dist/job.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { managedLabels } from './config.mjs';
|
|
2
|
+
import { writeState } from './state.mjs';
|
|
3
|
+
export async function openPr(job) {
|
|
4
|
+
const { context, issue, log, state, branch } = job;
|
|
5
|
+
const { repo, config, ports } = context;
|
|
6
|
+
const pr = await log.step('pull-request', { branch }, async () => {
|
|
7
|
+
const existing = await ports.findPr(repo, branch);
|
|
8
|
+
return (existing ??
|
|
9
|
+
ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
10
|
+
});
|
|
11
|
+
state.pr = pr;
|
|
12
|
+
await writeState(repo, state);
|
|
13
|
+
log.event('pull-request.ready', { pr }, 'quiet');
|
|
14
|
+
}
|
|
15
|
+
export async function setStatus(context, issue, pr, label) {
|
|
16
|
+
const managed = managedLabels(context.config);
|
|
17
|
+
await context.ports.setLabel(context.repo, 'issue', issue, label, managed);
|
|
18
|
+
if (pr !== undefined) {
|
|
19
|
+
await context.ports.setLabel(context.repo, 'pr', pr, label, managed);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function history(rounds) {
|
|
23
|
+
return rounds.map((round) => {
|
|
24
|
+
const head = `Round ${round.round}:\n${round.findings}`;
|
|
25
|
+
return round.dispute === undefined
|
|
26
|
+
? head
|
|
27
|
+
: `${head}\nThe fixer disputed every finding of this round: ${round.dispute}`;
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/merge.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { fixRound } from './fix.mjs';
|
|
2
|
+
import { escalate, finish } from './handover.mjs';
|
|
3
|
+
import { openPr, setStatus } from './job.mjs';
|
|
4
|
+
import { writeState } from './state.mjs';
|
|
5
|
+
export async function afterMerge(job) {
|
|
6
|
+
const { context, issue, state } = job;
|
|
7
|
+
const watched = await watchBase(job);
|
|
8
|
+
if (watched.done) {
|
|
9
|
+
return { done: true, report: watched.report };
|
|
10
|
+
}
|
|
11
|
+
if (watched.fixed) {
|
|
12
|
+
return { done: false };
|
|
13
|
+
}
|
|
14
|
+
await setStatus(context, issue.number, state.pr, context.config.labels.done);
|
|
15
|
+
return { done: true, report: await finish(job, 'done') };
|
|
16
|
+
}
|
|
17
|
+
async function watchBase(job) {
|
|
18
|
+
const { context, log, state, worktree } = job;
|
|
19
|
+
const { repo, config, ports } = context;
|
|
20
|
+
const sha = await ports.mergeCommit(repo, state.pr, {
|
|
21
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
22
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
23
|
+
});
|
|
24
|
+
if (sha === undefined) {
|
|
25
|
+
await escalate(job, 'The pull request did not merge in time, or it closed without a merge.');
|
|
26
|
+
return { done: true, report: await finish(job, 'needs-human', 'not merged') };
|
|
27
|
+
}
|
|
28
|
+
const checks = await log.step('main-checks', { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
|
|
29
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
30
|
+
graceMs: config.checkGraceSeconds * 1000,
|
|
31
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
32
|
+
}));
|
|
33
|
+
log.event('main-checks.state', { state: checks });
|
|
34
|
+
if (checks === 'pass' || checks === 'none') {
|
|
35
|
+
return { done: false, fixed: false };
|
|
36
|
+
}
|
|
37
|
+
job.mainBroken = true;
|
|
38
|
+
if (checks !== 'fail') {
|
|
39
|
+
await escalate(job, 'The checks on main did not finish in time.');
|
|
40
|
+
return { done: true, report: await finish(job, 'needs-human', 'main checks timeout') };
|
|
41
|
+
}
|
|
42
|
+
if (state.ciFixes >= config.maxCiFixes) {
|
|
43
|
+
await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
44
|
+
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
45
|
+
}
|
|
46
|
+
await ports.resetToBase(worktree, context.base, config.remote);
|
|
47
|
+
state.ciFixes += 1;
|
|
48
|
+
state.baseFix = { sha, head: await ports.revision(worktree) };
|
|
49
|
+
await writeState(repo, state);
|
|
50
|
+
return fixBase(job);
|
|
51
|
+
}
|
|
52
|
+
export async function fixBase(job) {
|
|
53
|
+
const { context, issue, log, state } = job;
|
|
54
|
+
const { repo, config, ports } = context;
|
|
55
|
+
const { sha, head } = state.baseFix;
|
|
56
|
+
const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
|
|
57
|
+
log.event('main-checks.logs', { chars: logs.length });
|
|
58
|
+
const fix = await fixRound(job, 'The checks on main failed after the merge.', logs, [], 'checks', true, head);
|
|
59
|
+
if (!fix.committed) {
|
|
60
|
+
const note = fix.unrelated;
|
|
61
|
+
await escalate(job, note === undefined
|
|
62
|
+
? 'The fixer added no commit for the failed checks on main.'
|
|
63
|
+
: `The checks on main fail for a reason this change did not cause: ${note}`);
|
|
64
|
+
return {
|
|
65
|
+
done: true,
|
|
66
|
+
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
await ports.reopenIssue(repo, issue.number);
|
|
70
|
+
state.reviewRounds = 0;
|
|
71
|
+
state.reviewLog = [];
|
|
72
|
+
state.baseFix = undefined;
|
|
73
|
+
await openPr(job);
|
|
74
|
+
return { done: false, fixed: true };
|
|
75
|
+
}
|
package/dist/pipeline.mjs
CHANGED
|
@@ -1,51 +1,15 @@
|
|
|
1
|
-
import { CODING
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { CODING } from './agents.mjs';
|
|
2
|
+
import { commitWork } from './fix.mjs';
|
|
3
|
+
import { checkGate } from './gate.mjs';
|
|
4
|
+
import { branchName } from './git.mjs';
|
|
5
|
+
import { cleanUp, escalate, finish, handOver, skipped, stop } from './handover.mjs';
|
|
6
|
+
import { openPr, setStatus } from './job.mjs';
|
|
7
|
+
import { afterMerge, fixBase } from './merge.mjs';
|
|
6
8
|
import { message } from './observe.mjs';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
export const PORTS = {
|
|
12
|
-
addWorktree,
|
|
13
|
-
commentOnIssue,
|
|
14
|
-
commentOnPr,
|
|
15
|
-
commitAll,
|
|
16
|
-
createPr,
|
|
17
|
-
deleteBranch,
|
|
18
|
-
detectSetupCommand,
|
|
19
|
-
diff,
|
|
20
|
-
discardChanges,
|
|
21
|
-
disputeBranches,
|
|
22
|
-
failedBaseLogs,
|
|
23
|
-
failedCheckLogs,
|
|
24
|
-
findPr,
|
|
25
|
-
findWorktree,
|
|
26
|
-
isDirty,
|
|
27
|
-
issueComments,
|
|
28
|
-
latestSubject,
|
|
29
|
-
markPrReady,
|
|
30
|
-
mergeCommit,
|
|
31
|
-
mergePr,
|
|
32
|
-
openBlockers,
|
|
33
|
-
openIssues,
|
|
34
|
-
prConflicts,
|
|
35
|
-
prMerged,
|
|
36
|
-
push,
|
|
37
|
-
removeWorktree,
|
|
38
|
-
reopenIssue,
|
|
39
|
-
resetToBase,
|
|
40
|
-
revision,
|
|
41
|
-
runAgent,
|
|
42
|
-
runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
|
|
43
|
-
setAside,
|
|
44
|
-
setLabel,
|
|
45
|
-
viewIssue,
|
|
46
|
-
waitForBaseChecks,
|
|
47
|
-
waitForChecks,
|
|
48
|
-
};
|
|
9
|
+
import { implementPrompt, parseCommitSubject } from './prompts.mjs';
|
|
10
|
+
import { reviewRound } from './review.mjs';
|
|
11
|
+
import { readState, resetState, savedIssues, writeState } from './state.mjs';
|
|
12
|
+
export { PORTS } from './ports.mjs';
|
|
49
13
|
export function skipReason(issue, config, resuming) {
|
|
50
14
|
const labels = new Set(issue.labels);
|
|
51
15
|
const names = config.labels;
|
|
@@ -168,9 +132,6 @@ export async function processIssue(context, issue) {
|
|
|
168
132
|
};
|
|
169
133
|
}
|
|
170
134
|
}
|
|
171
|
-
function skipped(issue) {
|
|
172
|
-
return { issue: issue.number, outcome: 'skipped', ciFixes: 0, reviewRounds: 0, ms: 0 };
|
|
173
|
-
}
|
|
174
135
|
async function work(context, issue, log, state, start) {
|
|
175
136
|
const started = Date.now();
|
|
176
137
|
const { repo, config, ports } = context;
|
|
@@ -280,381 +241,3 @@ async function work(context, issue, log, state, start) {
|
|
|
280
241
|
}
|
|
281
242
|
}
|
|
282
243
|
}
|
|
283
|
-
async function openPr(job) {
|
|
284
|
-
const { context, issue, log, state, branch } = job;
|
|
285
|
-
const { repo, config, ports } = context;
|
|
286
|
-
const pr = await log.step('pull-request', { branch }, async () => {
|
|
287
|
-
const existing = await ports.findPr(repo, branch);
|
|
288
|
-
return (existing ??
|
|
289
|
-
ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
290
|
-
});
|
|
291
|
-
state.pr = pr;
|
|
292
|
-
await writeState(repo, state);
|
|
293
|
-
log.event('pull-request.ready', { pr }, 'quiet');
|
|
294
|
-
}
|
|
295
|
-
async function checkGate(job) {
|
|
296
|
-
const { context, log, state, worktree } = job;
|
|
297
|
-
const { repo, config, ports } = context;
|
|
298
|
-
const head = await ports.revision(worktree);
|
|
299
|
-
const checks = await log.step('checks', { pr: state.pr, head }, () => ports.waitForChecks(repo, state.pr, {
|
|
300
|
-
head,
|
|
301
|
-
intervalSeconds: config.checkIntervalSeconds,
|
|
302
|
-
graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
|
|
303
|
-
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
304
|
-
show: log.level === 'verbose',
|
|
305
|
-
}));
|
|
306
|
-
log.event('checks.state', { state: checks });
|
|
307
|
-
job.noChecks ||= checks === 'none';
|
|
308
|
-
if (checks === 'timeout') {
|
|
309
|
-
await escalate(job, 'The checks did not finish in time.');
|
|
310
|
-
return { done: true, report: await finish(job, 'needs-human', 'checks timeout') };
|
|
311
|
-
}
|
|
312
|
-
if (checks === 'none' &&
|
|
313
|
-
(await ports.prConflicts(repo, state.pr, {
|
|
314
|
-
intervalSeconds: config.checkIntervalSeconds,
|
|
315
|
-
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
316
|
-
}))) {
|
|
317
|
-
await escalate(job, 'The pull request conflicts with its base, so no checks ran.');
|
|
318
|
-
return { done: true, report: await finish(job, 'needs-human', 'merge conflict') };
|
|
319
|
-
}
|
|
320
|
-
if (checks === 'moved') {
|
|
321
|
-
await escalate(job, 'Someone pushed to the pull request while the harness waited for the checks.');
|
|
322
|
-
return { done: true, report: await finish(job, 'needs-human', 'pull request moved') };
|
|
323
|
-
}
|
|
324
|
-
if (checks !== 'fail') {
|
|
325
|
-
return { done: false, fixed: false };
|
|
326
|
-
}
|
|
327
|
-
if (state.ciFixes >= config.maxCiFixes) {
|
|
328
|
-
await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
329
|
-
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
330
|
-
}
|
|
331
|
-
state.ciFixes += 1;
|
|
332
|
-
await writeState(repo, state);
|
|
333
|
-
const logs = await ports.failedCheckLogs(repo, state.pr, config.logMaxChars);
|
|
334
|
-
log.event('checks.logs', { chars: logs.length });
|
|
335
|
-
const fix = await fixRound(job, 'The continuous integration checks failed.', logs, history(state.reviewLog), 'checks');
|
|
336
|
-
if (!fix.committed) {
|
|
337
|
-
const note = fix.unrelated;
|
|
338
|
-
await escalate(job, note === undefined
|
|
339
|
-
? 'The fixer added no commit for the failed checks.'
|
|
340
|
-
: `The checks fail for a reason this change did not cause: ${note}`);
|
|
341
|
-
return {
|
|
342
|
-
done: true,
|
|
343
|
-
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
return { done: false, fixed: true };
|
|
347
|
-
}
|
|
348
|
-
async function reviewRound(job) {
|
|
349
|
-
const { context, issue, log, state, worktree, comments } = job;
|
|
350
|
-
const { repo, config, ports } = context;
|
|
351
|
-
const pr = state.pr;
|
|
352
|
-
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
353
|
-
await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
|
|
354
|
-
return { done: true, report: await finish(job, 'needs-human', 'review budget') };
|
|
355
|
-
}
|
|
356
|
-
state.reviewRounds += 1;
|
|
357
|
-
await writeState(repo, state);
|
|
358
|
-
await setStatus(context, issue.number, pr, config.labels.inReview);
|
|
359
|
-
const full = await ports.diff(worktree, context.base, config.remote);
|
|
360
|
-
const patch = capDiff(full, config.diffMaxChars);
|
|
361
|
-
const fields = {
|
|
362
|
-
round: state.reviewRounds,
|
|
363
|
-
diffChars: full.length,
|
|
364
|
-
cut: full.length > config.diffMaxChars,
|
|
365
|
-
};
|
|
366
|
-
const before = await ports.revision(worktree);
|
|
367
|
-
const review = await log.step('review', fields, () => ports.runAgent(log, {
|
|
368
|
-
name: 'reviewer',
|
|
369
|
-
cwd: worktree,
|
|
370
|
-
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
371
|
-
profile: REVIEWING,
|
|
372
|
-
model: config.models.reviewer,
|
|
373
|
-
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
374
|
-
}));
|
|
375
|
-
if (await ports.discardChanges(worktree, before)) {
|
|
376
|
-
log.event('review.discard', { round: state.reviewRounds }, 'quiet');
|
|
377
|
-
}
|
|
378
|
-
const verdict = parseVerdict(review.text);
|
|
379
|
-
if (verdict === undefined) {
|
|
380
|
-
await escalate(job, 'The reviewer gave no verdict.');
|
|
381
|
-
return { done: true, report: await finish(job, 'needs-human', 'no verdict') };
|
|
382
|
-
}
|
|
383
|
-
const blocking = blockingFindings(verdict);
|
|
384
|
-
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
385
|
-
log.event('verdict', {
|
|
386
|
-
round: state.reviewRounds,
|
|
387
|
-
blocking: blocking.length,
|
|
388
|
-
minor: verdict.findings.length - blocking.length,
|
|
389
|
-
open: open.length,
|
|
390
|
-
summary: verdict.summary,
|
|
391
|
-
}, 'quiet');
|
|
392
|
-
await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
|
|
393
|
-
if (open.length === 0) {
|
|
394
|
-
const gate = await checkGate(job);
|
|
395
|
-
if (gate.done) {
|
|
396
|
-
return { done: true, report: gate.report };
|
|
397
|
-
}
|
|
398
|
-
if (gate.fixed) {
|
|
399
|
-
return { done: false };
|
|
400
|
-
}
|
|
401
|
-
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
402
|
-
log.event('pull-request.undraft.fail', { pr }, 'quiet');
|
|
403
|
-
}
|
|
404
|
-
if (context.merge) {
|
|
405
|
-
if (!(await ports.mergePr(repo, pr))) {
|
|
406
|
-
await escalate(job, 'The merge of the pull request failed.');
|
|
407
|
-
return { done: true, report: await finish(job, 'needs-human', 'merge failed') };
|
|
408
|
-
}
|
|
409
|
-
return afterMerge(job);
|
|
410
|
-
}
|
|
411
|
-
await setStatus(context, issue.number, pr, config.labels.done);
|
|
412
|
-
return { done: true, report: await finish(job, 'done') };
|
|
413
|
-
}
|
|
414
|
-
const mark = fingerprint(open);
|
|
415
|
-
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
416
|
-
await escalate(job, 'The reviewer repeated findings that an earlier round did not fix.');
|
|
417
|
-
return { done: true, report: await finish(job, 'needs-human', 'repeated findings') };
|
|
418
|
-
}
|
|
419
|
-
const earlier = history(state.reviewLog);
|
|
420
|
-
state.reviewLog.push({
|
|
421
|
-
round: state.reviewRounds,
|
|
422
|
-
fingerprint: mark,
|
|
423
|
-
findings: formatFindings(open),
|
|
424
|
-
});
|
|
425
|
-
await writeState(repo, state);
|
|
426
|
-
const fix = await fixRound(job, 'The reviewer requested changes.', formatFindings(open), earlier, 'review');
|
|
427
|
-
if (!fix.committed) {
|
|
428
|
-
const note = fix.unrelated;
|
|
429
|
-
if (note === undefined) {
|
|
430
|
-
await escalate(job, 'The fixer added no commit for the review findings.');
|
|
431
|
-
return { done: true, report: await finish(job, 'needs-human', 'no fix commit') };
|
|
432
|
-
}
|
|
433
|
-
if (fix.stray === true) {
|
|
434
|
-
await escalate(job, `The fixer disputed the findings and left a change behind: ${note}`);
|
|
435
|
-
return { done: true, report: await finish(job, 'needs-human', 'dispute with changes') };
|
|
436
|
-
}
|
|
437
|
-
state.reviewLog.at(-1).dispute = note;
|
|
438
|
-
await writeState(repo, state);
|
|
439
|
-
await ports.commentOnPr(repo, pr, `### Fixer: no change\n\n${note}`);
|
|
440
|
-
}
|
|
441
|
-
return { done: false };
|
|
442
|
-
}
|
|
443
|
-
async function afterMerge(job) {
|
|
444
|
-
const { context, issue, state } = job;
|
|
445
|
-
const watched = await watchBase(job);
|
|
446
|
-
if (watched.done) {
|
|
447
|
-
return { done: true, report: watched.report };
|
|
448
|
-
}
|
|
449
|
-
if (watched.fixed) {
|
|
450
|
-
return { done: false };
|
|
451
|
-
}
|
|
452
|
-
await setStatus(context, issue.number, state.pr, context.config.labels.done);
|
|
453
|
-
return { done: true, report: await finish(job, 'done') };
|
|
454
|
-
}
|
|
455
|
-
async function watchBase(job) {
|
|
456
|
-
const { context, log, state, worktree } = job;
|
|
457
|
-
const { repo, config, ports } = context;
|
|
458
|
-
const sha = await ports.mergeCommit(repo, state.pr, {
|
|
459
|
-
intervalSeconds: config.checkIntervalSeconds,
|
|
460
|
-
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
461
|
-
});
|
|
462
|
-
if (sha === undefined) {
|
|
463
|
-
await escalate(job, 'The pull request did not merge in time, or it closed without a merge.');
|
|
464
|
-
return { done: true, report: await finish(job, 'needs-human', 'not merged') };
|
|
465
|
-
}
|
|
466
|
-
const checks = await log.step('main-checks', { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
|
|
467
|
-
intervalSeconds: config.checkIntervalSeconds,
|
|
468
|
-
graceMs: config.checkGraceSeconds * 1000,
|
|
469
|
-
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
470
|
-
}));
|
|
471
|
-
log.event('main-checks.state', { state: checks });
|
|
472
|
-
if (checks === 'pass' || checks === 'none') {
|
|
473
|
-
return { done: false, fixed: false };
|
|
474
|
-
}
|
|
475
|
-
job.mainBroken = true;
|
|
476
|
-
if (checks !== 'fail') {
|
|
477
|
-
await escalate(job, 'The checks on main did not finish in time.');
|
|
478
|
-
return { done: true, report: await finish(job, 'needs-human', 'main checks timeout') };
|
|
479
|
-
}
|
|
480
|
-
if (state.ciFixes >= config.maxCiFixes) {
|
|
481
|
-
await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
|
|
482
|
-
return { done: true, report: await finish(job, 'needs-human', 'check budget') };
|
|
483
|
-
}
|
|
484
|
-
await ports.resetToBase(worktree, context.base, config.remote);
|
|
485
|
-
state.ciFixes += 1;
|
|
486
|
-
state.baseFix = { sha, head: await ports.revision(worktree) };
|
|
487
|
-
await writeState(repo, state);
|
|
488
|
-
return fixBase(job);
|
|
489
|
-
}
|
|
490
|
-
async function fixBase(job) {
|
|
491
|
-
const { context, issue, log, state } = job;
|
|
492
|
-
const { repo, config, ports } = context;
|
|
493
|
-
const { sha, head } = state.baseFix;
|
|
494
|
-
const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
|
|
495
|
-
log.event('main-checks.logs', { chars: logs.length });
|
|
496
|
-
const fix = await fixRound(job, 'The checks on main failed after the merge.', logs, [], 'checks', true, head);
|
|
497
|
-
if (!fix.committed) {
|
|
498
|
-
const note = fix.unrelated;
|
|
499
|
-
await escalate(job, note === undefined
|
|
500
|
-
? 'The fixer added no commit for the failed checks on main.'
|
|
501
|
-
: `The checks on main fail for a reason this change did not cause: ${note}`);
|
|
502
|
-
return {
|
|
503
|
-
done: true,
|
|
504
|
-
report: await finish(job, 'needs-human', note === undefined ? 'no fix commit' : 'unrelated failure'),
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
await ports.reopenIssue(repo, issue.number);
|
|
508
|
-
state.reviewRounds = 0;
|
|
509
|
-
state.reviewLog = [];
|
|
510
|
-
state.baseFix = undefined;
|
|
511
|
-
await openPr(job);
|
|
512
|
-
return { done: false, fixed: true };
|
|
513
|
-
}
|
|
514
|
-
function escalate(job, reason) {
|
|
515
|
-
return handOver(job.context, job.log, job.issue.number, job.state, reason);
|
|
516
|
-
}
|
|
517
|
-
function stop(job) {
|
|
518
|
-
const { issue, log, state } = job;
|
|
519
|
-
log.event('issue.stop', { phase: state.phase }, 'quiet');
|
|
520
|
-
return {
|
|
521
|
-
issue: issue.number,
|
|
522
|
-
outcome: 'stopped',
|
|
523
|
-
pr: state.pr,
|
|
524
|
-
ciFixes: state.ciFixes,
|
|
525
|
-
reviewRounds: state.reviewRounds,
|
|
526
|
-
ms: Date.now() - job.started,
|
|
527
|
-
};
|
|
528
|
-
}
|
|
529
|
-
async function finish(job, outcome, reason) {
|
|
530
|
-
const { context, issue, log, state, branch } = job;
|
|
531
|
-
const { repo, ports } = context;
|
|
532
|
-
await cleanUp(context, log, branch);
|
|
533
|
-
if (outcome === 'done') {
|
|
534
|
-
for (const name of [branch, ...(await ports.disputeBranches(repo, branch))]) {
|
|
535
|
-
if (!(await ports.deleteBranch(repo, name))) {
|
|
536
|
-
log.event('branch.kept', { branch: name }, 'quiet');
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
await dropState(repo, issue.number);
|
|
540
|
-
}
|
|
541
|
-
return {
|
|
542
|
-
issue: issue.number,
|
|
543
|
-
outcome,
|
|
544
|
-
reason,
|
|
545
|
-
pr: state.pr,
|
|
546
|
-
ciFixes: state.ciFixes,
|
|
547
|
-
reviewRounds: state.reviewRounds,
|
|
548
|
-
ms: Date.now() - job.started,
|
|
549
|
-
...(job.mainBroken && outcome !== 'done' ? { halt: true } : {}),
|
|
550
|
-
};
|
|
551
|
-
}
|
|
552
|
-
async function fixRound(job, reason, detail, earlier, kind, force = false, from) {
|
|
553
|
-
const { context, issue, log, worktree, branch } = job;
|
|
554
|
-
const { ports } = context;
|
|
555
|
-
const before = from ?? (await ports.revision(worktree));
|
|
556
|
-
const result = await log.step('fix', { reason }, () => ports.runAgent(log, {
|
|
557
|
-
name: 'fixer',
|
|
558
|
-
cwd: worktree,
|
|
559
|
-
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
560
|
-
profile: CODING,
|
|
561
|
-
model: context.config.models.fixer,
|
|
562
|
-
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
|
563
|
-
}));
|
|
564
|
-
const unrelated = parseUnrelated(result.text);
|
|
565
|
-
if (unrelated !== undefined) {
|
|
566
|
-
const disputed = await ports.revision(worktree);
|
|
567
|
-
const moved = disputed !== before;
|
|
568
|
-
if (moved) {
|
|
569
|
-
const aside = `${branch}-dispute-${disputed.slice(0, 7)}`;
|
|
570
|
-
await ports.setAside(worktree, before, aside);
|
|
571
|
-
log.event('fix.aside', { branch: aside }, 'quiet');
|
|
572
|
-
}
|
|
573
|
-
const stray = moved || (await ports.isDirty(worktree));
|
|
574
|
-
log.event('fix.unrelated', { reason, note: unrelated, stray }, 'quiet');
|
|
575
|
-
return { committed: false, unrelated, stray };
|
|
576
|
-
}
|
|
577
|
-
const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
|
|
578
|
-
if (!(await commitWork(job, before, subject, 'fixer'))) {
|
|
579
|
-
log.event('commit.empty', { reason }, 'quiet');
|
|
580
|
-
return { committed: false };
|
|
581
|
-
}
|
|
582
|
-
await ports.push(worktree, context.config.remote, branch, force);
|
|
583
|
-
return { committed: true };
|
|
584
|
-
}
|
|
585
|
-
async function commitWork(job, before, subject, role) {
|
|
586
|
-
const { context, issue, log, state, worktree, comments } = job;
|
|
587
|
-
const { repo, config, ports } = context;
|
|
588
|
-
for (;;) {
|
|
589
|
-
try {
|
|
590
|
-
if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
591
|
-
log.event('commit', { subject });
|
|
592
|
-
}
|
|
593
|
-
if ((await ports.revision(worktree)) === before) {
|
|
594
|
-
return false;
|
|
595
|
-
}
|
|
596
|
-
state.title = subject;
|
|
597
|
-
return true;
|
|
598
|
-
}
|
|
599
|
-
catch (error) {
|
|
600
|
-
const used = state.commitFixes ?? 0;
|
|
601
|
-
if (!(error instanceof HookError) || used >= config.maxCommitFixes) {
|
|
602
|
-
throw error;
|
|
603
|
-
}
|
|
604
|
-
state.commitFixes = used + 1;
|
|
605
|
-
await writeState(repo, state);
|
|
606
|
-
const output = message(error).slice(-config.logMaxChars);
|
|
607
|
-
log.event('commit.fail', { role, output }, 'quiet');
|
|
608
|
-
const fix = await log.step('commit-fix', { role }, () => ports.runAgent(log, {
|
|
609
|
-
name: role,
|
|
610
|
-
cwd: worktree,
|
|
611
|
-
prompt: hookPrompt(issue, comments, role, output),
|
|
612
|
-
profile: CODING,
|
|
613
|
-
model: config.models[role],
|
|
614
|
-
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
615
|
-
}));
|
|
616
|
-
subject = parseCommitSubject(fix.text, subject);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
async function cleanUp(context, log, branch) {
|
|
621
|
-
try {
|
|
622
|
-
const path = await context.ports.findWorktree(context.repo, branch);
|
|
623
|
-
if (path === undefined) {
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
if (!(await context.ports.removeWorktree(context.repo, path))) {
|
|
627
|
-
log.event('worktree.kept', { path }, 'quiet');
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
catch (error) {
|
|
631
|
-
log.event('worktree.error', { error: message(error) }, 'quiet');
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
async function handOver(context, log, issue, state, reason) {
|
|
635
|
-
log.event('hand-over', { reason }, 'quiet');
|
|
636
|
-
await writeState(context.repo, state);
|
|
637
|
-
await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
|
|
638
|
-
const body = `next-issue needs help: ${reason}`;
|
|
639
|
-
if (state.pr === undefined) {
|
|
640
|
-
await context.ports.commentOnIssue(context.repo, issue, body);
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
await context.ports.commentOnPr(context.repo, state.pr, body);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
async function setStatus(context, issue, pr, label) {
|
|
647
|
-
const managed = managedLabels(context.config);
|
|
648
|
-
await context.ports.setLabel(context.repo, 'issue', issue, label, managed);
|
|
649
|
-
if (pr !== undefined) {
|
|
650
|
-
await context.ports.setLabel(context.repo, 'pr', pr, label, managed);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
function history(rounds) {
|
|
654
|
-
return rounds.map((round) => {
|
|
655
|
-
const head = `Round ${round.round}:\n${round.findings}`;
|
|
656
|
-
return round.dispute === undefined
|
|
657
|
-
? head
|
|
658
|
-
: `${head}\nThe fixer disputed every finding of this round: ${round.dispute}`;
|
|
659
|
-
});
|
|
660
|
-
}
|
package/dist/ports.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { runAgent } from './agents.mjs';
|
|
2
|
+
import { run } from './exec.mjs';
|
|
3
|
+
import { addWorktree, commitAll, deleteBranch, diff, discardChanges, disputeBranches, findWorktree, isDirty, latestSubject, push, removeWorktree, resetToBase, revision, setAside, } from './git.mjs';
|
|
4
|
+
import { commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
|
|
5
|
+
import { detectSetupCommand } from './setup.mjs';
|
|
6
|
+
export const PORTS = {
|
|
7
|
+
addWorktree,
|
|
8
|
+
commentOnIssue,
|
|
9
|
+
commentOnPr,
|
|
10
|
+
commitAll,
|
|
11
|
+
createPr,
|
|
12
|
+
deleteBranch,
|
|
13
|
+
detectSetupCommand,
|
|
14
|
+
diff,
|
|
15
|
+
discardChanges,
|
|
16
|
+
disputeBranches,
|
|
17
|
+
failedBaseLogs,
|
|
18
|
+
failedCheckLogs,
|
|
19
|
+
findPr,
|
|
20
|
+
findWorktree,
|
|
21
|
+
isDirty,
|
|
22
|
+
issueComments,
|
|
23
|
+
latestSubject,
|
|
24
|
+
markPrReady,
|
|
25
|
+
mergeCommit,
|
|
26
|
+
mergePr,
|
|
27
|
+
openBlockers,
|
|
28
|
+
openIssues,
|
|
29
|
+
prConflicts,
|
|
30
|
+
prMerged,
|
|
31
|
+
push,
|
|
32
|
+
removeWorktree,
|
|
33
|
+
reopenIssue,
|
|
34
|
+
resetToBase,
|
|
35
|
+
revision,
|
|
36
|
+
runAgent,
|
|
37
|
+
runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
|
|
38
|
+
setAside,
|
|
39
|
+
setLabel,
|
|
40
|
+
viewIssue,
|
|
41
|
+
waitForBaseChecks,
|
|
42
|
+
waitForChecks,
|
|
43
|
+
};
|
package/dist/review.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { REVIEWING } from './agents.mjs';
|
|
2
|
+
import { fixRound } from './fix.mjs';
|
|
3
|
+
import { checkGate } from './gate.mjs';
|
|
4
|
+
import { escalate, finish } from './handover.mjs';
|
|
5
|
+
import { history, setStatus } from './job.mjs';
|
|
6
|
+
import { afterMerge } from './merge.mjs';
|
|
7
|
+
import { capDiff, reviewPrompt } from './prompts.mjs';
|
|
8
|
+
import { writeState } from './state.mjs';
|
|
9
|
+
import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, parseVerdict, } from './verdict.mjs';
|
|
10
|
+
export async function reviewRound(job) {
|
|
11
|
+
const { context, issue, log, state, worktree, comments } = job;
|
|
12
|
+
const { repo, config, ports } = context;
|
|
13
|
+
const pr = state.pr;
|
|
14
|
+
if (state.reviewRounds >= config.maxReviewRounds) {
|
|
15
|
+
await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
|
|
16
|
+
return { done: true, report: await finish(job, 'needs-human', 'review budget') };
|
|
17
|
+
}
|
|
18
|
+
state.reviewRounds += 1;
|
|
19
|
+
await writeState(repo, state);
|
|
20
|
+
await setStatus(context, issue.number, pr, config.labels.inReview);
|
|
21
|
+
const full = await ports.diff(worktree, context.base, config.remote);
|
|
22
|
+
const patch = capDiff(full, config.diffMaxChars);
|
|
23
|
+
const fields = {
|
|
24
|
+
round: state.reviewRounds,
|
|
25
|
+
diffChars: full.length,
|
|
26
|
+
cut: full.length > config.diffMaxChars,
|
|
27
|
+
};
|
|
28
|
+
const before = await ports.revision(worktree);
|
|
29
|
+
const review = await log.step('review', fields, () => ports.runAgent(log, {
|
|
30
|
+
name: 'reviewer',
|
|
31
|
+
cwd: worktree,
|
|
32
|
+
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
33
|
+
profile: REVIEWING,
|
|
34
|
+
model: config.models.reviewer,
|
|
35
|
+
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
36
|
+
}));
|
|
37
|
+
if (await ports.discardChanges(worktree, before)) {
|
|
38
|
+
log.event('review.discard', { round: state.reviewRounds }, 'quiet');
|
|
39
|
+
}
|
|
40
|
+
const verdict = parseVerdict(review.text);
|
|
41
|
+
if (verdict === undefined) {
|
|
42
|
+
await escalate(job, 'The reviewer gave no verdict.');
|
|
43
|
+
return { done: true, report: await finish(job, 'needs-human', 'no verdict') };
|
|
44
|
+
}
|
|
45
|
+
const blocking = blockingFindings(verdict);
|
|
46
|
+
const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
|
|
47
|
+
log.event('verdict', {
|
|
48
|
+
round: state.reviewRounds,
|
|
49
|
+
blocking: blocking.length,
|
|
50
|
+
minor: verdict.findings.length - blocking.length,
|
|
51
|
+
open: open.length,
|
|
52
|
+
summary: verdict.summary,
|
|
53
|
+
}, 'quiet');
|
|
54
|
+
await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
|
|
55
|
+
if (open.length === 0) {
|
|
56
|
+
const gate = await checkGate(job);
|
|
57
|
+
if (gate.done) {
|
|
58
|
+
return { done: true, report: gate.report };
|
|
59
|
+
}
|
|
60
|
+
if (gate.fixed) {
|
|
61
|
+
return { done: false };
|
|
62
|
+
}
|
|
63
|
+
if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
|
|
64
|
+
log.event('pull-request.undraft.fail', { pr }, 'quiet');
|
|
65
|
+
}
|
|
66
|
+
if (context.merge) {
|
|
67
|
+
if (!(await ports.mergePr(repo, pr))) {
|
|
68
|
+
await escalate(job, 'The merge of the pull request failed.');
|
|
69
|
+
return { done: true, report: await finish(job, 'needs-human', 'merge failed') };
|
|
70
|
+
}
|
|
71
|
+
return afterMerge(job);
|
|
72
|
+
}
|
|
73
|
+
await setStatus(context, issue.number, pr, config.labels.done);
|
|
74
|
+
return { done: true, report: await finish(job, 'done') };
|
|
75
|
+
}
|
|
76
|
+
const mark = fingerprint(open);
|
|
77
|
+
if (state.reviewLog.some((round) => round.fingerprint === mark)) {
|
|
78
|
+
await escalate(job, 'The reviewer repeated findings that an earlier round did not fix.');
|
|
79
|
+
return { done: true, report: await finish(job, 'needs-human', 'repeated findings') };
|
|
80
|
+
}
|
|
81
|
+
const earlier = history(state.reviewLog);
|
|
82
|
+
state.reviewLog.push({
|
|
83
|
+
round: state.reviewRounds,
|
|
84
|
+
fingerprint: mark,
|
|
85
|
+
findings: formatFindings(open),
|
|
86
|
+
});
|
|
87
|
+
await writeState(repo, state);
|
|
88
|
+
const fix = await fixRound(job, 'The reviewer requested changes.', formatFindings(open), earlier, 'review');
|
|
89
|
+
if (!fix.committed) {
|
|
90
|
+
const note = fix.unrelated;
|
|
91
|
+
if (note === undefined) {
|
|
92
|
+
await escalate(job, 'The fixer added no commit for the review findings.');
|
|
93
|
+
return { done: true, report: await finish(job, 'needs-human', 'no fix commit') };
|
|
94
|
+
}
|
|
95
|
+
if (fix.stray === true) {
|
|
96
|
+
await escalate(job, `The fixer disputed the findings and left a change behind: ${note}`);
|
|
97
|
+
return { done: true, report: await finish(job, 'needs-human', 'dispute with changes') };
|
|
98
|
+
}
|
|
99
|
+
state.reviewLog.at(-1).dispute = note;
|
|
100
|
+
await writeState(repo, state);
|
|
101
|
+
await ports.commentOnPr(repo, pr, `### Fixer: no change\n\n${note}`);
|
|
102
|
+
}
|
|
103
|
+
return { done: false };
|
|
104
|
+
}
|