next-issue 2.4.3 → 2.4.5

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
@@ -37,7 +37,8 @@ and exits with code 1. For each issue:
37
37
  hook stops a commit, the agent that made the change gets the hook output,
38
38
  fixes it, and the harness commits again. A `commit:` line in the reply of
39
39
  the agent replaces the subject, so a hook that rejects the subject can pass.
40
- The budget is `maxCommitFixes`.
40
+ The budget is `maxCommitFixes`. Any other git failure in the commit stops
41
+ the run with an error.
41
42
  5. Open a draft pull request that closes the issue. Its title is the subject
42
43
  of the commit that starts it, so a squash merge gives a conventional
43
44
  commit.
@@ -100,13 +101,19 @@ when:
100
101
  - the fixer reports that the checks fail for a reason the change did not cause,
101
102
  such as a test already broken on the base branch or one that fails only under
102
103
  load. It changes nothing and the reason reaches the pull request, rather than
103
- the fixer spending its whole budget on a failure it cannot fix;
104
+ the fixer spending its whole budget on a failure it cannot fix. A commit it
105
+ makes anyway moves aside, as for a dispute of the findings;
104
106
  - a fixer disputes the findings and leaves a change behind, either in the
105
107
  worktree or in a commit of its own. A dispute carries a reason, not a diff,
106
108
  so the harness throws none of it away: an unclean worktree stays on the
107
- disk, and a commit of the fixer stays on the local branch. Neither is
108
- pushed, so the pull request shows no such change. It holds the reason of the
109
- dispute, and the change is on the machine that ran the harness;
109
+ disk, and a commit of the fixer moves to a new local
110
+ `<n>-dispute-<commit>` branch, so the `<n>` branch keeps only what the pull
111
+ request holds. The worktree drops the change of that commit, unless the fixer
112
+ edited the worktree after the commit: then the worktree keeps both and stays
113
+ on the disk. Neither is pushed, so the pull request shows no such change. It
114
+ holds the reason of the dispute, and the change is on the machine that ran
115
+ the harness. The `<n>-dispute-<commit>` branches go away with the `<n>`
116
+ branch when the issue is done;
110
117
  - the reviewer repeats a finding set from an earlier round, which shows a
111
118
  ping-pong between the fixer and the reviewer, or a finding that the fixer
112
119
  disputed and the reviewer holds to. The person then decides;
