opencode-magi 0.0.0-dev-20260525101932 → 0.0.0-dev-20260525154011

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
@@ -17,7 +17,7 @@ OpenCode Magi recreates the review cycle humans already run on GitHub: multiple
17
17
  - Multi-agent reviews with an odd-number majority of 3 or more reviewers.
18
18
  - Optional unanimous approval policy for merge automation when every reviewer must approve before a PR is merged.
19
19
  - Finding-level voting before posting change requests, so only findings accepted by reviewer majority are submitted.
20
- - Each reviewer acts through its configured GitHub account, posting real reviews, approvals, change requests, and follow-up comments.
20
+ - Multi-account review mode where each reviewer posts through its configured GitHub account, plus single-account review mode where one GitHub account posts the consensus result for multiple logical reviewers.
21
21
  - Re-review support for edited PRs: fixed threads are resolved, satisfied reviewers approve, and remaining issues are posted as additional comments.
22
22
  - Optional merge and close automation where an editor agent responds on behalf of the author, fixes changes it agrees with, pushes commits when needed, and repeats the reviewer/editor cycle until the PR can be approved, queued, merged, or closed.
23
23
  - Per-agent OpenCode permissions for reviewer, CI classifier, and editor child sessions.
@@ -87,7 +87,23 @@ Add the following content to the configuration file.
87
87
  }
88
88
  ```
89
89
 
90
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
90
+ After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals in the default `review.mode: "multi"`. Must be authenticated with `gh auth token --user <account>` and must be unique.
91
+
92
+ For individual setups, use `review.mode: "single"` with one `review.account`. Magi still runs an odd number of at least 3 logical reviewer agents and keeps majority voting, finding validation, and close reconsideration, but GitHub branch protection sees only the one configured review account.
93
+
94
+ ```json
95
+ {
96
+ "review": {
97
+ "mode": "single",
98
+ "account": "your-account",
99
+ "reviewers": [
100
+ { "id": "general", "model": "openai/gpt-5.5" },
101
+ { "id": "security", "model": "anthropic/claude-opus-4-7" },
102
+ { "id": "compat", "model": "opencode/kimi-k2-6" }
103
+ ]
104
+ }
105
+ }
106
+ ```
91
107
 
92
108
  #### Set project config
93
109
 
@@ -165,7 +181,7 @@ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` o
165
181
  }
