opencode-magi 0.7.0 → 0.9.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.
@@ -15,6 +15,85 @@ import { mapPool } from "./pool";
15
15
  import { formatReviewReport } from "./report";
16
16
  import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
17
17
  import { checkSafetyGate, hasSafetyGate } from "./safety";
18
+ function resolvedReviewMode(repository) {
19
+ return repository.review?.mode === "multi" ? "multi" : "single";
20
+ }
21
+ export function reviewPostingAccount(repository, reviewer) {
22
+ return resolvedReviewMode(repository) === "single"
23
+ ? (repository.review?.account ?? reviewer.account)
24
+ : reviewer.account;
25
+ }
26
+ function reviewAssignmentKey(repository, reviewer) {
27
+ return resolvedReviewMode(repository) === "single"
28
+ ? reviewer.key
29
+ : reviewer.account;
30
+ }
31
+ function parseMarkerFields(text) {
32
+ const fields = Object.fromEntries(text
33
+ .trim()
34
+ .split(/\s+/)
35
+ .flatMap((part) => {
36
+ const index = part.indexOf("=");
37
+ return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
38
+ }));
39
+ return fields.v === "1" && fields.mode === "single" ? fields : undefined;
40
+ }
41
+ function isMarkerVerdict(value) {
42
+ return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
43
+ }
44
+ export function formatReviewMarker(marker) {
45
+ return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
46
+ }
47
+ export function parseReviewMarkers(body) {
48
+ const markers = [];
49
+ const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
50
+ for (const match of body?.matchAll(regex) ?? []) {
51
+ const fields = parseMarkerFields(match[1] ?? "");
52
+ const pr = Number(fields?.pr);
53
+ if (!fields ||
54
+ !Number.isInteger(pr) ||
55
+ !fields.reviewer ||
56
+ !fields.head ||
57
+ !isMarkerVerdict(fields.verdict)) {
58
+ continue;
59
+ }
60
+ markers.push({
61
+ head: fields.head,
62
+ pr,
63
+ reviewer: fields.reviewer,
64
+ verdict: fields.verdict,
65
+ });
66
+ }
67
+ return markers;
68
+ }
69
+ export function formatReviewFindingMarker(marker) {
70
+ return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
71
+ }
72
+ export function parseReviewFindingMarkers(body) {
73
+ const markers = [];
74
+ const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
75
+ for (const match of body?.matchAll(regex) ?? []) {
76
+ const fields = parseMarkerFields(match[1] ?? "");
77
+ const pr = Number(fields?.pr);
78
+ const finding = Number(fields?.finding);
79
+ if (!fields ||
80
+ !Number.isInteger(pr) ||
81
+ !Number.isInteger(finding) ||
82
+ !fields.reviewer ||
83
+ !fields.head) {
84
+ continue;
85
+ }
86
+ markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
87
+ }
88
+ return markers;
89
+ }
90
+ function markerReviewState(verdict) {
91
+ if (verdict === "MERGE")
92
+ return "APPROVED";
93
+ if (verdict === "CHANGES_REQUESTED")
94
+ return "CHANGES_REQUESTED";
95
+ return "CLOSE";
96
+ }
18
97
  function errorMessage(error) {
19
98
  return error instanceof Error ? error.message : String(error);
20
99
  }
@@ -35,11 +114,12 @@ async function postReviewOutput(input, reviewerKey, output) {
35
114
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
36
115
  if (!reviewer)
37
116
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
117
+ const account = reviewPostingAccount(input.repository, reviewer);
38
118
  if (output.verdict === "MERGE")
39
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
119
+ return postApproval(input.exec, input.repository, input.pr, account);
40
120
  if (output.verdict === "CLOSE")
41
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
42
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
121
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
122
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.findings);
43
123
  }
