opencode-magi 0.0.0-dev-20260525101932 → 0.0.0-dev-20260525141349

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,24 @@ 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
+ headSha,
222
+ pr: input.pr,
223
+ reviewerKeys,
224
+ threads: options.dryRunThreads == null
225
+ ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.review?.account ?? "")
226
+ : Object.values(options.dryRunThreads).flat(),
227
+ })
228
+ : undefined;
215
229
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
216
230
  throwIfAborted(input.signal);
217
- const unresolved = options.dryRunThreads?.[reviewer.key] ??
218
- (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewer.account));
231
+ const unresolved = singleModeThreads?.[reviewer.key] ??
232
+ options.dryRunThreads?.[reviewer.key] ??
233
+ (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
219
234
  const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
220
235
  const prompt = await composeRereviewPrompt({
221
236
  baseSha: meta.baseRefOid,
@@ -388,14 +403,44 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
388
403
  };
389
404
  }));
390
405
  }
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
406
  const verdict = mergeVerdictForPolicy(entries.map((entry) => ({
396
407
  reviewer: entry.reviewer,
397
408
  verdict: entry.verdict,
398
409
  })), input.repository.merge.approvalPolicy);
410
+ const outputs = Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output]));
411
+ const posted = singleReviewMode
412
+ ? input.dryRun
413
+ ? { consensus: `dry-run:would-post-single-review:${verdict}` }
414
+ : {
415
+ consensus: await (async () => {
416
+ const account = input.repository.review?.account ?? "";
417
+ await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
418
+ await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
419
+ `**Reviewer:** ${entry.reviewer}`,
420
+ "",
421
+ item.body,
422
+ "",
423
+ formatReviewMarker({
424
+ head: headSha,
425
+ pr: input.pr,
426
+ reviewer: entry.reviewer,
427
+ verdict: entry.output.verdict,
428
+ }),
429
+ ].join("\n")))));
430
+ return postSingleConsensusReview({
431
+ exec: input.exec,
432
+ headSha,
433
+ outputs,
434
+ pr: input.pr,
435
+ repository: input.repository,
436
+ verdict,
437
+ });
438
+ })(),
439
+ }
440
+ : Object.fromEntries(await Promise.all(entries.map(async (entry) => [
441
+ entry.reviewer,
442
+ await postRereviewOutput(input, entry.reviewer, entry.output),
443
+ ])));
399
444
  await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
400
445
  approvalPolicy: input.repository.merge.approvalPolicy,
401
446
  verdict,
@@ -405,7 +450,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
405
450
  })),
406
451
  }, null, 2));
407
452
  return {
408
- outputs: Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output])),
453
+ outputs,
409
454
  posted,
410
455
  verdict,
411
456
  };
@@ -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,120 @@ 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 lines = [
535
+ `Magi single-account review result: ${input.verdict}.`,
536
+ "",
537
+ "Logical reviewer verdicts:",
538
+ ...Object.entries(input.outputs).map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
539
+ "",
540
+ ...Object.entries(input.outputs).map(([reviewer, output]) => formatReviewMarker({
541
+ head: input.headSha,
542
+ pr: input.pr,
543
+ reviewer,
544
+ verdict: output.verdict,
545
+ })),
546
+ ];
547
+ return lines.join("\n");
548
+ }
549
+ function singleFindingBody(input) {
550
+ return [
551
+ `**Issue:** ${input.finding.issue}`,
552
+ "",
553
+ `**Fix:** ${input.finding.fix}`,
554
+ "",
555
+ `**Reviewer:** ${input.reviewer}`,
556
+ "",
557
+ formatReviewFindingMarker({
558
+ finding: input.index,
559
+ head: input.headSha,
560
+ pr: input.pr,
561
+ reviewer: input.reviewer,
562
+ }),
563
+ ].join("\n");
564
+ }
565
+ export async function postSingleConsensusReview(input) {
566
+ const account = input.repository.review?.account;
567
+ if (!account)
568
+ throw new Error("review.account is required for single review mode");
569
+ const body = singleReviewBody(input);
570
+ if (input.verdict === "MERGE") {
571
+ return postApproval(input.exec, input.repository, input.pr, account, body);
572
+ }
573
+ if (input.verdict === "CLOSE") {
574
+ return postCloseComment(input.exec, input.repository, input.pr, account, body);
575
+ }
576
+ const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
577
+ return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
578
+ body,
579
+ commentBodies: findings.map((item) => singleFindingBody({
580
+ finding: item.finding,
581
+ headSha: input.headSha,
582
+ index: item.index,
583
+ pr: input.pr,
584
+ reviewer: item.reviewer,
585
+ })),
586
+ });
587
+ }
353
588
  async function postRereviewOutput(input, reviewerKey, output) {
354
589
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
355
590
  if (!reviewer)
356
591
  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)));