166
182
  ```
167
183
 
168
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
184
+ After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals in `multi` mode. Must be authenticated with `gh auth token --user <account>` and must be unique. In `single` mode, `review.account` is used for reviewer-originated review posts, approvals, change requests, close comments, reviewer replies, and reviewer thread resolutions. `merge.editor.account` is still used by `/magi:merge` to push fixes, close PRs, and merge PRs.
169
185
 
170
186
  #### Validate config
171
187
 
@@ -113,6 +113,7 @@ export function resolveAgents(config) {
113
113
  const agents = config.agents ?? {};
114
114
  const editor = config.merge?.editor;
115
115
  const creator = config.triage?.creator;
116
+ const singleReviewAccount = config.review?.mode === "single" ? config.review.account : undefined;
116
117
  return {
117
118
  editor: editor
118
119
  ? {
@@ -123,6 +124,7 @@ export function resolveAgents(config) {
123
124
  : undefined,
124
125
  reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
125
126
  ...reviewer,
127
+ account: singleReviewAccount ?? reviewer.account ?? "",
126
128
  key: reviewerKey(reviewer, index),
127
129
  index,
128
130
  model: normalizedModel(reviewer.model),
@@ -204,6 +206,10 @@ export function resolveRepository(config) {
204
206
  review: config.review?.prompts?.review,
205
207
  reviewGuidelines: config.review?.prompts?.reviewGuidelines,
206
208
  },
209
+ review: {
210
+ account: config.review?.account,
211
+ mode: config.review?.mode ?? "multi",
212
+ },
207
213
  reviewAutomation: {
208
214
  close: config.review?.automation?.close ?? false,
209
215
  merge: config.review?.automation?.merge ?? true,
@@ -54,10 +54,12 @@ const TRIAGE_CREATOR_KEYS = new Set([
54
54
  const AUTHOR_KEYS = new Set(["email", "name"]);
55
55
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
56
56
  const REVIEW_KEYS = new Set([
57
+ "account",
57
58
  "automation",
58
59
  "checks",
59
60
  "concurrency",
60
61
  "merge",
62
+ "mode",
61
63
  "output",
62
64
  "prompts",
63
65
  "reviewers",
@@ -384,7 +386,7 @@ function validateAndNormalizeModel(target, path, errors, catalog) {
384
386
  }
385
387
  errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
386
388
  }
387
- function validateReviewerList(reviewers, path, errors, catalog) {
389
+ function validateReviewerList(reviewers, path, errors, catalog, mode = "multi") {
388
390
  if (reviewers == null)
389
391
  return;
390
392
  if (!Array.isArray(reviewers)) {
@@ -404,7 +406,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
404
406
  if (!reviewer.model)
405
407
  errors.push(`${path}[${index}].model is required`);
406
408
  validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
407
- if (!reviewer.account)
409
+ if (mode === "multi" && !reviewer.account)
408
410
  errors.push(`${path}[${index}].account is required`);
409
411
  validateString(reviewer.account, `${path}[${index}].account`, errors);
410
412
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
@@ -454,18 +456,31 @@ function validateTriageAgentList(voters, path, errors, catalog) {
454
456
  }
455
457
  });
456
458
  }
457
- function validateResolvedReviewers(reviewers, path, errors) {
459
+ function validateResolvedReviewers(reviewers, path, errors, mode = "multi") {
458
460
  const keys = new Set();
459
461
  const accounts = new Set();
460
462
  for (const reviewer of reviewers) {
461
463
  if (keys.has(reviewer.key))
462
464
  errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
463
465
  keys.add(reviewer.key);
464
- if (accounts.has(reviewer.account))
466
+ if (mode === "multi" && accounts.has(reviewer.account))
465
467
  errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
466
468
  accounts.add(reviewer.account);
467
469
  }
468
470
  }
471
+ function reviewMode(config) {
472
+ return config.review?.mode === "single" ? "single" : "multi";
473
+ }
474
+ function validateReviewIdentity(config, errors) {
475
+ const mode = config.review?.mode;
476
+ if (mode != null && mode !== "multi" && mode !== "single") {
477
+ errors.push("review.mode must be multi or single");
478
+ }
479
+ validateString(config.review?.account, "review.account", errors);
480
+ if (mode === "single" && !config.review?.account) {
481
+ errors.push("review.account is required when review.mode is single");
482
+ }
483
+ }
469
484
  function validateResolvedTriageAgents(agents, path, errors) {
470
485
  const keys = new Set();
471
486
  const accounts = new Set();
@@ -908,8 +923,14 @@ async function validatePrompts(config, errors, directory) {
908
923
  async function validateAuth(config, exec, errors) {
909
924
  const accounts = new Set();
910
925
  const agents = resolveAgents(config);
911
- for (const reviewer of agents.reviewers)
912
- accounts.add(reviewer.account);
926
+ if (reviewMode(config) === "single") {
927
+ if (config.review?.account)
928
+ accounts.add(config.review.account);
929
+ }
930
+ else {
931
+ for (const reviewer of agents.reviewers)
932
+ accounts.add(reviewer.account);
933
+ }
913
934
  for (const agent of agents.triage ?? [])
914
935
  accounts.add(agent.account);
915
936
  if (agents.editor)
@@ -956,15 +977,20 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
956
977
  if (!config.github?.owner || !config.github.repo)
957
978
  return;
958
979
  const agents = resolveAgents(config);
959
- await Promise.all(agents.reviewers.map(async (reviewer) => {
980
+ const reviewAccounts = reviewMode(config) === "single"
981
+ ? config.review?.account
982
+ ? [config.review.account]
983
+ : []
984
+ : agents.reviewers.map((reviewer) => reviewer.account);
985
+ await Promise.all(reviewAccounts.map(async (account) => {
960
986
  try {
961
- const permissions = await fetchPermissions(config, exec, reviewer.account);
987
+ const permissions = await fetchPermissions(config, exec, account);
962
988
  if (!permissions.pull) {
963
- errors.push(`GitHub account cannot read repository for PR review: ${reviewer.account}`);
989
+ errors.push(`GitHub account cannot read repository for PR review: ${account}`);
964
990
  }
965
991
  }
966
992
  catch (error) {
967
- warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
993
+ warnings.push(`Could not validate repository permissions for GitHub account: ${account} (${error.message})`);
968
994
  }
969
995
  }));
970
996
  await Promise.all((agents.triage ?? []).map(async (agent) => {
@@ -1026,15 +1052,17 @@ export async function validateConfig(config, options = {}) {
1026
1052
  errors.push("review is required");
1027
1053
  }
1028
1054
  else if (config.review) {
1055
+ const mode = reviewMode(config);
1029
1056
  if (!isPlainObject(config.review)) {
1030
1057
  errors.push("review must be an object");
1031
1058
  }
1032
1059
  else {
1033
1060
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
1034
1061
  }
1062
+ validateReviewIdentity(config, errors);
1035
1063
  if (!config.review.reviewers)
1036
1064
  errors.push("review.reviewers is required");
1037
- validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
1065
+ validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
1038
1066
  if (Array.isArray(config.review.reviewers)) {
1039
1067
  validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
1040
1068
  account: reviewer &&
@@ -1045,7 +1073,7 @@ export async function validateConfig(config, options = {}) {
1045
1073
  key: reviewer && typeof reviewer === "object"
1046
1074
  ? reviewerKey(reviewer, index)
1047
1075
  : "",
1048
- })), "review.resolvedReviewers", errors);
1076
+ })), "review.resolvedReviewers", errors, mode);
1049
1077
  }
1050
1078
  }
1051
1079
  if (options.requireTriage && !config.triage) {
@@ -592,8 +592,18 @@ export async function removeWorktree(exec, worktreePath) {
592
592
  export async function removeBranch(exec, branch) {
593
593
  await exec(`git branch -D ${shellQuote(branch)}`);
594
594
  }
595
- export async function postApproval(exec, repository, pr, account) {
595
+ export async function postApproval(exec, repository, pr, account, body) {
596
596
  const token = await ghToken(exec, repository, account);
597
+ if (body != null) {
598
+ const payloadPath = join(tmpdir(), `magi-approve-${process.pid}-${Date.now()}.json`);
599
+ await writeFile(payloadPath, JSON.stringify({ body, event: "APPROVE" }));
600
+ try {
601
+ return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
602
+ }
603
+ finally {
604
+ await rm(payloadPath, { force: true });
605
+ }
606
+ }
597
607
  return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
598
608
  }
599
609
  export async function postCloseComment(exec, repository, pr, account, body) {
@@ -607,9 +617,9 @@ export async function postCloseComment(exec, repository, pr, account, body) {
607
617
  await rm(payloadPath, { force: true });
608
618
  }
609
619
  }
610
- function findingComment(finding) {
620
+ function findingComment(finding, body) {
611
621
  const comment = {
612
- body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
622
+ body: body ?? `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
613
623
  line: finding.line,