44
124
  function dryRunReviewPost(key, output) {
45
125
  if (output.verdict === "MERGE")
@@ -86,6 +166,56 @@ export function resolveReviewMode(reviews, accounts, current, accountsWithPendin
86
166
  return { assignments, type: "already_reviewed" };
87
167
  return { assignments, type: "active" };
88
168
  }
169
+ export function resolveSingleAccountReviewMode(input) {
170
+ const reviewerKeySet = new Set(input.reviewerKeys);
171
+ const pendingReviewers = input.pendingReviewers ?? new Set();
172
+ const latest = new Map();
173
+ for (const review of input.reviews) {
174
+ if (review.author.login !== input.account)
175
+ continue;
176
+ if (review.state === "DISMISSED")
177
+ continue;
178
+ for (const marker of parseReviewMarkers(review.body)) {
179
+ if (marker.pr !== input.pr || !reviewerKeySet.has(marker.reviewer)) {
180
+ continue;
181
+ }
182
+ const synthetic = {
183
+ ...review,
184
+ commit: { oid: marker.head },
185
+ comments: (review.comments ?? []).filter((comment) => parseReviewFindingMarkers(comment.body).some((findingMarker) => findingMarker.pr === input.pr &&
186
+ findingMarker.reviewer === marker.reviewer &&
187
+ findingMarker.head === marker.head)),
188
+ state: markerReviewState(marker.verdict),
189
+ };
190
+ const current = latest.get(marker.reviewer);
191
+ if (!current ||
192
+ current.submittedAt.localeCompare(review.submittedAt) < 0) {
193
+ latest.set(marker.reviewer, synthetic);
194
+ }
195
+ }
196
+ }
197
+ const reviewedHead = input.reviewerKeys.every((reviewer) => {
198
+ return (isReviewCurrent(latest.get(reviewer), input.current) &&
199
+ !pendingReviewers.has(reviewer));
200
+ });
201
+ const assignments = new Map();
202
+ for (const reviewer of input.reviewerKeys) {
203
+ const review = latest.get(reviewer);
204
+ if (!review) {
205
+ assignments.set(reviewer, { type: "initial" });
206
+ continue;
207
+ }
208
+ if (isReviewCurrent(review, input.current) &&
209
+ !pendingReviewers.has(reviewer)) {
210
+ assignments.set(reviewer, { review, type: "skip" });
211
+ continue;
212
+ }
213
+ assignments.set(reviewer, { review, type: "rereview" });
214
+ }
215
+ if (latest.size && reviewedHead)
216
+ return { assignments, type: "already_reviewed" };
217
+ return { assignments, type: "active" };
218
+ }
89
219
  export function reviewFreshnessTarget(commits, headSha) {
90
220
  const latestNonMerge = [...commits]
91
221
  .reverse()
@@ -112,6 +242,8 @@ function reviewStateToVerdict(state) {
112
242
  return "MERGE";
113
243
  if (state === "CHANGES_REQUESTED")
114
244
  return "CHANGES_REQUESTED";
245
+ if (state === "CLOSE")
246
+ return "CLOSE";
115
247
  throw new Error(`Unsupported GitHub review state: ${state}`);
116
248
  }
117
249
  function hasBlockingCiReports(reports) {
@@ -163,6 +295,90 @@ export async function inlineCommentTargetsForDiff(input) {
163
295
  cwd: input.worktreePath,
164
296
  }));
165
297
  }
