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 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. An issue with no saved state does not reuse a local
34
- `<n>` branch: it resets that branch, and any worktree that holds it, to
35
- the remote branch, or to the remote base when there is none. A
36
- fast-forward of the base that is not safe leaves the local base branch as
37
- it is and writes a `base.behind` event. A new branch then starts from the
38
- remote base.
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 hasBranch(repo, branch))
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
+ }
@@ -0,0 +1,7 @@
1
+ export const ISSUE_SECTIONS = [
2
+ 'Problem',
3
+ 'Context and findings',
4
+ 'Acceptance criteria',
5
+ 'Out of scope',
6
+ 'Open questions',
7
+ ];
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, 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, disputeBranches, findWorktree, HookError, isDirty, latestSubject, push, removeWorktree, resetToBase, revision, setAside, } from './git.mjs';
5
- import { commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
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 { 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, parseVerdict, } from './verdict.mjs';
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
+ };
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.5.9",
3
+ "version": "2.6.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",