614
624
  path: finding.path,
615
625
  side: "RIGHT",
@@ -625,13 +635,13 @@ function changesRequestedBody(findings) {
625
635
  ? "Changes requested: 1 inline comment."
626
636
  : `Changes requested: ${findings.length} inline comments.`;
627
637
  }
628
- export async function postChangesRequested(exec, repository, pr, account, findings) {
638
+ export async function postChangesRequested(exec, repository, pr, account, findings, options = {}) {
629
639
  const token = await ghToken(exec, repository, account);
630
640
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
631
- const body = changesRequestedBody(findings);
641
+ const body = options.body ?? changesRequestedBody(findings);
632
642
  await writeFile(payloadPath, JSON.stringify({
633
643
  body,
634
- comments: findings.map(findingComment),
644
+ comments: findings.map((finding, index) => findingComment(finding, options.commentBodies?.[index])),
635
645
  event: "REQUEST_CHANGES",
636
646
  }));
637
647
  try {
@@ -11,7 +11,7 @@ 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 { inlineCommentTargetsForDiff, runReview, } from "./review";
14
+ import { inlineCommentTargetsForDiff, assignThreadsByReviewFindingMarker, formatReviewMarker, postSingleConsensusReview, runReview, reviewPostingAccount, } from "./review";
15
15
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
16
  function outputDir(input) {
17
17
  return prRunOutputDir({
@@ -131,6 +131,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
131
131
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
132
132
  if (!reviewer)
133
133
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
134
+ const account = reviewPostingAccount(input.repository, reviewer);
134
135
  if (input.dryRun) {
135
136
  if (output.verdict === "MERGE")
136
137
  return `dry-run:would-approve:${reviewerKey}`;
@@ -139,16 +140,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
139
140
  }
140
141
  return `dry-run:would-request-changes:${reviewerKey}`;
141
142
  }
142
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
143
- const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
143
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
144
+ const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
144
145
  if (output.verdict === "MERGE") {
145
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
146
+ return postApproval(input.exec, input.repository, input.pr, account);
146
147
  }
147
148
  if (output.verdict === "CLOSE") {
148
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
149
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
149
150
  }
150
151
  if (output.newFindings.length) {
151
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
152
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
152
153
  fix: "Please address this before merging.",
153
154
  issue: finding.body,
154
155
  path: finding.path,
@@ -212,10 +213,23 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
212
213
  worktreePath,
213
214
  });
214
215
  const artifactDir = outputDir(input);
216
+ const singleReviewMode = input.repository.review?.mode === "single";
217
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
218
+ const singleModeThreads = singleReviewMode
219
+ ? assignThreadsByReviewFindingMarker({
220
+ fallbackReviewerKeys: reviewerKeys,
221
+ pr: input.pr,
222
+ reviewerKeys,
223
+ threads: options.dryRunThreads == null
224
+ ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.review?.account ?? "")
225
+ : Object.values(options.dryRunThreads).flat(),
226
+ })
227
+ : undefined;
215
228
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
216
229
  throwIfAborted(input.signal);
217
- const unresolved = options.dryRunThreads?.[reviewer.key] ??
218
- (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewer.account));
230
+ const unresolved = singleModeThreads?.[reviewer.key] ??
231
+ options.dryRunThreads?.[reviewer.key] ??
232
+ (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
219
233
  const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
220
234
  const prompt = await composeRereviewPrompt({
221
235
  baseSha: meta.baseRefOid,
@@ -388,14 +402,44 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
388
402
  };
389
403
  }));
390
404
  }