298
+ function firstTargetLine(targets, path) {
299
+ const lines = targets.get(path);
300
+ if (!lines?.size)
301
+ return undefined;
302
+ return [...lines].sort((a, b) => a - b)[0];
303
+ }
304
+ function mergeInlineCommentTargets(left, right) {
305
+ const merged = new Map();
306
+ for (const [path, lines] of [...left, ...right]) {
307
+ const targetLines = merged.get(path) ?? new Set();
308
+ for (const line of lines)
309
+ targetLines.add(line);
310
+ merged.set(path, targetLines);
311
+ }
312
+ return merged;
313
+ }
314
+ function targetLineSummary(targets, path) {
315
+ const lines = targets.get(path);
316
+ if (!lines?.size)
317
+ return "(none)";
318
+ const sorted = [...lines].sort((a, b) => a - b);
319
+ const shown = sorted.slice(0, 12).join(", ");
320
+ return sorted.length > 12 ? `${shown}, ...` : shown;
321
+ }
322
+ function indentedExcerpt(lines) {
323
+ return lines
324
+ .slice(0, 24)
325
+ .map((line) => ` ${line}`)
326
+ .join("\n");
327
+ }
328
+ function parseMergeConflictSections(output) {
329
+ const conflictHeaders = new Set([
330
+ "added in both",
331
+ "changed in both",
332
+ "removed in local",
333
+ "removed in remote",
334
+ ]);
335
+ const sections = [];
336
+ let current;
337
+ for (const line of output.split("\n")) {
338
+ if (!line.trim())
339
+ continue;
340
+ if (!line.startsWith(" ") &&
341
+ !line.startsWith("+") &&
342
+ !line.startsWith("-") &&
343
+ !line.startsWith("@")) {
344
+ current = conflictHeaders.has(line)
345
+ ? { lines: [line], paths: new Set() }
346
+ : undefined;
347
+ if (current)
348
+ sections.push(current);
349
+ continue;
350
+ }
351
+ if (!current)
352
+ continue;
353
+ current.lines.push(line);
354
+ const path = /^ (?:base|our|their)\s+\d+\s+[0-9a-f]+\s+(.+)$/.exec(line)?.[1];
355
+ if (path)
356
+ current.paths.add(path);
357
+ }
358
+ return sections.flatMap((section) => [...section.paths].map((path) => ({
359
+ excerpt: indentedExcerpt(section.lines),
360
+ path,
361
+ })));
362
+ }
363
+ export async function mergeConflictContextForDiff(input) {
364
+ const mergeBase = (await input.exec(`git merge-base ${shellQuote(input.baseSha)} ${shellQuote(input.headSha)}`, { cwd: input.worktreePath })).trim();
365
+ const output = await input.exec(`git merge-tree ${shellQuote(mergeBase)} ${shellQuote(input.headSha)} ${shellQuote(input.baseSha)}`, { cwd: input.worktreePath });
366
+ const conflicts = parseMergeConflictSections(output);
367
+ if (!conflicts.length)
368
+ return "";
369
+ return [
370
+ "The PR currently has unresolved merge conflicts with the base branch.",
371
+ "Treat unresolved conflicts as review findings and request changes when they make the PR unsafe or impossible to merge.",
372
+ "Use suggestedLine when it is present; it is a valid right-side PR diff line for an inline finding.",
373
+ ...conflicts.map((conflict) => {
374
+ const suggestedLine = firstTargetLine(input.inlineCommentTargets, conflict.path);
375
+ const suggestedLineText = suggestedLine
376
+ ? `suggestedLine: ${suggestedLine}`
377
+ : "suggestedLine: (no right-side PR diff line found)";
378
+ return `<conflict_file>\npath: ${conflict.path}\n${suggestedLineText}\nrightSideDiffLines: ${targetLineSummary(input.inlineCommentTargets, conflict.path)}\nmergeTreeExcerpt:\n${conflict.excerpt}\n</conflict_file>`;
379
+ }),
380
+ ].join("\n");
381
+ }
166
382
  function parsePostedFindingLocation(location) {
167
383
  const range = /^(.*):(\d+)-(\d+)$/.exec(location);
168
384
  if (range) {
@@ -211,7 +427,10 @@ function reviewFindingsFromBody(body) {
211
427
  return { findings };
212
428
  }
213
429
  function parsePostedFindingComment(body) {
214
- const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]+?)\s*$/.exec(body);
430
+ const visibleBody = body
431
+ .replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
432
+ .trim();
433
+ const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]*?)(?:\s*\r?\n\r?\n\*\*Reviewer:\*\*[\s\S]*)?\s*$/.exec(visibleBody);
215
434
  if (!match)
216
435
  return undefined;
217
436
  return {
@@ -266,19 +485,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
266
485
  comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
267
486
  });
268
487
  }
