next-issue 0.12.4 → 0.13.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
@@ -18,13 +18,12 @@ next-issue
18
18
 
19
19
  For each open issue, oldest first:
20
20
 
21
- 1. Claim the issue only when it holds the `status:todo` label, holds no
22
- `status:in-progress` or `status:in-review` label, and has no assignee. A
23
- `status:done`, `status:needs-human` or `status:blocked` label always stops
24
- the claim, a resume too. An issue with saved state under `.next-issue/`
25
- resumes instead, whatever its other labels, unless another person is now the
26
- assignee.
27
- 2. Assign the issue to you and add `status:in-progress`.
21
+ 1. Claim the issue only when it holds the `status:todo` label and holds no
22
+ `status:in-progress` or `status:in-review` label. A `status:done`,
23
+ `status:needs-human` or `status:blocked` label always stops the claim, a
24
+ resume too. An issue with saved state under `.next-issue/` resumes instead,
25
+ whatever its other labels.
26
+ 2. Add `status:in-progress` to the issue.
28
27
  3. Fetch the remote, fast-forward the local copy of the default branch when
29
28
  that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
30
29
  `issue-<n>` branch. A branch that exists on the remote is the start, and a
@@ -96,10 +95,11 @@ and to every child of it, so the run dies in the middle of a step. The saved
96
95
  state lets the next run continue the issue, but the issue keeps
97
96
  `status:in-progress` and the run writes no summary.
98
97
 
99
- Every status label that the harness puts on the issue goes on the pull request
100
- too, from the first review round. Thus a draft pull request shows work that the
101
- harness has not finished, and a `status:needs-human` pull request shows work
102
- that waits for a person.
98
+ A status label that the harness puts on the issue goes on the pull request too,
99
+ but only if the pull request exists at that moment. The `status:in-progress`
100
+ label of a fresh issue comes before the pull request, and stays on the issue
101
+ alone. Thus a draft pull request shows work that the harness has not finished,
102
+ and a `status:needs-human` pull request shows work that waits for a person.
103
103
 
104
104
  The harness runs all git and `gh` commands itself. The commit holds all the
105
105
  changes in the worktree, so a setup command that writes a file outside