391
- const posted = Object.fromEntries(await Promise.all(entries.map(async (entry) => [
392
- entry.reviewer,
393
- await postRereviewOutput(input, entry.reviewer, entry.output),
394
- ])));
395
405
  const verdict = mergeVerdictForPolicy(entries.map((entry) => ({
396
406
  reviewer: entry.reviewer,
397
407
  verdict: entry.verdict,
398
408
  })), input.repository.merge.approvalPolicy);
409
+ const outputs = Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output]));
410
+ const posted = singleReviewMode
411
+ ? input.dryRun
412
+ ? { consensus: `dry-run:would-post-single-review:${verdict}` }
413
+ : {
414
+ consensus: await (async () => {
415
+ const account = input.repository.review?.account ?? "";
416
+ await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
417
+ await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
418
+ `**Reviewer:** ${entry.reviewer}`,
419
+ "",
420
+ item.body,
421
+ "",
422
+ formatReviewMarker({
423
+ head: headSha,
424
+ pr: input.pr,
425
+ reviewer: entry.reviewer,
426
+ verdict: entry.output.verdict,
427
+ }),
428
+ ].join("\n")))));
429
+ return postSingleConsensusReview({
430
+ exec: input.exec,
431
+ headSha,
432
+ outputs,
433
+ pr: input.pr,
434
+ repository: input.repository,
435
+ verdict,
436
+ });
437
+ })(),
438
+ }
439
+ : Object.fromEntries(await Promise.all(entries.map(async (entry) => [
440
+ entry.reviewer,
441
+ await postRereviewOutput(input, entry.reviewer, entry.output),
442
+ ])));
399
443
  await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