488
+ export function assignThreadsByReviewFindingMarker(input) {
489
+ const reviewerKeys = new Set(input.reviewerKeys);
490
+ const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
491
+ for (const thread of input.threads) {
492
+ const markers = [
493
+ thread.body,
494
+ thread.latestBody,
495
+ ...thread.comments.map((comment) => comment.body),
496
+ ]
497
+ .flatMap(parseReviewFindingMarkers)
498
+ .filter((marker) => {
499
+ return (marker.pr === input.pr &&
500
+ reviewerKeys.has(marker.reviewer) &&
501
+ (!input.headSha || marker.head === input.headSha));
502
+ });
503
+ const reviewers = markers.length
504
+ ? [...new Set(markers.map((marker) => marker.reviewer))]
505
+ : input.fallbackReviewerKeys;
506
+ for (const reviewer of reviewers)
507
+ assigned[reviewer]?.push(thread);
508
+ }
509
+ return assigned;
510
+ }
511
+ function outputFindings(reviewer, output) {
512
+ if (output.verdict !== "CHANGES_REQUESTED")
513
+ return [];
514
+ if ("findings" in output) {
515
+ return output.findings.map((finding, index) => ({
516
+ finding,
517
+ index,
518
+ reviewer,
519
+ }));
520
+ }
521
+ return output.newFindings.map((finding, index) => ({
522
+ finding: {
523
+ fix: "Please address this before merging.",
524
+ issue: finding.body,
525
+ line: finding.line,
526
+ path: finding.path,
527
+ startLine: finding.startLine,
528
+ },
529
+ index,
530
+ reviewer,
531
+ }));
532
+ }
533
+ function singleReviewBody(input) {
534
+ const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
535
+ const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
536
+ ? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
537
+ : []);
538
+ const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
539
+ const line = finding.startLine == null || finding.startLine === finding.line
540
+ ? String(finding.line)
541
+ : `${finding.startLine}-${finding.line}`;
542
+ return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
543
+ }));
544
+ const lines = [
545
+ `Magi single-account review result: ${input.verdict}.`,
546
+ "",
547
+ "Logical reviewer verdicts:",
548
+ ...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
549
+ ...(input.verdict === "CLOSE" && closeReasons.length
550
+ ? ["", "Close reasons:", ...closeReasons]
551
+ : []),
552
+ ...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
553
+ ? ["", "Accepted change requests:", ...acceptedFindings]
554
+ : []),
555
+ "",
556
+ ...outputs.map(([reviewer, output]) => formatReviewMarker({
557
+ head: input.headSha,
558
+ pr: input.pr,
559
+ reviewer,
560
+ verdict: output.verdict,
561
+ })),
562
+ ];
563
+ return lines.join("\n");
564
+ }
565
+ function singleFindingBody(input) {
566
+ return [
567
+ `**Issue:** ${input.finding.issue}`,
568
+ "",
569
+ `**Fix:** ${input.finding.fix}`,
570
+ "",
571
+ `**Reviewer:** ${input.reviewer}`,
572
+ "",
573
+ formatReviewFindingMarker({
574
+ finding: input.index,
575
+ head: input.headSha,
576
+ pr: input.pr,
577
+ reviewer: input.reviewer,
578
+ }),
579
+ ].join("\n");
580
+ }
581
+ export async function postSingleConsensusReview(input) {
582
+ const account = input.repository.review?.account;
583
+ if (!account)
584
+ throw new Error("review.account is required for single review mode");
585
+ const body = singleReviewBody(input);
586
+ if (input.verdict === "MERGE") {
587
+ return postApproval(input.exec, input.repository, input.pr, account, body);
588
+ }
589
+ if (input.verdict === "CLOSE") {
590
+ return postCloseComment(input.exec, input.repository, input.pr, account, body);
591
+ }
592
+ const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
593
+ return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
594
+ body,
595
+ commentBodies: findings.map((item) => singleFindingBody({
596
+ finding: item.finding,
597
+ headSha: input.headSha,
598
+ index: item.index,
599
+ pr: input.pr,
600
+ reviewer: item.reviewer,
601
+ })),
602
+ });
603
+ }
269
604
  async function postRereviewOutput(input, reviewerKey, output) {
270
605
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
271
606
  if (!reviewer)
272
607
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
273
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
274
- await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
608
+ const account = reviewPostingAccount(input.repository, reviewer);
609
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
610
+ await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
275
611
  if (output.verdict === "MERGE")
276
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
612
+ return postApproval(input.exec, input.repository, input.pr, account);
277
613
  if (output.verdict === "CLOSE")
278
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
614
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
279
615
  if (!output.newFindings.length)
280
616
  return "";
281
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
617
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
282
618
  fix: "Please address this before merging.",
283
619
  issue: finding.body,
284
620
  path: finding.path,
@@ -586,23 +922,68 @@ export async function runReview(input) {
586
922
  const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
587
923
  const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
588
924
  const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
925
+ const singleReviewMode = resolvedReviewMode(input.repository) === "single";
926
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
589
927
  const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
590
- const preliminaryMode = resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
928
+ const preliminaryMode = singleReviewMode
929
+ ? resolveSingleAccountReviewMode({
930
+ account: input.repository.review?.account ?? "",
931
+ current: freshnessTarget,
932
+ pr: input.pr,
933
+ reviewerKeys,
934
+ reviews,
935
+ })
936
+ : resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
591
937
  const unresolvedThreadsByAccount = new Map();
938
+ const unresolvedThreadsByReviewer = new Map();
592
939
  const pendingThreadReplyAccounts = new Set();
940
+ const pendingThreadReplyReviewers = new Set();
593
941
  const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
594
- return preliminaryMode.assignments.get(reviewer.account)?.type === "skip";
942
+ return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
595
943
  });