@@ -188,8 +188,8 @@ does not know all stop the run, so a typo cannot pass without a word.
188
188
  {
189
189
  "remote": "origin",
190
190
  "issueLimit": 100,
191
- "maxCiFixes": 3,
192
- "maxReviewRounds": 3,
191
+ "maxCiFixes": 5,
192
+ "maxReviewRounds": 5,
193
193
  "checkIntervalSeconds": 15,
194
194
  "checkGraceSeconds": 60,
195
195
  "checkTimeoutMinutes": 60,
@@ -220,8 +220,8 @@ does not know all stop the run, so a typo cannot pass without a word.
220
220
  | --- | --- | --- |
221
221
  | `remote` | `origin` | The git remote for the repository and the branches |
222
222
  | `issueLimit` | `100` | The maximum number of open issues to read |
223
- | `maxCiFixes` | `3` | The budget for check fixes per issue |
224
- | `maxReviewRounds` | `3` | The budget for review rounds per issue |
223
+ | `maxCiFixes` | `5` | The budget for check fixes per issue |
224
+ | `maxReviewRounds` | `5` | The budget for review rounds per issue |
225
225
  | `checkIntervalSeconds` | `15` | The time between two check states |
226
226
  | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
227
227
  | `checkTimeoutMinutes` | `60` | The limit for one check wait |
@@ -237,10 +237,10 @@ does not know all stop the run, so a typo cannot pass without a word.
237
237
  | `labels` | see above | The names of the labels that the harness reads and sets |
238
238
 
239
239
  An empty `labels.ready` turns the ready requirement off. The harness then
240
- claims every open issue that no other label and no assignee holds back.
240
+ claims every open issue that no other label holds back.
241
241
 
242
- The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
243
- `in-flight` or `assigned`.
242
+ The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
243
+ `in-flight`.
244
244
 
245
245
  A role set to `null`, or with no entry in `models`, uses the default model of
246
246
  the SDK.
package/dist/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { parseArgs, USAGE } from "./args.mjs";
5
5
  import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
6
  import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
7
  import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
8
- import { currentLogin, defaultBranch, openIssues } from "./github.mjs";
8
+ import { defaultBranch, openIssues } from "./github.mjs";
9
9
  import { exitCode, runIssues } from "./loop.mjs";
10
10
  import { message, pruneRuns, Recorder } from "./observe.mjs";
11
11
  import { PORTS, processIssue } from "./pipeline.mjs";
@@ -60,13 +60,12 @@ async function init(root, force) {
60
60
  return 0;
61
61
  }
62
62
  async function handle(args, config, repo, recorder) {
63
- const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
64
- const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
63
+ const base = await defaultBranch(repo);
64
+ const context = { repo, config, base, reset: args.reset, recorder, ports: PORTS };
65
65
  recorder.event("run.start", {
66
66
  version: await packageVersion(),
67
67
  repo: `${repo.owner}/${repo.name}`,
68
68
  base,
69
- login,
70
69
  log: recorder.logFile,
71
70
  }, "quiet");
72
71
  const issues = await openIssues(repo, config.issueLimit);
package/dist/config.mjs CHANGED
@@ -4,8 +4,8 @@ import { message } from "./observe.mjs";
4
4
  const DEFAULTS = {
5
5
  remote: "origin",
6
6
  issueLimit: 100,
7
- maxCiFixes: 3,
8
- maxReviewRounds: 3,
7
+ maxCiFixes: 5,
8
+ maxReviewRounds: 5,
9
9
  checkIntervalSeconds: 15,
10
10
  checkGraceSeconds: 60,
11
11
  checkTimeoutMinutes: 60,
@@ -83,7 +83,7 @@ export async function loadConfig(root) {
83
83
  }
84
84
  return parseConfig(parsed);
85
85
  }
86
- export function parseConfig(value) {
86
+ function parseConfig(value) {
87
87
  const file = record(value, "the file");
88
88
  for (const key of Object.keys(file)) {
89
89
  if (!FIELDS.includes(key)) {
package/dist/github.mjs CHANGED
@@ -15,9 +15,6 @@ export async function defaultBranch(repo) {
15
15
  });
16
16
  return JSON.parse(raw).defaultBranchRef.name;
17
17
  }
18
- export async function currentLogin(repo) {
19
- return must("gh", ["api", "user", "--jq", ".login"], { cwd: repo.root });
20
- }
21
18
  export async function openIssues(repo, limit) {
22
19
  const raw = await gh(repo, [
23
20
  "issue",
@@ -29,7 +26,7 @@ export async function openIssues(repo, limit) {
29
26
  "--limit",
30
27
  String(limit),
31
28
  "--json",
32
- "number,title,body,createdAt,labels,assignees",
29
+ "number,title,body,createdAt,labels",
33
30
  ]);
34
31
  const parsed = JSON.parse(raw);
35
32
  return parsed
@@ -39,7 +36,6 @@ export async function openIssues(repo, limit) {
39
36
  body: item.body ?? "",
40
37
  createdAt: item.createdAt,
41
38
  labels: item.labels.map((label) => label.name),
42
- assignees: item.assignees.map((assignee) => assignee.login),
43
39
  }))
44
40
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
45
41
  }
@@ -48,10 +44,7 @@ export async function issueComments(repo, issue) {
48
44
  const parsed = JSON.parse(raw);
49
45
  return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
50
46
  }
51
- export async function assignIssue(repo, issue, login) {
52
- await gh(repo, ["issue", "edit", String(issue), "--add-assignee", login]);
53
- }
54
- export async function labelsOf(repo, kind, number) {
47
+ async function labelsOf(repo, kind, number) {
55
48
  const raw = await gh(repo, [kind, "view", String(number), "--json", "labels"]);
56
49
  const parsed = JSON.parse(raw);
57
50
  return parsed.labels.map((label) => label.name);
@@ -68,7 +61,7 @@ export async function setLabel(repo, kind, number, add, remove) {
68
61
  const current = await labelsOf(repo, kind, number);
69
62
  await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
70
63
  }
71
- export async function ensureLabel(repo, label) {
64
+ async function ensureLabel(repo, label) {
72
65
  let known = KNOWN_LABELS.get(slug(repo));
73
66
  if (known === undefined) {
74
67
  const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
@@ -217,7 +210,14 @@ export async function failedCheckLogs(repo, branch, maxChars) {
217
210
  cwd: repo.root,
218
211
  tailChars: share,
219
212
  });
220
- parts.push(logs.stdout.slice(-share));
213
+ if (logs.code === 0) {
214
+ parts.push(logs.stdout);
215
+ continue;
216
+ }
217
+ const reason = [`The log read failed with code ${logs.code}.`, logs.stderr.trim()]
218
+ .filter((line) => line !== "")
219
+ .join(" ");
220
+ parts.push([logs.stdout.trim(), reason].filter((line) => line !== "").join("\n"));
221
221
  }
222
222
  return parts.join("\n\n");
223
223
  }
package/dist/pipeline.mjs CHANGED
@@ -2,7 +2,7 @@ import { CODING, REVIEWING, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
4
  import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
- import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
5
+ import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
@@ -10,7 +10,6 @@ import { dropState, readState, resetState, writeState, } from "./state.mjs";
10
10
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
11
11
  export const PORTS = {
12
12
  addWorktree,
13
- assignIssue,
14
13
  commentOnPr,
15
14
  commitAll,
16
15
  createPr,
@@ -31,14 +30,14 @@ export const PORTS = {
31
30
  setLabel,
32
31
  waitForChecks,
33
32
  };
34
- export function skipReason(issue, config, login, resuming) {
33
+ export function skipReason(issue, config, resuming) {
35
34
  const labels = new Set(issue.labels);
36
35
  const names = config.labels;
37
36
  if (labels.has(names.done) || labels.has(names.needsHuman) || labels.has(names.skip)) {
38
37
  return "stop-label";
39
38
  }
40
39
  if (resuming) {
41
- return issue.assignees.every((name) => name === login) ? undefined : "assigned";
40
+ return undefined;
42
41
  }
43
42
  if (names.ready.length > 0 && !labels.has(names.ready)) {
44
43
  return "not-ready";
@@ -46,13 +45,13 @@ export function skipReason(issue, config, login, resuming) {
46
45
  if (labels.has(names.inProgress) || labels.has(names.inReview)) {
47
46
  return "in-flight";
48
47
  }
49
- return issue.assignees.length === 0 ? undefined : "assigned";
48
+ return undefined;
50
49
  }
51
50
  export async function processIssue(context, issue) {
52
51
  const started = Date.now();
53
52
  const log = context.recorder.scope({ issue: issue.number });
54
53
  const saved = await readState(context.repo, issue.number);
55
- const skip = skipReason(issue, context.config, context.login, saved !== undefined);
54
+ const skip = skipReason(issue, context.config, saved !== undefined);
56
55
  if (skip !== undefined) {
57
56
  log.event("issue.skip", { title: issue.title, reason: skip });
58
57
  return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
@@ -97,7 +96,6 @@ async function work(context, issue, log, state) {
97
96
  const { repo, config, ports } = context;
98
97
  const branch = state.branch;
99
98
  await log.step("claim", { label: config.labels.inProgress }, async () => {
100
- await ports.assignIssue(repo, issue.number, context.login);
101
99
  await setStatus(context, issue.number, state.pr, config.labels.inProgress);
102
100
  await writeState(repo, state);
103
101
  });
@@ -107,25 +105,16 @@ async function work(context, issue, log, state) {
107
105
  if (!added.baseUpdated) {
108
106
  log.event("base.behind", { base: context.base }, "quiet");
109
107
  }
110
- const job = { context, issue, log, state, branch, worktree, comments };
111
- const escalate = (reason) => handOver(context, log, issue.number, state, reason);
112
- const finish = async (outcome, reason) => {
113
- await cleanUp(context, log, issue.number);
114
- if (outcome === "done") {
115
- if (!(await ports.deleteBranch(repo, branch))) {
116
- log.event("branch.kept", { branch }, "quiet");
117
- }
118
- await dropState(repo, issue.number);
119
- }
120
- return {
121
- issue: issue.number,
122
- outcome,
123
- reason,
124
- pr: state.pr,
125
- ciFixes: state.ciFixes,
126
- reviewRounds: state.reviewRounds,
127
- ms: Date.now() - started,
128
- };
108
+ const job = {
109
+ context,
110
+ issue,
111
+ log,
112
+ state,
113
+ branch,
114
+ worktree,
115
+ comments,
116
+ started,
117
+ noChecks: false,
129
118
  };
130
119
  const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
131
120
  if (setupCommand !== undefined) {
@@ -133,10 +122,10 @@ async function work(context, issue, log, state) {
133
122
  if (setup.code !== 0) {
134
123
  const late = setup.timedOut === true;
135
124
  log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
136
- await escalate(late
125
+ await escalate(job, late
137
126
  ? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
138
127
  : "The setup command failed in the worktree.");
139
- return finish("needs-human", late ? "setup timeout" : "setup failed");
128
+ return finish(job, "needs-human", late ? "setup timeout" : "setup failed");
140
129
  }
141
130
  }
142
131
  if (state.phase === "claimed") {
@@ -151,138 +140,173 @@ async function work(context, issue, log, state) {
151
140
  }));
152
141
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
153
142
  if (!(await commitWork(job, before, subject))) {
154
- await escalate("The implementer produced no change.");
155
- return finish("needs-human", "no implementation commit");
143
+ await escalate(job, "The implementer produced no change.");
144
+ return finish(job, "needs-human", "no implementation commit");
156
145
  }
157
146
  state.phase = "implemented";
158
147
  await writeState(repo, state);
159
148
  }
160
149
  await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
161
- state.pr = await log.step("pull-request", { branch }, async () => {
150
+ const pr = await log.step("pull-request", { branch }, async () => {
162
151
  const existing = await ports.findPr(repo, branch);
163
152
  return (existing ??
164
153
  ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
165
154
  });
155
+ state.pr = pr;
166
156
  state.phase = "review";
167
157
  await writeState(repo, state);
168
- log.event("pull-request.ready", { pr: state.pr }, "quiet");
169
- let noChecks = false;
170
- const gate = async () => {
171
- const head = await ports.revision(worktree);
172
- const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
173
- head,
174
- intervalSeconds: config.checkIntervalSeconds,
175
- graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
176
- timeoutMs: config.checkTimeoutMinutes * 60_000,
177
- show: log.level === "verbose",
178
- }));
179
- log.event("checks.state", { state: checks });
180
- noChecks ||= checks === "none";
181
- if (checks === "timeout") {
182
- await escalate("The checks did not finish in time.");
183
- return { done: true, report: await finish("needs-human", "checks timeout") };
184
- }
185
- if (checks !== "fail") {
186
- return { done: false, fixed: false };
187
- }
188
- if (state.ciFixes >= config.maxCiFixes) {
189
- await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
190
- return { done: true, report: await finish("needs-human", "check budget") };
191
- }
192
- state.ciFixes += 1;
193
- await writeState(repo, state);
194
- const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
195
- log.event("checks.logs", { chars: logs.length });
196
- const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
197
- if (!fix.committed) {
198
- const note = fix.unrelated;
199
- await escalate(note === undefined
200
- ? "The fixer added no commit for the failed checks."
201
- : `The checks fail for a reason this change did not cause: ${note}`);
202
- return {
203
- done: true,
204
- report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
205
- };
206
- }
207
- return { done: false, fixed: true };
208
- };
158
+ log.event("pull-request.ready", { pr }, "quiet");
209
159
  for (;;) {
210
- const before = await gate();
211
- if (before.done) {
212
- return before.report;
160
+ const gate = await checkGate(job);
161
+ if (gate.done) {
162
+ return gate.report;
213
163
  }
214
- if (before.fixed) {
164
+ if (gate.fixed) {
215
165
  continue;
216
166
  }
217
- if (state.reviewRounds >= config.maxReviewRounds) {
218
- await escalate(`The limit of ${config.maxReviewRounds} review rounds was reached.`);
219
- return finish("needs-human", "review budget");
167
+ const round = await reviewRound(job);
168
+ if (round.done) {
169
+ return round.report;
220
170
  }
221
- state.reviewRounds += 1;
222
- await writeState(repo, state);
223
- await setStatus(context, issue.number, state.pr, config.labels.inReview);
224
- const full = await ports.diff(worktree, context.base, config.remote);
225
- const patch = capDiff(full, config.diffMaxChars);
226
- const fields = {
227
- round: state.reviewRounds,
228
- diffChars: full.length,
229
- cut: full.length > config.diffMaxChars,
171
+ }
172
+ }
173
+ async function checkGate(job) {
174
+ const { context, log, state, branch, worktree } = job;
175
+ const { repo, config, ports } = context;
176
+ const head = await ports.revision(worktree);
177
+ const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
178
+ head,
179
+ intervalSeconds: config.checkIntervalSeconds,
180
+ graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
181
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
182
+ show: log.level === "verbose",
183
+ }));
184
+ log.event("checks.state", { state: checks });
185
+ job.noChecks ||= checks === "none";
186
+ if (checks === "timeout") {
187
+ await escalate(job, "The checks did not finish in time.");
188
+ return { done: true, report: await finish(job, "needs-human", "checks timeout") };
189
+ }
190
+ if (checks !== "fail") {
191
+ return { done: false, fixed: false };
192
+ }
193
+ if (state.ciFixes >= config.maxCiFixes) {
194
+ await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
195
+ return { done: true, report: await finish(job, "needs-human", "check budget") };
196
+ }
197
+ state.ciFixes += 1;
198
+ await writeState(repo, state);
199
+ const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
200
+ log.event("checks.logs", { chars: logs.length });
201
+ const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
202
+ if (!fix.committed) {
203
+ const note = fix.unrelated;
204
+ await escalate(job, note === undefined
205
+ ? "The fixer added no commit for the failed checks."
206
+ : `The checks fail for a reason this change did not cause: ${note}`);
207
+ return {
208
+ done: true,
209
+ report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
230
210
  };
231
- const review = await log.step("review", fields, () => ports.runAgent(log, {
232
- name: "reviewer",
233
- cwd: worktree,
234
- prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
235
- profile: REVIEWING,
236
- model: config.models.reviewer,
237
- timeoutMs: config.agentTimeoutMinutes * 60_000,
238
- outputSchema: VERDICT_SCHEMA,
239
- }));
240
- if (await ports.discardChanges(worktree)) {
241
- log.event("review.discard", { round: state.reviewRounds }, "quiet");
242
- }
243
- const verdict = readVerdict(review.structured);
244
- if (verdict === undefined) {
245
- await escalate("The reviewer gave no verdict.");
246
- return finish("needs-human", "no verdict");
211
+ }
212
+ return { done: false, fixed: true };
213
+ }
214
+ async function reviewRound(job) {
215
+ const { context, issue, log, state, worktree, comments } = job;
216
+ const { repo, config, ports } = context;
217
+ const pr = state.pr;
218
+ if (state.reviewRounds >= config.maxReviewRounds) {
219
+ await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
220
+ return { done: true, report: await finish(job, "needs-human", "review budget") };
221
+ }
222
+ state.reviewRounds += 1;
223
+ await writeState(repo, state);
224
+ await setStatus(context, issue.number, pr, config.labels.inReview);
225
+ const full = await ports.diff(worktree, context.base, config.remote);
226
+ const patch = capDiff(full, config.diffMaxChars);
227
+ const fields = {
228
+ round: state.reviewRounds,
229
+ diffChars: full.length,
230
+ cut: full.length > config.diffMaxChars,
231
+ };
232
+ const review = await log.step("review", fields, () => ports.runAgent(log, {
233
+ name: "reviewer",
234
+ cwd: worktree,
235
+ prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
236
+ profile: REVIEWING,
237
+ model: config.models.reviewer,
238
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
239
+ outputSchema: VERDICT_SCHEMA,
240
+ }));
241
+ if (await ports.discardChanges(worktree)) {
242
+ log.event("review.discard", { round: state.reviewRounds }, "quiet");
243
+ }
244
+ const verdict = readVerdict(review.structured);
245
+ if (verdict === undefined) {
246
+ await escalate(job, "The reviewer gave no verdict.");
247
+ return { done: true, report: await finish(job, "needs-human", "no verdict") };
248
+ }
249
+ const blocking = blockingFindings(verdict);
250
+ const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
251
+ log.event("verdict", {
252
+ round: state.reviewRounds,
253
+ blocking: blocking.length,
254
+ minor: verdict.findings.length - blocking.length,
255
+ open: open.length,
256
+ summary: verdict.summary,
257
+ }, "quiet");
258
+ await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
259
+ if (open.length === 0) {
260
+ const gate = await checkGate(job);
261
+ if (gate.done) {
262
+ return { done: true, report: gate.report };
247
263
  }
248
- const blocking = blockingFindings(verdict);
249
- const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
250
- log.event("verdict", {
251
- round: state.reviewRounds,
252
- blocking: blocking.length,
253
- minor: verdict.findings.length - blocking.length,
254
- open: open.length,
255
- summary: verdict.summary,
256
- }, "quiet");
257
- await ports.commentOnPr(repo, state.pr, formatVerdict(verdict, open));
258
- if (open.length === 0) {
259
- const after = await gate();
260
- if (after.done) {
261
- return after.report;
262
- }
263
- if (after.fixed) {
264
- continue;
265
- }
266
- if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
267
- log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
268
- }
269
- await setStatus(context, issue.number, state.pr, config.labels.done);
270
- return finish("done");
264
+ if (gate.fixed) {
265
+ return { done: false };
271
266
  }
272
- const mark = fingerprint(open);
273
- if (state.reviewLog.some((round) => round.fingerprint === mark)) {
274
- await escalate("The reviewer repeated findings that an earlier round did not fix.");
275
- return finish("needs-human", "repeated findings");
267
+ if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
268
+ log.event("pull-request.undraft.fail", { pr }, "quiet");
276
269
  }
277
- const earlier = history(state.reviewLog);
278
- state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
279
- await writeState(repo, state);
280
- const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
281
- if (!fix.committed) {
282
- await escalate("The fixer added no commit for the review findings.");
283
- return finish("needs-human", "no fix commit");
270
+ await setStatus(context, issue.number, pr, config.labels.done);
271
+ return { done: true, report: await finish(job, "done") };
272
+ }
273
+ const mark = fingerprint(open);
274
+ if (state.reviewLog.some((round) => round.fingerprint === mark)) {
275
+ await escalate(job, "The reviewer repeated findings that an earlier round did not fix.");
276
+ return { done: true, report: await finish(job, "needs-human", "repeated findings") };
277
+ }
278
+ const earlier = history(state.reviewLog);
279
+ state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
280
+ await writeState(repo, state);
281
+ const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
282
+ if (!fix.committed) {
283
+ await escalate(job, "The fixer added no commit for the review findings.");
284
+ return { done: true, report: await finish(job, "needs-human", "no fix commit") };
285
+ }
286
+ return { done: false };
287
+ }
288
+ function escalate(job, reason) {
289
+ return handOver(job.context, job.log, job.issue.number, job.state, reason);
290
+ }
291
+ async function finish(job, outcome, reason) {
292
+ const { context, issue, log, state, branch } = job;
293
+ const { repo, ports } = context;
294
+ await cleanUp(context, log, issue.number);
295
+ if (outcome === "done") {
296
+ if (!(await ports.deleteBranch(repo, branch))) {
297
+ log.event("branch.kept", { branch }, "quiet");
284
298
  }
299
+ await dropState(repo, issue.number);
285
300
  }
301
+ return {
302
+ issue: issue.number,
303
+ outcome,
304
+ reason,
305
+ pr: state.pr,
306
+ ciFixes: state.ciFixes,
307
+ reviewRounds: state.reviewRounds,
308
+ ms: Date.now() - job.started,
309
+ };
286
310
  }
287
311
  async function fixRound(job, reason, detail, earlier, kind) {
288
312
  const { context, issue, log, worktree, branch } = job;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",