400
444
  approvalPolicy: input.repository.merge.approvalPolicy,
401
445
  verdict,
@@ -405,7 +449,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
405
449
  })),
406
450
  }, null, 2));
407
451
  return {
408
- outputs: Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output])),
452
+ outputs,
409
453
  posted,
410
454
  verdict,
411
455
  };
@@ -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 === "single" ? "single" : "multi";
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) {
@@ -295,7 +427,10 @@ function reviewFindingsFromBody(body) {
295
427
  return { findings };
296
428
  }
297
429
  function parsePostedFindingComment(body) {
298
- 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);
299
434
  if (!match)
300
435
  return undefined;
301
436
  return {
@@ -350,19 +485,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
350
485
  comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
351
486
  });
352
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
+ }
353
604
  async function postRereviewOutput(input, reviewerKey, output) {
354
605
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
355
606
  if (!reviewer)
356
607
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
357
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
358
- 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)));
359
611
  if (output.verdict === "MERGE")
360
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
612
+ return postApproval(input.exec, input.repository, input.pr, account);
361
613
  if (output.verdict === "CLOSE")
362
- 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.");
363
615
  if (!output.newFindings.length)
364
616
  return "";
365
- 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) => ({
366
618
  fix: "Please address this before merging.",
367
619
  issue: finding.body,
368
620
  path: finding.path,
@@ -670,23 +922,68 @@ export async function runReview(input) {
670
922
  const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
671
923
  const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
672
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);
673
927
  const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
674
- 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);
675
937
  const unresolvedThreadsByAccount = new Map();
938
+ const unresolvedThreadsByReviewer = new Map();
676
939
  const pendingThreadReplyAccounts = new Set();
940
+ const pendingThreadReplyReviewers = new Set();
677
941
  const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
678
- return preliminaryMode.assignments.get(reviewer.account)?.type === "skip";
942
+ return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
679
943
  });
680
- await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
681
- const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
682
- unresolvedThreadsByAccount.set(reviewer.account, threads);
683
- if (hasPendingThreadReply(threads, reviewer.account)) {
684
- 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
+ }
685
962
  }
686
- }, { signal: input.signal });
687
- const mode = pendingThreadReplyAccounts.size
688
- ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
689
- : 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;
690
987
  if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
691
988
  throw new Error("PR has already been reviewed by all configured accounts");