596
- await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
597
- const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
598
- unresolvedThreadsByAccount.set(reviewer.account, threads);
599
- if (hasPendingThreadReply(threads, reviewer.account)) {
600
- pendingThreadReplyAccounts.add(reviewer.account);
944
+ if (singleReviewMode) {
945
+ const account = input.repository.review?.account ?? "";
946
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
947
+ const assigned = assignThreadsByReviewFindingMarker({
948
+ fallbackReviewerKeys: reviewerKeys,
949
+ pr: input.pr,
950
+ reviewerKeys,
951
+ threads,
952
+ });
953
+ for (const reviewer of input.repository.agents.reviewers) {
954
+ const reviewerThreads = assigned[reviewer.key] ?? [];
955
+ unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
956
+ if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
957
+ continue;
958
+ }
959
+ if (hasPendingThreadReply(reviewerThreads, account)) {
960
+ pendingThreadReplyReviewers.add(reviewer.key);
961
+ }
601
962
  }
602
- }, { signal: input.signal });
603
- const mode = pendingThreadReplyAccounts.size
604
- ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
605
- : preliminaryMode;
963
+ }
964
+ else {
965
+ await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
966
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
967
+ unresolvedThreadsByAccount.set(reviewer.account, threads);
968
+ if (hasPendingThreadReply(threads, reviewer.account)) {
969
+ pendingThreadReplyAccounts.add(reviewer.account);
970
+ }
971
+ }, { signal: input.signal });
972
+ }
973
+ const mode = singleReviewMode
974
+ ? pendingThreadReplyReviewers.size
975
+ ? resolveSingleAccountReviewMode({
976
+ account: input.repository.review?.account ?? "",
977
+ current: freshnessTarget,
978
+ pendingReviewers: pendingThreadReplyReviewers,
979
+ pr: input.pr,
980
+ reviewerKeys,
981
+ reviews,
982
+ })
983
+ : preliminaryMode
984
+ : pendingThreadReplyAccounts.size
985
+ ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
986
+ : preliminaryMode;
606
987
  if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
607
988
  throw new Error("PR has already been reviewed by all configured accounts");
