opencode-magi 0.5.0 → 0.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.
@@ -1,9 +1,20 @@
1
1
  import { majorityThreshold } from "./majority";
2
+ function validationFindings(output) {
3
+ if ("findings" in output)
4
+ return output.findings;
5
+ return output.newFindings.map((finding) => ({
6
+ fix: "Please address this before merging.",
7
+ issue: finding.body,
8
+ line: finding.line,
9
+ path: finding.path,
10
+ startLine: finding.startLine,
11
+ }));
12
+ }
2
13
  export function reviewFindingTargets(outputs) {
3
14
  return Object.entries(outputs).flatMap(([reviewer, output]) => {
4
15
  if (output.verdict !== "CHANGES_REQUESTED")
5
16
  return [];
6
- return output.findings.map((finding, findingIndex) => ({
17
+ return validationFindings(output).map((finding, findingIndex) => ({
7
18
  finding,
8
19
  findingIndex,
9
20
  reviewer,
@@ -41,7 +52,9 @@ export function applyFindingValidation(input) {
41
52
  next[reviewer] = output;
42
53
  continue;
43
54
  }
44
- const findings = output.findings.filter((finding, findingIndex) => {
55
+ const keptIndexes = new Set();
56
+ const findings = validationFindings(output);
57
+ findings.forEach((finding, findingIndex) => {
45
58
  let agrees = 1;
46
59
  for (const validator of input.reviewerKeys) {
47
60
  if (validator === reviewer)
@@ -53,14 +66,23 @@ export function applyFindingValidation(input) {
53
66
  const target = { finding, findingIndex, reviewer };
54
67
  if (agrees >= threshold) {
55
68
  kept.push(target);
56
- return true;
69
+ keptIndexes.add(findingIndex);
70
+ return;
57
71
  }
58
72
  discarded.push(target);
59
- return false;
60
73
  });
61
- next[reviewer] = findings.length
62
- ? { ...output, findings }
63
- : { findings: [], verdict: "MERGE" };
74
+ if ("findings" in output) {
75
+ const keptFindings = output.findings.filter((_finding, index) => keptIndexes.has(index));
76
+ next[reviewer] = keptFindings.length
77
+ ? { ...output, findings: keptFindings }
78
+ : { findings: [], verdict: "MERGE" };
79
+ continue;
80
+ }
81
+ const newFindings = output.newFindings.filter((_finding, index) => keptIndexes.has(index));
82
+ next[reviewer] =
83
+ newFindings.length || output.followUps.length
84
+ ? { ...output, newFindings }
85
+ : { ...output, newFindings, verdict: "MERGE" };
64
86
  }
65
87
  return { outputs: next, summary: { discarded, kept } };
66
88
  }
@@ -10,7 +10,7 @@ export function aggregateStringMajority(results, votes) {
10
10
  const voters = Object.fromEntries(votes.map((vote) => [vote, []]));
11
11
  for (const result of results) {
12
12
  counts[result.vote] += 1;
13
- voters[result.vote].push(result.reviewer);
13
+ voters[result.vote].push(result.voter);
14
14
  }
15
15
  const threshold = majorityThreshold(results.length);
16
16
  const vote = votes.find((item) => counts[item] >= threshold);
@@ -1,17 +1,17 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { prRunOutputDir } from "../config/output";
4
- import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, shellQuote, waitForMergeQueue, } from "../github/commands";
4
+ import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, waitForMergeQueue, } from "../github/commands";
5
5
  import { composeEditPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
6
6
  import { parseEditOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, } from "../prompts/output";
7
7
  import { throwIfAborted, withAbortSignal } from "./abort";
8
8
  import { waitForChecksWithClassification } from "./ci";
9
- import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
9
+ import { validateInlineCommentTargets, } from "./inline-comments";
10
10
  import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
11
11
  import { runModelWithRepair } from "./model";
12
12
  import { mapPool } from "./pool";
13
13
  import { formatMergeReport } from "./report";
14
- import { runReview } from "./review";
14
+ import { inlineCommentTargetsForDiff, runReview, } from "./review";
15
15
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
16
  function outputDir(input) {
17
17
  return prRunOutputDir({
@@ -53,7 +53,7 @@ async function withReviewerFailureProgress(input) {
53
53
  async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedThreads) {
54
54
  const editor = input.repository.agents.editor;
55
55
  if (!editor)
56
- throw new Error("agents.editor is required for magi_merge");
56
+ throw new Error("merge.editor is required for magi_merge");
57
57
  throwIfAborted(input.signal);
58
58
  await configureGitIdentity(input.exec, worktreePath, {
59
59
  email: editor.author?.email,
@@ -96,6 +96,7 @@ async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedT
96
96
  }
97
97
  },
98
98
  options: editor.options,
99
+ parentSessionId: input.parentSessionId,
99
100
  parse: parseEditOutput,
100
101
  permission: editor.permission,
101
102
  prompt,
@@ -195,7 +196,21 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
195
196
  throwIfAborted(input.signal);
196
197
  const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
197
198
  const headSha = options.dryRunHeadSha ?? meta.headRefOid;
198
- const inlineCommentTargets = parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(headSha)}`, { cwd: worktreePath }));
199
+ const inlineCommentTargets = await inlineCommentTargetsForDiff({
200
+ ensure: options.dryRunHeadSha
201
+ ? undefined
202
+ : {
203
+ fromSource: "base",
204
+ meta,
205
+ repository: input.repository,
206
+ toSource: "head",
207
+ },
208
+ exec: input.exec,
209
+ fromSha: meta.baseRefOid,
210
+ range: "direct",
211
+ toSha: headSha,
212
+ worktreePath,
213
+ });
199
214
  const artifactDir = outputDir(input);
200
215
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
201
216
  throwIfAborted(input.signal);
@@ -250,6 +265,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
250
265
  }
251
266
  },
252
267
  options: reviewer.options,
268
+ parentSessionId: input.parentSessionId,
253
269
  parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
254
270
  permission: reviewer.permission,
255
271
  prompt,
@@ -294,7 +310,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
294
310
  baseSha: meta.baseRefOid,
295
311
  closeReason: entry.output.reason,
296
312
  directory: input.directory,
297
- headSha: meta.headRefOid,
313
+ headSha,
298
314
  includeReviewGuidelines: !hasReviewerSession,
299
315
  includeSessionContext: !hasReviewerSession,
300
316
  pr: input.pr,
@@ -333,6 +349,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
333
349
  }
334
350
  },
335
351
  options: reviewer.options,
352
+ parentSessionId: input.parentSessionId,
336
353
  parse: (text) => {
337
354
  const output = parseRereviewCloseReconsiderationOutput(text);
338
355
  validateInlineCommentTargets(output.newFindings, inlineCommentTargets, "newFindings");
@@ -409,8 +426,11 @@ async function finishMergeRun(input, result, reportInput) {
409
426
  }
410
427
  async function mergeWithQueue(input, exec, editorAccount) {
411
428
  await mergePullRequest(exec, input.repository, input.pr, editorAccount);
412
- if (!input.repository.merge.mergeQueue)
413
- return "merged";
429
+ if (!input.repository.merge.mergeQueue) {
430
+ if (!input.repository.merge.auto)
431
+ return "merged";
432
+ return waitForAutoMerge(exec, input.repository, input.pr);
433
+ }
414
434
  return waitForMergeQueue(exec, input.repository, input.pr);
415
435
  }
416
436
  export function hasBlockingCiReports(reports) {
@@ -570,7 +590,7 @@ export async function runMerge(input) {
570
590
  const abortableInput = { ...input, exec };
571
591
  const editor = input.repository.agents.editor;
572
592
  if (!editor)
573
- throw new Error("agents.editor is required for magi_merge");
593
+ throw new Error("merge.editor is required for magi_merge");
574
594
  throwIfAborted(input.signal);
575
595
  const artifactDir = outputDir(input);
576
596
  await mkdir(artifactDir, { recursive: true });
@@ -701,7 +721,7 @@ export async function runMerge(input) {
701
721
  threads: unresolvedThreads,
702
722
  });
703
723
  const editorFindings = blockingReviewFindings(reportOutputs);
704
- const editableFindings = editableThreads.length ? editorFindings : [];
724
+ const editableFindings = editorFindings;
705
725
  const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
706
726
  cycle > input.repository.merge.maxThreadResolutionCycles;
707
727
  if (!editableThreads.length &&
@@ -781,6 +801,7 @@ export async function runMerge(input) {
781
801
  exec,
782
802
  headSha: editedHeadSha,
783
803
  onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
804
+ parentSessionId: input.parentSessionId,
784
805
  pr: input.pr,
785
806
  repairAttempts: input.config.output?.repairAttempts ?? 3,
786
807
  repository: input.repository,
@@ -89,6 +89,7 @@ function extractText(result, allowEmpty = false) {
89
89
  export async function createModelSession(input) {
90
90
  return extractSessionId(await input.client.session.create({
91
91
  body: {
92
+ parentID: input.parentSessionId,
92
93
  permission: toOpenCodePermissionRules(input.permission),
93
94
  title: input.title,
94
95
  },
@@ -96,15 +97,26 @@ export async function createModelSession(input) {
96
97
  }
97
98
  export async function promptModelText(input) {
98
99
  throwIfAborted(input.signal);
99
- const result = await input.client.session.prompt({
100
- body: {
101
- model: modelBody(input.model),
102
- parts: [{ type: "text", text: input.prompt }],
103
- },
104
- path: { id: input.sessionId },
105
- });
106
- throwIfAborted(input.signal);
107
- return extractText(result, input.allowEmpty);
100
+ const abort = () => {
101
+ void input.client.session
102
+ .abort?.({ path: { id: input.sessionId } })
103
+ .catch(() => undefined);
104
+ };
105
+ input.signal?.addEventListener("abort", abort, { once: true });
106
+ try {
107
+ const result = await input.client.session.prompt({
108
+ body: {
109
+ model: modelBody(input.model),
110
+ parts: [{ type: "text", text: input.prompt }],
111
+ },
112
+ path: { id: input.sessionId },
113
+ });
114
+ throwIfAborted(input.signal);
115
+ return extractText(result, input.allowEmpty);
116
+ }
117
+ finally {
118
+ input.signal?.removeEventListener("abort", abort);
119
+ }
108
120
  }
109
121
  async function sendPrompt(client, sessionId, model, prompt, signal) {
110
122
  return promptModelText({ client, model, prompt, sessionId, signal });
@@ -113,6 +125,7 @@ export async function runModelText(input) {
113
125
  throwIfAborted(input.signal);
114
126
  const sessionId = await createModelSession({
115
127
  client: input.client,
128
+ parentSessionId: input.parentSessionId,
116
129
  permission: input.permission,
117
130
  title: input.title,
118
131
  });
@@ -152,6 +165,7 @@ export async function runModelWithRepair(input) {
152
165
  ? input.sessionId
153
166
  : extractSessionId(await input.client.session.create({
154
167
  body: {
168
+ parentID: input.parentSessionId,
155
169
  permission: toOpenCodePermissionRules(input.permission),
156
170
  title: input.title,
157
171
  },
@@ -1,10 +1,10 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, shellQuote, } from "../github/commands";
4
- import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
3
+ import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, ensurePullRequestCommits, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, shellQuote, } from "../github/commands";
4
+ import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
5
5
  import { prRunOutputDir } from "../config/output";
6
- import { worktreeBaseDir } from "../config/worktree";
7
- import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
6
+ import { prRunWorktreeDir } from "../config/worktree";
7
+ import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
8
8
  import { throwIfAborted, withAbortSignal } from "./abort";
9
9
  import { waitForChecksWithClassification } from "./ci";
10
10
  import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
@@ -112,7 +112,7 @@ function reviewStateToVerdict(state) {
112
112
  return "MERGE";
113
113
  if (state === "CHANGES_REQUESTED")
114
114
  return "CHANGES_REQUESTED";
115
- return "CLOSE";
115
+ throw new Error(`Unsupported GitHub review state: ${state}`);
116
116
  }
117
117
  function hasBlockingCiReports(reports) {
118
118
  return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
@@ -135,6 +135,34 @@ function parseRereviewOutputWithInlineTargets(text, targets) {
135
135
  validateInlineCommentTargets(output.newFindings, targets, "newFindings");
136
136
  return output;
137
137
  }
138
+ export async function inlineCommentTargetsForDiff(input) {
139
+ if (input.ensure) {
140
+ await ensurePullRequestCommits({
141
+ commits: [
142
+ {
143
+ label: "base",
144
+ sha: input.fromSha,
145
+ source: input.ensure.fromSource,
146
+ },
147
+ {
148
+ label: "head",
149
+ sha: input.toSha,
150
+ source: input.ensure.toSource,
151
+ },
152
+ ],
153
+ exec: input.exec,
154
+ meta: input.ensure.meta,
155
+ repository: input.ensure.repository,
156
+ worktreePath: input.worktreePath,
157
+ });
158
+ }
159
+ const diffRange = input.range === "direct"
160
+ ? `${shellQuote(input.fromSha)} ${shellQuote(input.toSha)}`
161
+ : `${shellQuote(input.fromSha)}...${shellQuote(input.toSha)}`;
162
+ return parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${diffRange}`, {
163
+ cwd: input.worktreePath,
164
+ }));
165
+ }
138
166
  function parsePostedFindingLocation(location) {
139
167
  const range = /^(.*):(\d+)-(\d+)$/.exec(location);
140
168
  if (range) {
@@ -182,10 +210,42 @@ function reviewFindingsFromBody(body) {
182
210
  }
183
211
  return { findings };
184
212
  }
213
+ function parsePostedFindingComment(body) {
214
+ const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]+?)\s*$/.exec(body);
215
+ if (!match)
216
+ return undefined;
217
+ return {
218
+ fix: match[2]?.trim() || "Please address this before merging.",
219
+ issue: match[1]?.trim() || "Review finding.",
220
+ };
221
+ }
222
+ function reviewFindingsFromComments(comments) {
223
+ return {
224
+ findings: (comments ?? []).flatMap((comment) => {
225
+ if (comment.line == null)
226
+ return [];
227
+ const parsed = parsePostedFindingComment(comment.body);
228
+ if (!parsed)
229
+ return [];
230
+ return [
231
+ {
232
+ ...parsed,
233
+ line: comment.line,
234
+ path: comment.path,
235
+ startLine: comment.startLine ?? undefined,
236
+ },
237
+ ];
238
+ }),
239
+ };
240
+ }
185
241
  export function reviewOutputFromState(review) {
186
242
  const verdict = reviewStateToVerdict(review.state);
187
- if (verdict === "CHANGES_REQUESTED")
243
+ if (verdict === "CHANGES_REQUESTED") {
244
+ const fromComments = reviewFindingsFromComments(review.comments);
245
+ if (fromComments.findings.length)
246
+ return { ...fromComments, verdict };
188
247
  return { ...reviewFindingsFromBody(review.body), verdict };
248
+ }
189
249
  return verdict === "CLOSE"
190
250
  ? {
191
251
  findings: [],
@@ -230,8 +290,8 @@ function isReviewOutput(output) {
230
290
  return "findings" in output;
231
291
  }
232
292
  async function runFindingValidation(input) {
233
- const reviewOutputs = Object.fromEntries(input.entries.flatMap((entry) => isReviewOutput(entry.value) ? [[entry.key, entry.value]] : []));
234
- const targets = reviewFindingTargets(reviewOutputs);
293
+ const outputs = Object.fromEntries(input.entries.map((entry) => [entry.key, entry.value]));
294
+ const targets = reviewFindingTargets(outputs);
235
295
  if (!targets.length) {
236
296
  return {
237
297
  outputs: Object.fromEntries(input.entries.map((entry) => [entry.key, entry.value])),
@@ -290,6 +350,7 @@ async function runFindingValidation(input) {
290
350
  }
291
351
  },
292
352
  options: reviewer.options,
353
+ parentSessionId: input.reviewInput.parentSessionId,
293
354
  parse: (text) => {
294
355
  const output = parseFindingValidationOutput(text);
295
356
  validateFindingVotes({
@@ -315,7 +376,7 @@ async function runFindingValidation(input) {
315
376
  return [reviewer.key, result.value];
316
377
  }, { signal: input.reviewInput.signal }));
317
378
  const filtered = applyFindingValidation({
318
- outputs: reviewOutputs,
379
+ outputs,
319
380
  reviewerKeys: input.reviewInput.repository.agents.reviewers.map((reviewer) => reviewer.key),
320
381
  validations,
321
382
  });
@@ -323,7 +384,7 @@ async function runFindingValidation(input) {
323
384
  await input.reviewInput.onProgress?.({
324
385
  discarded: filtered.summary.discarded.length,
325
386
  kept: filtered.summary.kept.length,
326
- reviewersChangedToMerge: Object.entries(reviewOutputs)
387
+ reviewersChangedToMerge: Object.entries(outputs)
327
388
  .filter(([reviewer, output]) => {
328
389
  return (output.verdict === "CHANGES_REQUESTED" &&
329
390
  filtered.outputs[reviewer]?.verdict === "MERGE");
@@ -352,27 +413,49 @@ async function runCloseReconsideration(input) {
352
413
  type: "phase",
353
414
  });
354
415
  return Promise.all(input.entries.map(async (entry) => {
355
- if (!targets.includes(entry.key) || !isReviewOutput(entry.value)) {
416
+ if (!targets.includes(entry.key)) {
356
417
  return entry;
357
418
  }
358
419
  const reviewer = input.reviewInput.repository.agents.reviewers.find((item) => item.key === entry.key);
359
420
  if (!reviewer)
360
421
  return entry;
361
422
  const hasReviewerSession = Boolean(input.sessionIds[reviewer.key]);
362
- const prompt = await composeCloseReconsiderationPrompt({
363
- baseSha: input.meta.baseRefOid,
364
- ciFailureContext: undefined,
365
- closeReason: entry.value.reason,
366
- directory: input.reviewInput.directory,
367
- headSha: input.meta.headRefOid,
368
- includeReviewGuidelines: !hasReviewerSession,
369
- includeSessionContext: !hasReviewerSession,
370
- pr: input.reviewInput.pr,
371
- repository: input.reviewInput.repository,
372
- reviewContext: input.reviewContext,
373
- reviewer,
374
- worktreePath: input.worktreePath,
375
- });
423
+ const isReviewEntry = isReviewOutput(entry.value);
424
+ let prompt;
425
+ if (isReviewEntry) {
426
+ prompt = await composeCloseReconsiderationPrompt({
427
+ baseSha: input.meta.baseRefOid,
428
+ ciFailureContext: undefined,
429
+ closeReason: entry.value.reason,
430
+ directory: input.reviewInput.directory,
431
+ headSha: input.meta.headRefOid,
432
+ includeReviewGuidelines: !hasReviewerSession,
433
+ includeSessionContext: !hasReviewerSession,
434
+ pr: input.reviewInput.pr,
435
+ repository: input.reviewInput.repository,
436
+ reviewContext: input.reviewContext,
437
+ reviewer,
438
+ worktreePath: input.worktreePath,
439
+ });
440
+ }
441
+ else {
442
+ if (!entry.previousHeadSha) {
443
+ throw new Error(`Missing previous review commit for ${reviewer.account}`);
444
+ }
445
+ prompt = await composeRereviewCloseReconsiderationPrompt({
446
+ baseSha: input.meta.baseRefOid,
447
+ closeReason: entry.value.reason,
448
+ directory: input.reviewInput.directory,
449
+ headSha: input.meta.headRefOid,
450
+ includeReviewGuidelines: !hasReviewerSession,
451
+ includeSessionContext: !hasReviewerSession,
452
+ pr: input.reviewInput.pr,
453
+ previousHeadSha: entry.previousHeadSha,
454
+ repository: input.reviewInput.repository,
455
+ reviewer,
456
+ worktreePath: input.worktreePath,
457
+ });
458
+ }
376
459
  const result = await withReviewerFailureProgress({
377
460
  onProgress: input.reviewInput.onProgress,
378
461
  reviewer: reviewer.key,
@@ -403,15 +486,21 @@ async function runCloseReconsideration(input) {
403
486
  }
404
487
  },
405
488
  options: reviewer.options,
489
+ parentSessionId: input.reviewInput.parentSessionId,
406
490
  parse: (text) => {
407
- const output = parseCloseReconsiderationOutput(text);
408
- validateInlineCommentTargets(output.findings, input.inlineCommentTargets);
491
+ const output = isReviewEntry
492
+ ? parseCloseReconsiderationOutput(text)
493
+ : parseRereviewCloseReconsiderationOutput(text);
494
+ const findings = "newFindings" in output ? output.newFindings : output.findings;
495
+ validateInlineCommentTargets(findings, entry.inlineCommentTargets, "newFindings" in output ? "newFindings" : "findings");
409
496
  return output;
410
497
  },
411
498
  permission: reviewer.permission,
412
499
  prompt,
413
500
  repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
414
- schemaName: "close reconsideration",
501
+ schemaName: isReviewEntry
502
+ ? "close reconsideration"
503
+ : "rereview close reconsideration",
415
504
  sessionId: input.sessionIds[reviewer.key],
416
505
  signal: input.reviewInput.signal,
417
506
  title: `magi reconsider close ${input.reviewInput.repository.alias}#${input.reviewInput.pr} ${reviewer.key}`,
@@ -434,7 +523,9 @@ async function runCloseReconsideration(input) {
434
523
  });
435
524
  input.sessionIds[reviewer.key] = result.sessionId;
436
525
  return {
526
+ inlineCommentTargets: entry.inlineCommentTargets,
437
527
  key: entry.key,
528
+ previousHeadSha: entry.previousHeadSha,
438
529
  raw: result.raw,
439
530
  sessionId: result.sessionId,
440
531
  value: result.value,
@@ -514,11 +605,13 @@ export async function runReview(input) {
514
605
  : preliminaryMode;
515
606
  if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
516
607
  throw new Error("PR has already been reviewed by all configured accounts");
517
- const outputDir = join(prRunOutputDir({
608
+ const runId = input.runId ?? `run-${Date.now().toString(36)}`;
609
+ const outputDir = prRunOutputDir({
518
610
  config: input.config,
519
611
  directory: input.directory,
520
612
  pr: input.pr,
521
- }), ...(input.runId ? [input.runId] : []));
613
+ runId,
614
+ });
522
615
  await mkdir(outputDir, { recursive: true });
523
616
  await input.onProgress?.({ phase: "fetching review context", type: "phase" });
524
617
  const reviewContextSnapshot = await buildReviewContextSnapshot({
@@ -567,6 +660,7 @@ export async function runReview(input) {
567
660
  },
568
661
  onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
569
662
  outputDir,
663
+ parentSessionId: input.parentSessionId,
570
664
  pr: input.pr,
571
665
  repairAttempts: input.config.output?.repairAttempts ?? 3,
572
666
  repository: input.repository,
@@ -582,10 +676,14 @@ export async function runReview(input) {
582
676
  checkResult.report.scopeInside.length)) {
583
677
  await input.onProgress?.({ report: checkResult.report, type: "ci_report" });
584
678
  }
585
- const worktreeRoot = worktreeBaseDir(input.directory, input.config, "pr");
679
+ const worktreePath = prRunWorktreeDir({
680
+ config: input.config,
681
+ directory: input.directory,
682
+ pr: input.pr,
683
+ runId,
684
+ });
586
685
  await input.onProgress?.({ phase: "creating worktree", type: "phase" });
587
- const worktree = await createWorktree(exec, input.repository, input.pr, worktreeRoot);
588
- const worktreePath = worktree.path;
686
+ const worktree = await createWorktree(exec, input.repository, input.pr, worktreePath);
589
687
  await input.onProgress?.({
590
688
  branch: worktree.branch,
591
689
  type: "worktree_created",
@@ -599,7 +697,18 @@ export async function runReview(input) {
599
697
  return [];
600
698
  return [{ assignment, reviewer }];
601
699
  });
602
- const inlineCommentTargets = parseRightSideDiffTargets(await exec(`git diff --no-ext-diff --unified=3 ${shellQuote(meta.baseRefOid)} ${shellQuote(meta.headRefOid)}`, { cwd: worktreePath }));
700
+ const initialInlineCommentTargets = await inlineCommentTargetsForDiff({
701
+ ensure: {
702
+ fromSource: "base",
703
+ meta,
704
+ repository: input.repository,
705
+ toSource: "head",
706
+ },
707
+ exec,
708
+ fromSha: meta.baseRefOid,
709
+ toSha: meta.headRefOid,
710
+ worktreePath,
711
+ });
603
712
  for (const reviewer of input.repository.agents.reviewers) {
604
713
  const assignment = mode.assignments.get(reviewer.account);
605
714
  if (assignment?.type !== "skip")
@@ -619,6 +728,18 @@ export async function runReview(input) {
619
728
  const previous = assignment.review;
620
729
  if (!previous.commit?.oid)
621
730
  throw new Error(`Missing previous review commit for ${reviewer.account}`);
731
+ const inlineCommentTargets = await inlineCommentTargetsForDiff({
732
+ ensure: {
733
+ fromSource: "head",
734
+ meta,
735
+ repository: input.repository,
736
+ toSource: "head",
737
+ },
738
+ exec,
739
+ fromSha: previous.commit.oid,
740
+ toSha: meta.headRefOid,
741
+ worktreePath,
742
+ });
622
743
  const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
623
744
  (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
624
745
  const prompt = await composeRereviewPrompt({
@@ -665,6 +786,7 @@ export async function runReview(input) {
665
786
  }
666
787
  },
667
788
  options: reviewer.options,
789
+ parentSessionId: input.parentSessionId,
668
790
  parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
669
791
  permission: reviewer.permission,
670
792
  prompt,
@@ -684,7 +806,9 @@ export async function runReview(input) {
684
806
  verdict: result.value.verdict,
685
807
  });
686
808
  return {
809
+ inlineCommentTargets,
687
810
  key: reviewer.key,
811
+ previousHeadSha: previous.commit.oid,
688
812
  raw: result.raw,
689
813
  sessionId: result.sessionId,
690
814
  value: result.value,
@@ -731,7 +855,8 @@ export async function runReview(input) {
731
855
  }
732
856
  },
733
857
  options: reviewer.options,
734
- parse: (text) => parseReviewOutputWithInlineTargets(text, inlineCommentTargets),
858
+ parentSessionId: input.parentSessionId,
859
+ parse: (text) => parseReviewOutputWithInlineTargets(text, initialInlineCommentTargets),
735
860
  permission: reviewer.permission,
736
861
  prompt,
737
862
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -750,6 +875,7 @@ export async function runReview(input) {
750
875
  verdict: result.value.verdict,
751
876
  });
752
877
  return {
878
+ inlineCommentTargets: initialInlineCommentTargets,
753
879
  key: reviewer.key,
754
880
  raw: result.raw,
755
881
  sessionId: result.sessionId,
@@ -783,6 +909,7 @@ export async function runReview(input) {
783
909
  return [
784
910
  {
785
911
  key: reviewer.key,
912
+ inlineCommentTargets: initialInlineCommentTargets,
786
913
  raw: assignment.review.body ?? "",
787
914
  sessionId: "",
788
915
  value: reviewOutputFromState(assignment.review),
@@ -791,7 +918,6 @@ export async function runReview(input) {
791
918
  });
792
919
  entries = await runCloseReconsideration({
793
920
  entries: [...entries, ...skippedCloseEntries],
794
- inlineCommentTargets,
795
921
  meta,
796
922
  outputDir,
797
923
  reviewContext,