692
989
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
@@ -776,7 +1073,7 @@ export async function runReview(input) {
776
1073
  try {
777
1074
  throwIfAborted(input.signal);
778
1075
  const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
779
- const assignment = mode.assignments.get(reviewer.account);
1076
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
780
1077
  if (!assignment || assignment.type === "skip")
781
1078
  return [];
782
1079
  return [{ assignment, reviewer }];
@@ -801,7 +1098,7 @@ export async function runReview(input) {
801
1098
  worktreePath,
802
1099
  });
803
1100
  for (const reviewer of input.repository.agents.reviewers) {
804
- const assignment = mode.assignments.get(reviewer.account);
1101
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
805
1102
  if (assignment?.type !== "skip")
806
1103
  continue;
807
1104
  await input.onProgress?.({
@@ -834,8 +1131,9 @@ export async function runReview(input) {
834
1131
  const rereviewInlineCommentTargets = mergeConflictContext
835
1132
  ? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
836
1133
  : inlineCommentTargets;
837
- const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
838
- (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
1134
+ const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
1135
+ unresolvedThreadsByAccount.get(reviewer.account) ??
1136
+ (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
839
1137
  const prompt = await composeRereviewPrompt({
840
1138
  baseSha: meta.baseRefOid,
841
1139
  ciFailureContext,
@@ -981,7 +1279,7 @@ export async function runReview(input) {
981
1279
  throwIfAborted(input.signal);
982
1280
  const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
983
1281
  const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
984
- const assignment = mode.assignments.get(reviewer.account);
1282
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
985
1283
  if (assignment?.type !== "skip")
986
1284
  return [];
987
1285
  return [
@@ -999,7 +1297,7 @@ export async function runReview(input) {
999
1297
  })),
1000
1298
  ]);
1001
1299
  const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
1002
- const assignment = mode.assignments.get(reviewer.account);
1300
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1003
1301
  if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
1004
1302
  return [];
1005
1303
  return [
@@ -1033,14 +1331,14 @@ export async function runReview(input) {
1033
1331
  });
1034
1332
  const activeOutputs = validation.outputs;
1035
1333
  const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1036
- const assignment = mode.assignments.get(reviewer.account);
1334
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1037
1335
  return assignment?.type === "skip"
1038
1336
  ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
1039
1337
  : [];
1040
1338
  }));
1041
1339
  const outputs = { ...skippedOutputs, ...activeOutputs };
1042
1340
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
1043
- const assignment = mode.assignments.get(reviewer.account);
1341
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1044
1342
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
1045
1343
  return [];
1046
1344
  return [
@@ -1056,23 +1354,68 @@ export async function runReview(input) {
1056
1354
  }));
1057
1355
  const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
1058
1356
  await input.onProgress?.({ phase: "posting reviews", type: "phase" });
1059
- const posted = {
1060
- ...Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1061
- const assignment = mode.assignments.get(reviewer.account);
1062
- return assignment?.type === "skip"
1063
- ? [[reviewer.key, "skipped: already reviewed current head"]]
1064
- : [];
1065
- })),
1066
- ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
1067
- key,
1068
- input.dryRun
1069
- ? dryRunReviewPost(key, output)
1070
- : "resolve" in output
1071
- ? await postRereviewOutput({ ...input, exec }, key, output)
1072
- : await postReviewOutput({ ...input, exec }, key, output),
1073
- ]))),
1074
- };
1075
- 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;
1076
1419
  const enableReviewAutomation = input.enableReviewAutomation ?? true;
1077
1420
  if (enableReviewAutomation &&
1078
1421
  verdict === "MERGE" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260525101932",
3
+ "version": "0.0.0-dev-20260525154011",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
package/schema.json CHANGED
@@ -77,7 +77,7 @@
77
77
  "reviewer": {
78
78
  "type": "object",
79
79
  "if": { "not": { "required": ["ref"] } },
80
- "then": { "required": ["model", "account"] },
80
+ "then": { "required": ["model"] },
81
81
  "additionalProperties": false,
82
82
  "properties": {
83
83
  "ref": { "type": "string", "minLength": 1 },
@@ -366,6 +366,8 @@
366
366
  "type": "object",
367
367
  "additionalProperties": false,
368
368
  "properties": {
369
+ "mode": { "enum": ["multi", "single"], "default": "multi" },
370
+ "account": { "type": "string", "minLength": 1 },
369
371
  "reviewers": {
370
372
  "type": "array",
371
373
  "minItems": 3,