next-issue 2.5.9 → 2.6.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 +19 -6
- package/dist/fix.mjs +73 -0
- package/dist/gate.mjs +57 -0
- package/dist/git.mjs +7 -1
- 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
|
@@ -30,12 +30,13 @@ and exits with code 1. For each issue:
|
|
|
30
30
|
state keeps the branch name that the state holds, and a worktree that
|
|
31
31
|
holds that branch already stays in use. A branch that exists on the
|
|
32
32
|
remote is the start, and a local branch that fell behind the remote is
|
|
33
|
-
fast-forwarded.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
fast-forwarded. A local branch that the remote does not hold and that has
|
|
34
|
+
no commits of its own starts again from the remote base. An issue with no
|
|
35
|
+
saved state does not reuse a local `<n>` branch: it resets that branch,
|
|
36
|
+
and any worktree that holds it, to the remote branch, or to the remote
|
|
37
|
+
base when there is none. A fast-forward of the base that is not safe
|
|
38
|
+
leaves the local base branch as it is and writes a `base.behind` event. A
|
|
39
|
+
new branch then starts from the remote base.
|
|
39
40
|
4. Let the implementer agent do the work, then commit and push. When a git
|
|
40
41
|
hook stops a commit, the agent that made the change gets the hook output,
|
|
41
42
|
fixes it, and the harness commits again. A `commit:` line in the reply of
|
|
@@ -101,6 +102,18 @@ and exits with code 1. For each issue:
|
|
|
101
102
|
some findings wrong, the fixer fixes the correct ones, and the next round
|
|
102
103
|
holds the rest.
|
|
103
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
|
+
|
|
104
117
|
## Stops for a loop
|
|
105
118
|
|
|
106
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
|
+
}
|
package/dist/git.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export async function addWorktree(repo, issue, branch, base, remote, start) {
|
|
|
39
39
|
const path = existing ?? worktreePath(repo, issue);
|
|
40
40
|
const target = pushed ? upstream : `${remote}/${base}`;
|
|
41
41
|
if (existing === undefined) {
|
|
42
|
-
const args = !start && (await
|
|
42
|
+
const args = !start && (await hasWork(repo, branch, pushed, `${remote}/${base}`))
|
|
43
43
|
? ['worktree', 'add', path, branch]
|
|
44
44
|
: ['worktree', 'add', '-B', branch, path, target];
|
|
45
45
|
await must('git', args, { cwd: repo.root });
|
|
@@ -52,6 +52,12 @@ export async function addWorktree(repo, issue, branch, base, remote, start) {
|
|
|
52
52
|
}
|
|
53
53
|
return { path, baseUpdated };
|
|
54
54
|
}
|
|
55
|
+
async function hasWork(repo, branch, pushed, base) {
|
|
56
|
+
if (!(await hasBranch(repo, branch))) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return pushed || !(await isAncestor(repo.root, branch, base));
|
|
60
|
+
}
|
|
55
61
|
async function hasBranch(repo, name, ...flags) {
|
|
56
62
|
const list = await must('git', ['branch', '--list', ...flags, name], { cwd: repo.root });
|
|
57
63
|
return list.length > 0;
|
|
@@ -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
|
+
}
|