592
+ const account = reviewPostingAccount(input.repository, reviewer);
593
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
594
+ await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
359
595
  if (output.verdict === "MERGE")
360
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
596
+ return postApproval(input.exec, input.repository, input.pr, account);
361
597
  if (output.verdict === "CLOSE")
362
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
598
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
363
599
  if (!output.newFindings.length)
364
600
  return "";
365
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
601
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
366
602
  fix: "Please address this before merging.",
367
603
  issue: finding.body,
368
604
  path: finding.path,
@@ -670,23 +906,66 @@ export async function runReview(input) {
670
906
  const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
671
907
  const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
672
908
  const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
909
+ const singleReviewMode = resolvedReviewMode(input.repository) === "single";
910
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
673
911
  const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
674
- const preliminaryMode = resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
912
+ const preliminaryMode = singleReviewMode
913
+ ? resolveSingleAccountReviewMode({
914
+ account: input.repository.review?.account ?? "",
915
+ current: freshnessTarget,
916
+ pr: input.pr,
917
+ reviewerKeys,
918
+ reviews,
919
+ })
920
+ : resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
675
921
  const unresolvedThreadsByAccount = new Map();
922
+ const unresolvedThreadsByReviewer = new Map();
676
923
  const pendingThreadReplyAccounts = new Set();
924
+ const pendingThreadReplyReviewers = new Set();
677
925
  const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
678
- return preliminaryMode.assignments.get(reviewer.account)?.type === "skip";
926
+ return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
679
927
  });
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);
928
+ if (singleReviewMode && skippedReviewers.length) {
929
+ const account = input.repository.review?.account ?? "";
930
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
931
+ const assigned = assignThreadsByReviewFindingMarker({
932
+ fallbackReviewerKeys: reviewerKeys,
933
+ headSha: meta.headRefOid,
934
+ pr: input.pr,
935
+ reviewerKeys,
936
+ threads,
937
+ });
938
+ for (const reviewer of skippedReviewers) {
939
+ const reviewerThreads = assigned[reviewer.key] ?? [];
940
+ unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
941
+ if (hasPendingThreadReply(reviewerThreads, account)) {
942
+ pendingThreadReplyReviewers.add(reviewer.key);
943
+ }
685
944
  }
686
- }, { signal: input.signal });
687
- const mode = pendingThreadReplyAccounts.size
688
- ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
689
- : preliminaryMode;
945
+ }
946
+ else {
947
+ await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
948
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
949
+ unresolvedThreadsByAccount.set(reviewer.account, threads);
950
+ if (hasPendingThreadReply(threads, reviewer.account)) {
951
+ pendingThreadReplyAccounts.add(reviewer.account);
952
+ }
953
+ }, { signal: input.signal });
954
+ }
955
+ const mode = singleReviewMode
956
+ ? pendingThreadReplyReviewers.size
957
+ ? resolveSingleAccountReviewMode({
958
+ account: input.repository.review?.account ?? "",
959
+ current: freshnessTarget,
960
+ pendingReviewers: pendingThreadReplyReviewers,
961
+ pr: input.pr,
962
+ reviewerKeys,
963
+ reviews,
964
+ })
965
+ : preliminaryMode
966
+ : pendingThreadReplyAccounts.size
967
+ ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
968
+ : preliminaryMode;
690
969
  if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
691
970
  throw new Error("PR has already been reviewed by all configured accounts");