package/dist/git.mjs CHANGED
@@ -1,4 +1,5 @@
1
- import { appendFile, readFile } from 'node:fs/promises';
1
+ import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  import { must, run } from './exec.mjs';
4
5
  const REMOTE_PATTERNS = [
@@ -97,14 +98,34 @@ export async function isDirty(cwd) {
97
98
  export async function diff(cwd, base, remote) {
98
99
  return must('git', ['diff', `${remote}/${base}...HEAD`], { cwd });
99
100
  }
101
+ export class HookError extends Error {
102
+ }
100
103
  export async function commitAll(cwd, message) {
101
104
  if (!(await isDirty(cwd))) {
102
105
  return false;
103
106
  }
104
107
  await must('git', ['add', '-A'], { cwd });
105
- await must('git', ['commit', '-m', message], { cwd });
108
+ const folder = await mkdtemp(join(tmpdir(), 'next-issue-message-'));
109
+ const file = join(folder, 'COMMIT_EDITMSG');
110
+ try {
111
+ await writeFile(file, `${message}\n`);
112
+ await runHook(cwd, 'pre-commit');
113
+ await runHook(cwd, 'commit-msg', file);
114
+ await must('git', ['commit', '--no-verify', '-F', file], { cwd });
115
+ }
116
+ finally {
117
+ await rm(folder, { recursive: true, force: true });
118
+ }
106
119
  return true;
107
120
  }
121
+ async function runHook(cwd, name, ...args) {
122
+ try {
123
+ await must('git', ['hook', 'run', '--ignore-missing', name, '--', ...args], { cwd });
124
+ }
125
+ catch (error) {
126
+ throw new HookError(error instanceof Error ? error.message : String(error));
127
+ }
128
+ }
108
129
  export async function discardChanges(cwd, target) {
109
130
  if (!(await isDirty(cwd)) && (await revision(cwd)) === target) {
110
131
  return false;
@@ -113,6 +134,18 @@ export async function discardChanges(cwd, target) {
113
134
  await must('git', ['clean', '-fd'], { cwd });
114
135
  return true;
115
136
  }
137
+ export async function setAside(cwd, target, branch) {
138
+ await must('git', ['branch', branch], { cwd });
139
+ if (await isDirty(cwd)) {
140
+ await must('git', ['reset', '--soft', target], { cwd });
141
+ return;
142
+ }
143
+ await discardChanges(cwd, target);
144
+ }
145
+ export async function disputeBranches(repo, branch) {
146
+ const list = await must('git', ['for-each-ref', '--format=%(refname:short)', `refs/heads/${branch}-dispute-*`], { cwd: repo.root });
147
+ return list.split('\n').filter((line) => line.length > 0);
148
+ }
116
149
  export async function push(cwd, remote, branch, force = false) {
117
150
  await must('git', ['push', '-u', ...(force ? ['--force'] : []), remote, branch], { cwd });
118
151
  }
package/dist/pipeline.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CODING, REVIEWING, runAgent } from './agents.mjs';
2
2
  import { managedLabels } from './config.mjs';
3
3
  import { run } from './exec.mjs';
4
- import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, findWorktree, isDirty, push, removeWorktree, resetToBase, revision, } from './git.mjs';
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, disputeBranches, findWorktree, HookError, isDirty, push, removeWorktree, resetToBase, revision, setAside, } from './git.mjs';
5
5
  import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
6
6
  import { message } from './observe.mjs';
7
7
  import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from './prompts.mjs';
@@ -17,6 +17,7 @@ export const PORTS = {
17
17
  detectSetupCommand,
18
18
  diff,
19
19
  discardChanges,
20
+ disputeBranches,
20
21
  failedBaseLogs,
21
22
  failedCheckLogs,
22
23
  findPr,
@@ -36,6 +37,7 @@ export const PORTS = {
36
37
  revision,
37
38
  runAgent,
38
39
  runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
40
+ setAside,
39
41
  setLabel,
40
42
  viewIssue,
41
43
  waitForBaseChecks,
@@ -501,8 +503,10 @@ async function finish(job, outcome, reason) {
501
503
  const { repo, ports } = context;
502
504
  await cleanUp(context, log, branch);
503
505
  if (outcome === 'done') {
504
- if (!(await ports.deleteBranch(repo, branch))) {
505
- log.event('branch.kept', { branch }, 'quiet');
506
+ for (const name of [branch, ...(await ports.disputeBranches(repo, branch))]) {
507
+ if (!(await ports.deleteBranch(repo, name))) {
508
+ log.event('branch.kept', { branch: name }, 'quiet');
509
+ }
506
510
  }
507
511
  await dropState(repo, issue.number);
508
512
  }
@@ -531,7 +535,14 @@ async function fixRound(job, reason, detail, earlier, kind, force = false, from)
531
535
  }));
532
536
  const unrelated = parseUnrelated(result.text);
533
537
  if (unrelated !== undefined) {
534
- const stray = (await ports.revision(worktree)) !== before || (await ports.isDirty(worktree));
538
+ const disputed = await ports.revision(worktree);
539
+ const moved = disputed !== before;
540
+ if (moved) {
541
+ const aside = `${branch}-dispute-${disputed.slice(0, 7)}`;
542
+ await ports.setAside(worktree, before, aside);
543
+ log.event('fix.aside', { branch: aside }, 'quiet');
544
+ }
545
+ const stray = moved || (await ports.isDirty(worktree));
535
546
  log.event('fix.unrelated', { reason, note: unrelated, stray }, 'quiet');
536
547
  return { committed: false, unrelated, stray };
537
548
  }
@@ -559,7 +570,7 @@ async function commitWork(job, before, subject, role) {
559
570
  }
560
571
  catch (error) {
561
572
  const used = state.commitFixes ?? 0;
562
- if (used >= config.maxCommitFixes) {
573
+ if (!(error instanceof HookError) || used >= config.maxCommitFixes) {
563
574
  throw error;
564
575
  }
565
576
  state.commitFixes = used + 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",