608
989
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
@@ -692,7 +1073,7 @@ export async function runReview(input) {
692
1073
  try {
693
1074
  throwIfAborted(input.signal);
694
1075
  const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
695
- const assignment = mode.assignments.get(reviewer.account);
1076
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
696
1077
  if (!assignment || assignment.type === "skip")
697
1078
  return [];
698
1079
  return [{ assignment, reviewer }];
@@ -709,8 +1090,15 @@ export async function runReview(input) {
709
1090
  toSha: meta.headRefOid,
710
1091
  worktreePath,
711
1092
  });
1093
+ const mergeConflictContext = await mergeConflictContextForDiff({
1094
+ baseSha: meta.baseRefOid,
1095
+ exec,
1096
+ headSha: meta.headRefOid,
1097
+ inlineCommentTargets: initialInlineCommentTargets,
1098
+ worktreePath,
1099
+ });
712
1100
  for (const reviewer of input.repository.agents.reviewers) {
713
- const assignment = mode.assignments.get(reviewer.account);
1101
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
714
1102
  if (assignment?.type !== "skip")
715
1103
  continue;
716
1104
  await input.onProgress?.({
@@ -740,13 +1128,18 @@ export async function runReview(input) {
740
1128
  toSha: meta.headRefOid,
741
1129
  worktreePath,
742
1130
  });
743
- const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
744
- (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
1131
+ const rereviewInlineCommentTargets = mergeConflictContext
1132
+ ? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
1133
+ : inlineCommentTargets;
1134
+ const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
1135
+ unresolvedThreadsByAccount.get(reviewer.account) ??
1136
+ (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
745
1137
  const prompt = await composeRereviewPrompt({
746
1138
  baseSha: meta.baseRefOid,
747
1139
  ciFailureContext,
748
1140
  directory: input.directory,
749
1141
  headSha: meta.headRefOid,
1142
+ mergeConflictContext,
750
1143
  pr: input.pr,
751
1144
  previousReview: previousReviewText(previous),
752
1145
  previousHeadSha: previous.commit.oid,
@@ -787,7 +1180,7 @@ export async function runReview(input) {
787
1180
  },
788
1181
  options: reviewer.options,
789
1182
  parentSessionId: input.parentSessionId,
790
- parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
1183
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, rereviewInlineCommentTargets),
791
1184
  permission: reviewer.permission,
792
1185
  prompt,
793
1186
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -819,6 +1212,7 @@ export async function runReview(input) {
819
1212
  ciFailureContext,
820
1213
  directory: input.directory,
821
1214
  headSha: meta.headRefOid,
1215
+ mergeConflictContext,
822
1216
  pr: input.pr,
823
1217
  repository: input.repository,
824
1218
  reviewContext,
@@ -885,7 +1279,7 @@ export async function runReview(input) {
885
1279
  throwIfAborted(input.signal);
886
1280
  const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
887
1281
  const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
888
- const assignment = mode.assignments.get(reviewer.account);
1282
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
889
1283
  if (assignment?.type !== "skip")
890
1284
  return [];
891
1285
  return [
@@ -903,7 +1297,7 @@ export async function runReview(input) {
903
1297
  })),
904
1298
  ]);
905
1299
  const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
906
- const assignment = mode.assignments.get(reviewer.account);
1300
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
907
1301
  if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
908
1302
  return [];
909
1303
  return [
@@ -937,14 +1331,14 @@ export async function runReview(input) {
937
1331
  });
938
1332
  const activeOutputs = validation.outputs;
939
1333
  const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
940
- const assignment = mode.assignments.get(reviewer.account);
1334
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
941
1335
  return assignment?.type === "skip"
942
1336
  ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
943
1337
  : [];
944
1338
  }));
945
1339
  const outputs = { ...skippedOutputs, ...activeOutputs };
946
1340
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
947
- const assignment = mode.assignments.get(reviewer.account);
1341
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
948
1342
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
949
1343
  return [];
950
1344
  return [
@@ -960,23 +1354,68 @@ export async function runReview(input) {
960
1354
  }));
961
1355
  const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
962
1356
  await input.onProgress?.({ phase: "posting reviews", type: "phase" });
963
- const posted = {
964
- ...Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
965
- const assignment = mode.assignments.get(reviewer.account);
966
- return assignment?.type === "skip"
967
- ? [[reviewer.key, "skipped: already reviewed current head"]]
968
- : [];
969
- })),
970
- ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
971
- key,
972
- input.dryRun
973
- ? dryRunReviewPost(key, output)
974
- : "resolve" in output
975
- ? await postRereviewOutput({ ...input, exec }, key, output)
976
- : await postReviewOutput({ ...input, exec }, key, output),
977
- ]))),
978
- };
979
- const automationAccount = input.repository.agents.reviewers[0]?.account;
1357
+ const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1358
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1359
+ return assignment?.type === "skip"
1360
+ ? [[reviewer.key, "skipped: already reviewed current head"]]
1361
+ : [];
1362
+ }));
1363
+ const posted = singleReviewMode
1364
+ ? {
1365
+ ...skippedPosted,
1366
+ ...(Object.keys(activeOutputs).length
1367
+ ? {
1368
+ consensus: input.dryRun
1369
+ ? `dry-run:would-post-single-review:${verdict}`
1370
+ : await (async () => {
1371
+ const account = input.repository.review?.account ?? "";
1372
+ await Promise.all(Object.values(activeOutputs).flatMap((output) => {
1373
+ if (!("resolve" in output))
1374
+ return [];
1375
+ return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
1376
+ }));
1377
+ await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
1378
+ if (!("followUps" in output))
1379
+ return [];
1380
+ return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
1381
+ `**Reviewer:** ${key}`,
1382
+ "",
1383
+ item.body,
1384
+ "",
1385
+ formatReviewMarker({
1386
+ head: meta.headRefOid,
1387
+ pr: input.pr,
1388
+ reviewer: key,
1389
+ verdict: output.verdict,
1390
+ }),
1391
+ ].join("\n")));
1392
+ }));
1393
+ return postSingleConsensusReview({
1394
+ exec,
1395
+ headSha: meta.headRefOid,
1396
+ outputs,
1397
+ pr: input.pr,
1398
+ repository: input.repository,
1399
+ verdict,
1400
+ });
1401
+ })(),
1402
+ }
1403
+ : {}),
1404
+ }
1405
+ : {
1406
+ ...skippedPosted,
1407
+ ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
1408
+ key,
1409
+ input.dryRun
1410
+ ? dryRunReviewPost(key, output)
1411
+ : "resolve" in output
1412
+ ? await postRereviewOutput({ ...input, exec }, key, output)
1413
+ : await postReviewOutput({ ...input, exec }, key, output),
1414
+ ]))),
1415
+ };
1416
+ const automationAccount = singleReviewMode
1417
+ ? input.repository.review?.account
1418
+ : input.repository.agents.reviewers[0]?.account;
980
1419
  const enableReviewAutomation = input.enableReviewAutomation ?? true;
981
1420
  if (enableReviewAutomation &&
982
1421
  verdict === "MERGE" &&