692
971
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
@@ -776,7 +1055,7 @@ export async function runReview(input) {
776
1055
  try {
777
1056
  throwIfAborted(input.signal);
778
1057
  const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
779
- const assignment = mode.assignments.get(reviewer.account);
1058
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
780
1059
  if (!assignment || assignment.type === "skip")
781
1060
  return [];
782
1061
  return [{ assignment, reviewer }];
@@ -801,7 +1080,7 @@ export async function runReview(input) {
801
1080
  worktreePath,
802
1081
  });
803
1082
  for (const reviewer of input.repository.agents.reviewers) {
804
- const assignment = mode.assignments.get(reviewer.account);
1083
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
805
1084
  if (assignment?.type !== "skip")
806
1085
  continue;
807
1086
  await input.onProgress?.({
@@ -834,8 +1113,9 @@ export async function runReview(input) {
834
1113
  const rereviewInlineCommentTargets = mergeConflictContext
835
1114
  ? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
836
1115
  : inlineCommentTargets;
837
- const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
838
- (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
1116
+ const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
1117
+ unresolvedThreadsByAccount.get(reviewer.account) ??
1118
+ (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
839
1119
  const prompt = await composeRereviewPrompt({
840
1120
  baseSha: meta.baseRefOid,
841
1121
  ciFailureContext,
@@ -981,7 +1261,7 @@ export async function runReview(input) {
981
1261
  throwIfAborted(input.signal);
982
1262
  const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
983
1263
  const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
984
- const assignment = mode.assignments.get(reviewer.account);
1264
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
985
1265
  if (assignment?.type !== "skip")
986
1266
  return [];
987
1267
  return [
@@ -999,7 +1279,7 @@ export async function runReview(input) {
999
1279
  })),
1000
1280
  ]);
1001
1281
  const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
1002
- const assignment = mode.assignments.get(reviewer.account);
1282
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1003
1283
  if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
1004
1284
  return [];
1005
1285
  return [
@@ -1033,14 +1313,14 @@ export async function runReview(input) {
1033
1313
  });
1034
1314
  const activeOutputs = validation.outputs;
1035
1315
  const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1036
- const assignment = mode.assignments.get(reviewer.account);
1316
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1037
1317
  return assignment?.type === "skip"
1038
1318
  ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
1039
1319
  : [];
1040
1320
  }));
1041
1321
  const outputs = { ...skippedOutputs, ...activeOutputs };
1042
1322
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
1043
- const assignment = mode.assignments.get(reviewer.account);
1323
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1044
1324
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
1045
1325
  return [];
1046
1326
  return [
@@ -1056,23 +1336,68 @@ export async function runReview(input) {
1056
1336
  }));
1057
1337
  const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
1058
1338
  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;
1339
+ const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1340
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1341
+ return assignment?.type === "skip"
1342
+ ? [[reviewer.key, "skipped: already reviewed current head"]]
1343
+ : [];
1344
+ }));
1345
+ const posted = singleReviewMode
1346
+ ? {
1347
+ ...skippedPosted,
1348
+ ...(Object.keys(activeOutputs).length
1349
+ ? {
1350
+ consensus: input.dryRun
1351
+ ? `dry-run:would-post-single-review:${verdict}`
1352
+ : await (async () => {
1353
+ const account = input.repository.review?.account ?? "";
1354
+ await Promise.all(Object.values(activeOutputs).flatMap((output) => {
1355
+ if (!("resolve" in output))
1356
+ return [];
1357
+ return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
1358
+ }));
1359
+ await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
1360
+ if (!("followUps" in output))
1361
+ return [];
1362
+ return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
1363
+ `**Reviewer:** ${key}`,
1364
+ "",
1365
+ item.body,
1366
+ "",
1367
+ formatReviewMarker({
1368
+ head: meta.headRefOid,
1369
+ pr: input.pr,
1370
+ reviewer: key,
1371
+ verdict: output.verdict,
1372
+ }),
1373
+ ].join("\n")));
1374
+ }));
1375
+ return postSingleConsensusReview({
1376
+ exec,
1377
+ headSha: meta.headRefOid,
1378
+ outputs,
1379
+ pr: input.pr,
1380
+ repository: input.repository,
1381
+ verdict,
1382
+ });
1383
+ })(),
1384
+ }
1385
+ : {}),
1386
+ }
1387
+ : {
1388
+ ...skippedPosted,
1389
+ ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
1390
+ key,
1391
+ input.dryRun
1392
+ ? dryRunReviewPost(key, output)
1393
+ : "resolve" in output
1394
+ ? await postRereviewOutput({ ...input, exec }, key, output)
1395
+ : await postReviewOutput({ ...input, exec }, key, output),
1396
+ ]))),
1397
+ };
1398
+ const automationAccount = singleReviewMode
1399
+ ? input.repository.review?.account
1400
+ : input.repository.agents.reviewers[0]?.account;
1076
1401
  const enableReviewAutomation = input.enableReviewAutomation ?? true;
1077
1402
  if (enableReviewAutomation &&
1078
1403
  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-20260525141349",
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,