opencode-magi 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -15
- package/dist/config/resolve.js +7 -1
- package/dist/config/validate.js +60 -22
- package/dist/github/commands.js +16 -6
- package/dist/orchestrator/merge.js +57 -13
- package/dist/orchestrator/review.js +390 -45
- package/package.json +8 -8
- package/schema.json +4 -2
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
|
-
-
|
|
20
|
+
- Single-account identity mode by default, where one GitHub account posts consensus-backed review and triage results for multiple logical agents, plus multi-account mode for setups that need separate GitHub identities.
|
|
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.
|
|
@@ -61,19 +61,17 @@ Add the following content to the configuration file.
|
|
|
61
61
|
```json
|
|
62
62
|
{
|
|
63
63
|
"$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
|
|
64
|
+
"account": "your-account",
|
|
64
65
|
"agents": {
|
|
65
66
|
"refs": {
|
|
66
67
|
"account-1": {
|
|
67
|
-
"model": "openai/gpt-5.5"
|
|
68
|
-
"account": "account-1"
|
|
68
|
+
"model": "openai/gpt-5.5"
|
|
69
69
|
},
|
|
70
70
|
"account-2": {
|
|
71
|
-
"model": "anthropic/claude-opus-4-7"
|
|
72
|
-
"account": "account-2"
|
|
71
|
+
"model": "anthropic/claude-opus-4-7"
|
|
73
72
|
},
|
|
74
73
|
"account-3": {
|
|
75
|
-
"model": "opencode/kimi-k2-6"
|
|
76
|
-
"account": "account-3"
|
|
74
|
+
"model": "opencode/kimi-k2-6"
|
|
77
75
|
}
|
|
78
76
|
}
|
|
79
77
|
},
|
|
@@ -87,7 +85,26 @@ Add the following content to the configuration file.
|
|
|
87
85
|
}
|
|
88
86
|
```
|
|
89
87
|
|
|
90
|
-
|
|
88
|
+
By default, `mode` is `"single"`. Magi uses one top-level `account` to post reviewer- and triage-originated GitHub mutations while still running multiple logical agents and preserving majority voting, finding validation, and close reconsideration. The account must be authenticated with `gh auth token --user <account>`.
|
|
89
|
+
|
|
90
|
+
For advanced team setups that need GitHub to see separate review or triage identities, set top-level `mode: "multi"` and configure unique accounts for each reviewer or triage voter.
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mode": "multi",
|
|
95
|
+
"review": {
|
|
96
|
+
"reviewers": [
|
|
97
|
+
{ "id": "general", "model": "openai/gpt-5.5", "account": "account-1" },
|
|
98
|
+
{
|
|
99
|
+
"id": "security",
|
|
100
|
+
"model": "anthropic/claude-opus-4-7",
|
|
101
|
+
"account": "account-2"
|
|
102
|
+
},
|
|
103
|
+
{ "id": "compat", "model": "opencode/kimi-k2-6", "account": "account-3" }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
91
108
|
|
|
92
109
|
#### Set project config
|
|
93
110
|
|
|
@@ -104,6 +121,7 @@ Add the following content to the configuration file.
|
|
|
104
121
|
```json
|
|
105
122
|
{
|
|
106
123
|
"$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
|
|
124
|
+
"account": "your-account",
|
|
107
125
|
"github": {
|
|
108
126
|
"owner": "your-owner",
|
|
109
127
|
"repo": "your-repo"
|
|
@@ -111,16 +129,13 @@ Add the following content to the configuration file.
|
|
|
111
129
|
"agents": {
|
|
112
130
|
"refs": {
|
|
113
131
|
"account-1": {
|
|
114
|
-
"model": "openai/gpt-5.5"
|
|
115
|
-
"account": "account-1"
|
|
132
|
+
"model": "openai/gpt-5.5"
|
|
116
133
|
},
|
|
117
134
|
"account-2": {
|
|
118
|
-
"model": "anthropic/claude-opus-4-7"
|
|
119
|
-
"account": "account-2"
|
|
135
|
+
"model": "anthropic/claude-opus-4-7"
|
|
120
136
|
},
|
|
121
137
|
"account-3": {
|
|
122
|
-
"model": "opencode/kimi-k2-6"
|
|
123
|
-
"account": "account-3"
|
|
138
|
+
"model": "opencode/kimi-k2-6"
|
|
124
139
|
},
|
|
125
140
|
"account-4": {
|
|
126
141
|
"model": "openai/gpt-5.5",
|
|
@@ -165,7 +180,7 @@ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` o
|
|
|
165
180
|
}
|
|
166
181
|
```
|
|
167
182
|
|
|
168
|
-
After `refs` are expanded, `
|
|
183
|
+
After `refs` are expanded, top-level `account` is the GitHub account used for reviewer- and triage-originated posts and mutations in `single` mode. In `multi` mode, `review.reviewers[].account` and `triage.voters[].account` are used instead and must be unique within their agent lists. `merge.editor.account` is still used by `/magi:merge` to push fixes, close PRs, and merge PRs.
|
|
169
184
|
|
|
170
185
|
#### Validate config
|
|
171
186
|
|
package/dist/config/resolve.js
CHANGED
|
@@ -113,6 +113,8 @@ 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 && config.mode !== "multi" ? config.account : undefined;
|
|
117
|
+
const singleTriageAccount = config.triage && config.mode !== "multi" ? config.account : undefined;
|
|
116
118
|
return {
|
|
117
119
|
editor: editor
|
|
118
120
|
? {
|
|
@@ -123,6 +125,7 @@ export function resolveAgents(config) {
|
|
|
123
125
|
: undefined,
|
|
124
126
|
reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
|
|
125
127
|
...reviewer,
|
|
128
|
+
account: singleReviewAccount ?? reviewer.account ?? "",
|
|
126
129
|
key: reviewerKey(reviewer, index),
|
|
127
130
|
index,
|
|
128
131
|
model: normalizedModel(reviewer.model),
|
|
@@ -130,6 +133,7 @@ export function resolveAgents(config) {
|
|
|
130
133
|
})),
|
|
131
134
|
triage: (config.triage?.voters ?? []).map((agent, index) => ({
|
|
132
135
|
...agent,
|
|
136
|
+
account: singleTriageAccount ?? agent.account ?? "",
|
|
133
137
|
key: triageAgentKey(agent, index),
|
|
134
138
|
index,
|
|
135
139
|
model: normalizedModel(agent.model),
|
|
@@ -138,7 +142,7 @@ export function resolveAgents(config) {
|
|
|
138
142
|
triageCreator: creator
|
|
139
143
|
? {
|
|
140
144
|
...creator,
|
|
141
|
-
account: creator.account ?? "",
|
|
145
|
+
account: singleTriageAccount ?? creator.account ?? "",
|
|
142
146
|
model: normalizedModel(creator.model),
|
|
143
147
|
permission: resolveTriageCreatorPermission(agents, creator),
|
|
144
148
|
}
|
|
@@ -184,6 +188,8 @@ export function resolveRepository(config) {
|
|
|
184
188
|
repo: config.github.repo,
|
|
185
189
|
},
|
|
186
190
|
language: config.language,
|
|
191
|
+
account: config.account,
|
|
192
|
+
mode: config.mode ?? "single",
|
|
187
193
|
merge: {
|
|
188
194
|
approvalPolicy: config.review?.merge?.approvalPolicy ?? "majority",
|
|
189
195
|
method: config.review?.merge?.method ?? "squash",
|
package/dist/config/validate.js
CHANGED
|
@@ -13,11 +13,13 @@ const TRIAGE_CATEGORY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
|
13
13
|
const RESERVED_TRIAGE_CATEGORY_IDS = new Set(["ASK", "none"]);
|
|
14
14
|
const CONFIG_KEYS = new Set([
|
|
15
15
|
"$schema",
|
|
16
|
+
"account",
|
|
16
17
|
"agents",
|
|
17
18
|
"clear",
|
|
18
19
|
"github",
|
|
19
20
|
"language",
|
|
20
21
|
"merge",
|
|
22
|
+
"mode",
|
|
21
23
|
"output",
|
|
22
24
|
"review",
|
|
23
25
|
"triage",
|
|
@@ -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 = "single") {
|
|
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);
|
|
@@ -419,7 +421,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
|
|
|
419
421
|
}
|
|
420
422
|
});
|
|
421
423
|
}
|
|
422
|
-
function validateTriageAgentList(voters, path, errors, catalog) {
|
|
424
|
+
function validateTriageAgentList(voters, path, errors, catalog, mode = "single") {
|
|
423
425
|
if (voters == null)
|
|
424
426
|
return;
|
|
425
427
|
if (!Array.isArray(voters)) {
|
|
@@ -439,8 +441,10 @@ function validateTriageAgentList(voters, path, errors, catalog) {
|
|
|
439
441
|
if (!agent.model)
|
|
440
442
|
errors.push(`${path}[${index}].model is required`);
|
|
441
443
|
validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
|
|
442
|
-
if (!agent.account)
|
|
444
|
+
if (mode === "multi" && !agent.account)
|
|
443
445
|
errors.push(`${path}[${index}].account is required`);
|
|
446
|
+
if (mode === "single" && agent.account)
|
|
447
|
+
errors.push(`${path}[${index}].account is not supported in single mode`);
|
|
444
448
|
validateString(agent.account, `${path}[${index}].account`, errors);
|
|
445
449
|
validateString(agent.persona, `${path}[${index}].persona`, errors);
|
|
446
450
|
validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
|
|
@@ -454,26 +458,38 @@ function validateTriageAgentList(voters, path, errors, catalog) {
|
|
|
454
458
|
}
|
|
455
459
|
});
|
|
456
460
|
}
|
|
457
|
-
function validateResolvedReviewers(reviewers, path, errors) {
|
|
461
|
+
function validateResolvedReviewers(reviewers, path, errors, mode = "single") {
|
|
458
462
|
const keys = new Set();
|
|
459
463
|
const accounts = new Set();
|
|
460
464
|
for (const reviewer of reviewers) {
|
|
461
465
|
if (keys.has(reviewer.key))
|
|
462
466
|
errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
|
|
463
467
|
keys.add(reviewer.key);
|
|
464
|
-
if (accounts.has(reviewer.account))
|
|
468
|
+
if (mode === "multi" && accounts.has(reviewer.account))
|
|
465
469
|
errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
|
|
466
470
|
accounts.add(reviewer.account);
|
|
467
471
|
}
|
|
468
472
|
}
|
|
469
|
-
function
|
|
473
|
+
function reviewMode(config) {
|
|
474
|
+
return config.mode === "multi" ? "multi" : "single";
|
|
475
|
+
}
|
|
476
|
+
function validateReviewIdentity(config, errors) {
|
|
477
|
+
const mode = config.mode;
|
|
478
|
+
if (mode != null && mode !== "multi" && mode !== "single") {
|
|
479
|
+
errors.push("mode must be multi or single");
|
|
480
|
+
}
|
|
481
|
+
if ((mode == null || mode === "single") && !config.account) {
|
|
482
|
+
errors.push("account is required when mode is single");
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function validateResolvedTriageAgents(agents, path, errors, mode = "single") {
|
|
470
486
|
const keys = new Set();
|
|
471
487
|
const accounts = new Set();
|
|
472
488
|
for (const agent of agents) {
|
|
473
489
|
if (keys.has(agent.key))
|
|
474
490
|
errors.push(`${path} has duplicate agent key: ${agent.key}`);
|
|
475
491
|
keys.add(agent.key);
|
|
476
|
-
if (accounts.has(agent.account))
|
|
492
|
+
if (mode === "multi" && accounts.has(agent.account))
|
|
477
493
|
errors.push(`${path} has duplicate agent account: ${agent.account}`);
|
|
478
494
|
accounts.add(agent.account);
|
|
479
495
|
}
|
|
@@ -517,7 +533,7 @@ function validateEditor(editor, path, errors, catalog) {
|
|
|
517
533
|
}
|
|
518
534
|
}
|
|
519
535
|
}
|
|
520
|
-
function validateTriageCreator(creator, path, errors, catalog) {
|
|
536
|
+
function validateTriageCreator(creator, path, errors, catalog, mode = "single") {
|
|
521
537
|
if (!creator)
|
|
522
538
|
return;
|
|
523
539
|
if (!isPlainObject(creator)) {
|
|
@@ -527,6 +543,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
|
|
|
527
543
|
validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
|
|
528
544
|
if (!creator.model)
|
|
529
545
|
errors.push(`${path}.model is required`);
|
|
546
|
+
if (mode === "single" && creator.account)
|
|
547
|
+
errors.push(`${path}.account is not supported in single mode`);
|
|
530
548
|
validateString(creator.account, `${path}.account`, errors);
|
|
531
549
|
validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
|
|
532
550
|
validateString(creator.persona, `${path}.persona`, errors);
|
|
@@ -815,6 +833,7 @@ function validatePromptObject(prompts, path, keys, errors) {
|
|
|
815
833
|
}
|
|
816
834
|
function validateTriage(config, errors, options) {
|
|
817
835
|
const triage = config.triage;
|
|
836
|
+
const mode = reviewMode(config);
|
|
818
837
|
if (!triage)
|
|
819
838
|
return;
|
|
820
839
|
if (!isPlainObject(triage)) {
|
|
@@ -829,7 +848,7 @@ function validateTriage(config, errors, options) {
|
|
|
829
848
|
const safety = triage.safety;
|
|
830
849
|
if (!triage.voters)
|
|
831
850
|
errors.push("triage.voters is required");
|
|
832
|
-
validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
|
|
851
|
+
validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog, mode);
|
|
833
852
|
if (Array.isArray(triage.voters)) {
|
|
834
853
|
const resolvedTriageAgents = triage.voters.map((agent, index) => ({
|
|
835
854
|
account: agent && typeof agent === "object" && typeof agent.account === "string"
|
|
@@ -837,17 +856,21 @@ function validateTriage(config, errors, options) {
|
|
|
837
856
|
: "",
|
|
838
857
|
key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
|
|
839
858
|
}));
|
|
840
|
-
validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
|
|
841
|
-
if (
|
|
859
|
+
validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors, mode);
|
|
860
|
+
if (mode === "multi" &&
|
|
861
|
+
reporter != null &&
|
|
842
862
|
!resolvedTriageAgents.some((agent) => agent.key === reporter)) {
|
|
843
863
|
errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
|
|
844
864
|
}
|
|
845
865
|
}
|
|
866
|
+
if (mode === "single" && reporter != null) {
|
|
867
|
+
errors.push("triage.reporter is not supported in single mode");
|
|
868
|
+
}
|
|
846
869
|
validateString(triage.reporter, "triage.reporter", errors);
|
|
847
|
-
validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
|
|
870
|
+
validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog, mode);
|
|
848
871
|
if (automation?.create && !creator)
|
|
849
872
|
errors.push("triage.creator is required when triage.automation.create is true");
|
|
850
|
-
if (automation?.create && creator && !creator.account)
|
|
873
|
+
if (mode === "multi" && automation?.create && creator && !creator.account)
|
|
851
874
|
errors.push("triage.creator.account is required when triage.automation.create is true");
|
|
852
875
|
if (automation != null && !isPlainObject(automation)) {
|
|
853
876
|
errors.push("triage.automation must be an object");
|
|
@@ -908,8 +931,14 @@ async function validatePrompts(config, errors, directory) {
|
|
|
908
931
|
async function validateAuth(config, exec, errors) {
|
|
909
932
|
const accounts = new Set();
|
|
910
933
|
const agents = resolveAgents(config);
|
|
911
|
-
|
|
912
|
-
|
|
934
|
+
if (reviewMode(config) === "single") {
|
|
935
|
+
if (config.account)
|
|
936
|
+
accounts.add(config.account);
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
for (const reviewer of agents.reviewers)
|
|
940
|
+
accounts.add(reviewer.account);
|
|
941
|
+
}
|
|
913
942
|
for (const agent of agents.triage ?? [])
|
|
914
943
|
accounts.add(agent.account);
|
|
915
944
|
if (agents.editor)
|
|
@@ -956,15 +985,20 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
|
|
|
956
985
|
if (!config.github?.owner || !config.github.repo)
|
|
957
986
|
return;
|
|
958
987
|
const agents = resolveAgents(config);
|
|
959
|
-
|
|
988
|
+
const reviewAccounts = reviewMode(config) === "single"
|
|
989
|
+
? config.account
|
|
990
|
+
? [config.account]
|
|
991
|
+
: []
|
|
992
|
+
: agents.reviewers.map((reviewer) => reviewer.account);
|
|
993
|
+
await Promise.all(reviewAccounts.map(async (account) => {
|
|
960
994
|
try {
|
|
961
|
-
const permissions = await fetchPermissions(config, exec,
|
|
995
|
+
const permissions = await fetchPermissions(config, exec, account);
|
|
962
996
|
if (!permissions.pull) {
|
|
963
|
-
errors.push(`GitHub account cannot read repository for PR review: ${
|
|
997
|
+
errors.push(`GitHub account cannot read repository for PR review: ${account}`);
|
|
964
998
|
}
|
|
965
999
|
}
|
|
966
1000
|
catch (error) {
|
|
967
|
-
warnings.push(`Could not validate repository permissions for GitHub account: ${
|
|
1001
|
+
warnings.push(`Could not validate repository permissions for GitHub account: ${account} (${error.message})`);
|
|
968
1002
|
}
|
|
969
1003
|
}));
|
|
970
1004
|
await Promise.all((agents.triage ?? []).map(async (agent) => {
|
|
@@ -1014,7 +1048,10 @@ export async function validateConfig(config, options = {}) {
|
|
|
1014
1048
|
validateJsonSchema(config, errors);
|
|
1015
1049
|
validateKnownKeys(config, "config", CONFIG_KEYS, errors);
|
|
1016
1050
|
validateString(config.$schema, "$schema", errors);
|
|
1051
|
+
validateString(config.account, "account", errors);
|
|
1017
1052
|
validateString(config.language, "language", errors);
|
|
1053
|
+
if (config.review || config.triage)
|
|
1054
|
+
validateReviewIdentity(config, errors);
|
|
1018
1055
|
if (config.agents != null && !isPlainObject(config.agents)) {
|
|
1019
1056
|
errors.push("agents must be an object");
|
|
1020
1057
|
}
|
|
@@ -1026,6 +1063,7 @@ export async function validateConfig(config, options = {}) {
|
|
|
1026
1063
|
errors.push("review is required");
|
|
1027
1064
|
}
|
|
1028
1065
|
else if (config.review) {
|
|
1066
|
+
const mode = reviewMode(config);
|
|
1029
1067
|
if (!isPlainObject(config.review)) {
|
|
1030
1068
|
errors.push("review must be an object");
|
|
1031
1069
|
}
|
|
@@ -1034,7 +1072,7 @@ export async function validateConfig(config, options = {}) {
|
|
|
1034
1072
|
}
|
|
1035
1073
|
if (!config.review.reviewers)
|
|
1036
1074
|
errors.push("review.reviewers is required");
|
|
1037
|
-
validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
|
|
1075
|
+
validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
|
|
1038
1076
|
if (Array.isArray(config.review.reviewers)) {
|
|
1039
1077
|
validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
|
|
1040
1078
|
account: reviewer &&
|
|
@@ -1045,7 +1083,7 @@ export async function validateConfig(config, options = {}) {
|
|
|
1045
1083
|
key: reviewer && typeof reviewer === "object"
|
|
1046
1084
|
? reviewerKey(reviewer, index)
|
|
1047
1085
|
: "",
|
|
1048
|
-
})), "review.resolvedReviewers", errors);
|
|
1086
|
+
})), "review.resolvedReviewers", errors, mode);
|
|
1049
1087
|
}
|
|
1050
1088
|
}
|
|
1051
1089
|
if (options.requireTriage && !config.triage) {
|
package/dist/github/commands.js
CHANGED
|
@@ -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,
|
|
143
|
-
const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr,
|
|
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,
|
|
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,
|
|
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,
|
|
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.mode !== "multi";
|
|
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.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 =
|
|
218
|
-
|
|
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.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
|
|
452
|
+
outputs,
|
|
409
453
|
posted,
|
|
410
454
|
verdict,
|
|
411
455
|
};
|
|
@@ -15,6 +15,87 @@ 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.mode === "multi" ? "multi" : "single";
|
|
20
|
+
}
|
|
21
|
+
export function reviewPostingAccount(repository, reviewer) {
|
|
22
|
+
if (resolvedReviewMode(repository) !== "single")
|
|
23
|
+
return reviewer.account;
|
|
24
|
+
if (repository.account)
|
|
25
|
+
return repository.account;
|
|
26
|
+
throw new Error("account is required for single review mode");
|
|
27
|
+
}
|
|
28
|
+
function reviewAssignmentKey(repository, reviewer) {
|
|
29
|
+
return resolvedReviewMode(repository) === "single"
|
|
30
|
+
? reviewer.key
|
|
31
|
+
: reviewer.account;
|
|
32
|
+
}
|
|
33
|
+
function parseMarkerFields(text) {
|
|
34
|
+
const fields = Object.fromEntries(text
|
|
35
|
+
.trim()
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.flatMap((part) => {
|
|
38
|
+
const index = part.indexOf("=");
|
|
39
|
+
return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
|
|
40
|
+
}));
|
|
41
|
+
return fields.v === "1" && fields.mode === "single" ? fields : undefined;
|
|
42
|
+
}
|
|
43
|
+
function isMarkerVerdict(value) {
|
|
44
|
+
return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
|
|
45
|
+
}
|
|
46
|
+
export function formatReviewMarker(marker) {
|
|
47
|
+
return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
|
|
48
|
+
}
|
|
49
|
+
export function parseReviewMarkers(body) {
|
|
50
|
+
const markers = [];
|
|
51
|
+
const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
|
|
52
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
53
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
54
|
+
const pr = Number(fields?.pr);
|
|
55
|
+
if (!fields ||
|
|
56
|
+
!Number.isInteger(pr) ||
|
|
57
|
+
!fields.reviewer ||
|
|
58
|
+
!fields.head ||
|
|
59
|
+
!isMarkerVerdict(fields.verdict)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
markers.push({
|
|
63
|
+
head: fields.head,
|
|
64
|
+
pr,
|
|
65
|
+
reviewer: fields.reviewer,
|
|
66
|
+
verdict: fields.verdict,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return markers;
|
|
70
|
+
}
|
|
71
|
+
export function formatReviewFindingMarker(marker) {
|
|
72
|
+
return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
|
|
73
|
+
}
|
|
74
|
+
export function parseReviewFindingMarkers(body) {
|
|
75
|
+
const markers = [];
|
|
76
|
+
const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
|
|
77
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
78
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
79
|
+
const pr = Number(fields?.pr);
|
|
80
|
+
const finding = Number(fields?.finding);
|
|
81
|
+
if (!fields ||
|
|
82
|
+
!Number.isInteger(pr) ||
|
|
83
|
+
!Number.isInteger(finding) ||
|
|
84
|
+
!fields.reviewer ||
|
|
85
|
+
!fields.head) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
|
|
89
|
+
}
|
|
90
|
+
return markers;
|
|
91
|
+
}
|
|
92
|
+
function markerReviewState(verdict) {
|
|
93
|
+
if (verdict === "MERGE")
|
|
94
|
+
return "APPROVED";
|
|
95
|
+
if (verdict === "CHANGES_REQUESTED")
|
|
96
|
+
return "CHANGES_REQUESTED";
|
|
97
|
+
return "CLOSE";
|
|
98
|
+
}
|
|
18
99
|
function errorMessage(error) {
|
|
19
100
|
return error instanceof Error ? error.message : String(error);
|
|
20
101
|
}
|
|
@@ -35,11 +116,12 @@ async function postReviewOutput(input, reviewerKey, output) {
|
|
|
35
116
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
36
117
|
if (!reviewer)
|
|
37
118
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
119
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
38
120
|
if (output.verdict === "MERGE")
|
|
39
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
121
|
+
return postApproval(input.exec, input.repository, input.pr, account);
|
|
40
122
|
if (output.verdict === "CLOSE")
|
|
41
|
-
return postCloseComment(input.exec, input.repository, input.pr,
|
|
42
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
123
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
124
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, output.findings);
|
|
43
125
|
}
|
|
44
126
|
function dryRunReviewPost(key, output) {
|
|
45
127
|
if (output.verdict === "MERGE")
|
|
@@ -86,6 +168,56 @@ export function resolveReviewMode(reviews, accounts, current, accountsWithPendin
|
|
|
86
168
|
return { assignments, type: "already_reviewed" };
|
|
87
169
|
return { assignments, type: "active" };
|
|
88
170
|
}
|
|
171
|
+
export function resolveSingleAccountReviewMode(input) {
|
|
172
|
+
const reviewerKeySet = new Set(input.reviewerKeys);
|
|
173
|
+
const pendingReviewers = input.pendingReviewers ?? new Set();
|
|
174
|
+
const latest = new Map();
|
|
175
|
+
for (const review of input.reviews) {
|
|
176
|
+
if (review.author.login !== input.account)
|
|
177
|
+
continue;
|
|
178
|
+
if (review.state === "DISMISSED")
|
|
179
|
+
continue;
|
|
180
|
+
for (const marker of parseReviewMarkers(review.body)) {
|
|
181
|
+
if (marker.pr !== input.pr || !reviewerKeySet.has(marker.reviewer)) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const synthetic = {
|
|
185
|
+
...review,
|
|
186
|
+
commit: { oid: marker.head },
|
|
187
|
+
comments: (review.comments ?? []).filter((comment) => parseReviewFindingMarkers(comment.body).some((findingMarker) => findingMarker.pr === input.pr &&
|
|
188
|
+
findingMarker.reviewer === marker.reviewer &&
|
|
189
|
+
findingMarker.head === marker.head)),
|
|
190
|
+
state: markerReviewState(marker.verdict),
|
|
191
|
+
};
|
|
192
|
+
const current = latest.get(marker.reviewer);
|
|
193
|
+
if (!current ||
|
|
194
|
+
current.submittedAt.localeCompare(review.submittedAt) < 0) {
|
|
195
|
+
latest.set(marker.reviewer, synthetic);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const reviewedHead = input.reviewerKeys.every((reviewer) => {
|
|
200
|
+
return (isReviewCurrent(latest.get(reviewer), input.current) &&
|
|
201
|
+
!pendingReviewers.has(reviewer));
|
|
202
|
+
});
|
|
203
|
+
const assignments = new Map();
|
|
204
|
+
for (const reviewer of input.reviewerKeys) {
|
|
205
|
+
const review = latest.get(reviewer);
|
|
206
|
+
if (!review) {
|
|
207
|
+
assignments.set(reviewer, { type: "initial" });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (isReviewCurrent(review, input.current) &&
|
|
211
|
+
!pendingReviewers.has(reviewer)) {
|
|
212
|
+
assignments.set(reviewer, { review, type: "skip" });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
assignments.set(reviewer, { review, type: "rereview" });
|
|
216
|
+
}
|
|
217
|
+
if (latest.size && reviewedHead)
|
|
218
|
+
return { assignments, type: "already_reviewed" };
|
|
219
|
+
return { assignments, type: "active" };
|
|
220
|
+
}
|
|
89
221
|
export function reviewFreshnessTarget(commits, headSha) {
|
|
90
222
|
const latestNonMerge = [...commits]
|
|
91
223
|
.reverse()
|
|
@@ -112,6 +244,8 @@ function reviewStateToVerdict(state) {
|
|
|
112
244
|
return "MERGE";
|
|
113
245
|
if (state === "CHANGES_REQUESTED")
|
|
114
246
|
return "CHANGES_REQUESTED";
|
|
247
|
+
if (state === "CLOSE")
|
|
248
|
+
return "CLOSE";
|
|
115
249
|
throw new Error(`Unsupported GitHub review state: ${state}`);
|
|
116
250
|
}
|
|
117
251
|
function hasBlockingCiReports(reports) {
|
|
@@ -295,7 +429,10 @@ function reviewFindingsFromBody(body) {
|
|
|
295
429
|
return { findings };
|
|
296
430
|
}
|
|
297
431
|
function parsePostedFindingComment(body) {
|
|
298
|
-
const
|
|
432
|
+
const visibleBody = body
|
|
433
|
+
.replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
|
|
434
|
+
.trim();
|
|
435
|
+
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
436
|
if (!match)
|
|
300
437
|
return undefined;
|
|
301
438
|
return {
|
|
@@ -350,19 +487,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
|
350
487
|
comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
|
|
351
488
|
});
|
|
352
489
|
}
|
|
490
|
+
export function assignThreadsByReviewFindingMarker(input) {
|
|
491
|
+
const reviewerKeys = new Set(input.reviewerKeys);
|
|
492
|
+
const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
|
|
493
|
+
for (const thread of input.threads) {
|
|
494
|
+
const markers = [
|
|
495
|
+
thread.body,
|
|
496
|
+
thread.latestBody,
|
|
497
|
+
...thread.comments.map((comment) => comment.body),
|
|
498
|
+
]
|
|
499
|
+
.flatMap(parseReviewFindingMarkers)
|
|
500
|
+
.filter((marker) => {
|
|
501
|
+
return (marker.pr === input.pr &&
|
|
502
|
+
reviewerKeys.has(marker.reviewer) &&
|
|
503
|
+
(!input.headSha || marker.head === input.headSha));
|
|
504
|
+
});
|
|
505
|
+
const reviewers = markers.length
|
|
506
|
+
? [...new Set(markers.map((marker) => marker.reviewer))]
|
|
507
|
+
: input.fallbackReviewerKeys;
|
|
508
|
+
for (const reviewer of reviewers)
|
|
509
|
+
assigned[reviewer]?.push(thread);
|
|
510
|
+
}
|
|
511
|
+
return assigned;
|
|
512
|
+
}
|
|
513
|
+
function outputFindings(reviewer, output) {
|
|
514
|
+
if (output.verdict !== "CHANGES_REQUESTED")
|
|
515
|
+
return [];
|
|
516
|
+
if ("findings" in output) {
|
|
517
|
+
return output.findings.map((finding, index) => ({
|
|
518
|
+
finding,
|
|
519
|
+
index,
|
|
520
|
+
reviewer,
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
return output.newFindings.map((finding, index) => ({
|
|
524
|
+
finding: {
|
|
525
|
+
fix: "Please address this before merging.",
|
|
526
|
+
issue: finding.body,
|
|
527
|
+
line: finding.line,
|
|
528
|
+
path: finding.path,
|
|
529
|
+
startLine: finding.startLine,
|
|
530
|
+
},
|
|
531
|
+
index,
|
|
532
|
+
reviewer,
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
function singleReviewBody(input) {
|
|
536
|
+
const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
|
|
537
|
+
const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
|
|
538
|
+
? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
|
|
539
|
+
: []);
|
|
540
|
+
const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
|
|
541
|
+
const line = finding.startLine == null || finding.startLine === finding.line
|
|
542
|
+
? String(finding.line)
|
|
543
|
+
: `${finding.startLine}-${finding.line}`;
|
|
544
|
+
return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
|
|
545
|
+
}));
|
|
546
|
+
const lines = [
|
|
547
|
+
`Magi single-account review result: ${input.verdict}.`,
|
|
548
|
+
"",
|
|
549
|
+
"Logical reviewer verdicts:",
|
|
550
|
+
...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
|
|
551
|
+
...(input.verdict === "CLOSE" && closeReasons.length
|
|
552
|
+
? ["", "Close reasons:", ...closeReasons]
|
|
553
|
+
: []),
|
|
554
|
+
...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
|
|
555
|
+
? ["", "Accepted change requests:", ...acceptedFindings]
|
|
556
|
+
: []),
|
|
557
|
+
"",
|
|
558
|
+
...outputs.map(([reviewer, output]) => formatReviewMarker({
|
|
559
|
+
head: input.headSha,
|
|
560
|
+
pr: input.pr,
|
|
561
|
+
reviewer,
|
|
562
|
+
verdict: output.verdict,
|
|
563
|
+
})),
|
|
564
|
+
];
|
|
565
|
+
return lines.join("\n");
|
|
566
|
+
}
|
|
567
|
+
function singleFindingBody(input) {
|
|
568
|
+
return [
|
|
569
|
+
`**Issue:** ${input.finding.issue}`,
|
|
570
|
+
"",
|
|
571
|
+
`**Fix:** ${input.finding.fix}`,
|
|
572
|
+
"",
|
|
573
|
+
`**Reviewer:** ${input.reviewer}`,
|
|
574
|
+
"",
|
|
575
|
+
formatReviewFindingMarker({
|
|
576
|
+
finding: input.index,
|
|
577
|
+
head: input.headSha,
|
|
578
|
+
pr: input.pr,
|
|
579
|
+
reviewer: input.reviewer,
|
|
580
|
+
}),
|
|
581
|
+
].join("\n");
|
|
582
|
+
}
|
|
583
|
+
export async function postSingleConsensusReview(input) {
|
|
584
|
+
const account = input.repository.account;
|
|
585
|
+
if (!account)
|
|
586
|
+
throw new Error("account is required for single review mode");
|
|
587
|
+
const body = singleReviewBody(input);
|
|
588
|
+
if (input.verdict === "MERGE") {
|
|
589
|
+
return postApproval(input.exec, input.repository, input.pr, account, body);
|
|
590
|
+
}
|
|
591
|
+
if (input.verdict === "CLOSE") {
|
|
592
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, body);
|
|
593
|
+
}
|
|
594
|
+
const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
|
|
595
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
|
|
596
|
+
body,
|
|
597
|
+
commentBodies: findings.map((item) => singleFindingBody({
|
|
598
|
+
finding: item.finding,
|
|
599
|
+
headSha: input.headSha,
|
|
600
|
+
index: item.index,
|
|
601
|
+
pr: input.pr,
|
|
602
|
+
reviewer: item.reviewer,
|
|
603
|
+
})),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
353
606
|
async function postRereviewOutput(input, reviewerKey, output) {
|
|
354
607
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
355
608
|
if (!reviewer)
|
|
356
609
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
357
|
-
|
|
358
|
-
await Promise.all(output.
|
|
610
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
611
|
+
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
|
|
612
|
+
await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
|
|
359
613
|
if (output.verdict === "MERGE")
|
|
360
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
614
|
+
return postApproval(input.exec, input.repository, input.pr, account);
|
|
361
615
|
if (output.verdict === "CLOSE")
|
|
362
|
-
return postCloseComment(input.exec, input.repository, input.pr,
|
|
616
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
363
617
|
if (!output.newFindings.length)
|
|
364
618
|
return "";
|
|
365
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
619
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
|
|
366
620
|
fix: "Please address this before merging.",
|
|
367
621
|
issue: finding.body,
|
|
368
622
|
path: finding.path,
|
|
@@ -670,23 +924,68 @@ export async function runReview(input) {
|
|
|
670
924
|
const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
|
|
671
925
|
const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
|
|
672
926
|
const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
|
|
927
|
+
const singleReviewMode = resolvedReviewMode(input.repository) === "single";
|
|
928
|
+
const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
|
|
673
929
|
const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
|
|
674
|
-
const preliminaryMode =
|
|
930
|
+
const preliminaryMode = singleReviewMode
|
|
931
|
+
? resolveSingleAccountReviewMode({
|
|
932
|
+
account: input.repository.account ?? "",
|
|
933
|
+
current: freshnessTarget,
|
|
934
|
+
pr: input.pr,
|
|
935
|
+
reviewerKeys,
|
|
936
|
+
reviews,
|
|
937
|
+
})
|
|
938
|
+
: resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
|
|
675
939
|
const unresolvedThreadsByAccount = new Map();
|
|
940
|
+
const unresolvedThreadsByReviewer = new Map();
|
|
676
941
|
const pendingThreadReplyAccounts = new Set();
|
|
942
|
+
const pendingThreadReplyReviewers = new Set();
|
|
677
943
|
const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
|
|
678
|
-
return preliminaryMode.assignments.get(reviewer
|
|
944
|
+
return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
|
|
679
945
|
});
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
946
|
+
if (singleReviewMode) {
|
|
947
|
+
const account = input.repository.account ?? "";
|
|
948
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
|
|
949
|
+
const assigned = assignThreadsByReviewFindingMarker({
|
|
950
|
+
fallbackReviewerKeys: reviewerKeys,
|
|
951
|
+
pr: input.pr,
|
|
952
|
+
reviewerKeys,
|
|
953
|
+
threads,
|
|
954
|
+
});
|
|
955
|
+
for (const reviewer of input.repository.agents.reviewers) {
|
|
956
|
+
const reviewerThreads = assigned[reviewer.key] ?? [];
|
|
957
|
+
unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
|
|
958
|
+
if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (hasPendingThreadReply(reviewerThreads, account)) {
|
|
962
|
+
pendingThreadReplyReviewers.add(reviewer.key);
|
|
963
|
+
}
|
|
685
964
|
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
968
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
|
|
969
|
+
unresolvedThreadsByAccount.set(reviewer.account, threads);
|
|
970
|
+
if (hasPendingThreadReply(threads, reviewer.account)) {
|
|
971
|
+
pendingThreadReplyAccounts.add(reviewer.account);
|
|
972
|
+
}
|
|
973
|
+
}, { signal: input.signal });
|
|
974
|
+
}
|
|
975
|
+
const mode = singleReviewMode
|
|
976
|
+
? pendingThreadReplyReviewers.size
|
|
977
|
+
? resolveSingleAccountReviewMode({
|
|
978
|
+
account: input.repository.account ?? "",
|
|
979
|
+
current: freshnessTarget,
|
|
980
|
+
pendingReviewers: pendingThreadReplyReviewers,
|
|
981
|
+
pr: input.pr,
|
|
982
|
+
reviewerKeys,
|
|
983
|
+
reviews,
|
|
984
|
+
})
|
|
985
|
+
: preliminaryMode
|
|
986
|
+
: pendingThreadReplyAccounts.size
|
|
987
|
+
? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
|
|
988
|
+
: preliminaryMode;
|
|
690
989
|
if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
|
|
691
990
|
throw new Error("PR has already been reviewed by all configured accounts");
|
|
692
991
|
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
@@ -776,7 +1075,7 @@ export async function runReview(input) {
|
|
|
776
1075
|
try {
|
|
777
1076
|
throwIfAborted(input.signal);
|
|
778
1077
|
const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
779
|
-
const assignment = mode.assignments.get(reviewer
|
|
1078
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
780
1079
|
if (!assignment || assignment.type === "skip")
|
|
781
1080
|
return [];
|
|
782
1081
|
return [{ assignment, reviewer }];
|
|
@@ -801,7 +1100,7 @@ export async function runReview(input) {
|
|
|
801
1100
|
worktreePath,
|
|
802
1101
|
});
|
|
803
1102
|
for (const reviewer of input.repository.agents.reviewers) {
|
|
804
|
-
const assignment = mode.assignments.get(reviewer
|
|
1103
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
805
1104
|
if (assignment?.type !== "skip")
|
|
806
1105
|
continue;
|
|
807
1106
|
await input.onProgress?.({
|
|
@@ -834,8 +1133,9 @@ export async function runReview(input) {
|
|
|
834
1133
|
const rereviewInlineCommentTargets = mergeConflictContext
|
|
835
1134
|
? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
|
|
836
1135
|
: inlineCommentTargets;
|
|
837
|
-
const unresolved =
|
|
838
|
-
(
|
|
1136
|
+
const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
|
|
1137
|
+
unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
1138
|
+
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
|
|
839
1139
|
const prompt = await composeRereviewPrompt({
|
|
840
1140
|
baseSha: meta.baseRefOid,
|
|
841
1141
|
ciFailureContext,
|
|
@@ -981,7 +1281,7 @@ export async function runReview(input) {
|
|
|
981
1281
|
throwIfAborted(input.signal);
|
|
982
1282
|
const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
|
|
983
1283
|
const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
984
|
-
const assignment = mode.assignments.get(reviewer
|
|
1284
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
985
1285
|
if (assignment?.type !== "skip")
|
|
986
1286
|
return [];
|
|
987
1287
|
return [
|
|
@@ -999,7 +1299,7 @@ export async function runReview(input) {
|
|
|
999
1299
|
})),
|
|
1000
1300
|
]);
|
|
1001
1301
|
const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1002
|
-
const assignment = mode.assignments.get(reviewer
|
|
1302
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1003
1303
|
if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
|
|
1004
1304
|
return [];
|
|
1005
1305
|
return [
|
|
@@ -1033,14 +1333,14 @@ export async function runReview(input) {
|
|
|
1033
1333
|
});
|
|
1034
1334
|
const activeOutputs = validation.outputs;
|
|
1035
1335
|
const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1036
|
-
const assignment = mode.assignments.get(reviewer
|
|
1336
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1037
1337
|
return assignment?.type === "skip"
|
|
1038
1338
|
? [[reviewer.key, reviewOutputFromState(assignment.review)]]
|
|
1039
1339
|
: [];
|
|
1040
1340
|
}));
|
|
1041
1341
|
const outputs = { ...skippedOutputs, ...activeOutputs };
|
|
1042
1342
|
const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1043
|
-
const assignment = mode.assignments.get(reviewer
|
|
1343
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1044
1344
|
if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
|
|
1045
1345
|
return [];
|
|
1046
1346
|
return [
|
|
@@ -1056,23 +1356,68 @@ export async function runReview(input) {
|
|
|
1056
1356
|
}));
|
|
1057
1357
|
const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
|
|
1058
1358
|
await input.onProgress?.({ phase: "posting reviews", type: "phase" });
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
?
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1359
|
+
const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1360
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1361
|
+
return assignment?.type === "skip"
|
|
1362
|
+
? [[reviewer.key, "skipped: already reviewed current head"]]
|
|
1363
|
+
: [];
|
|
1364
|
+
}));
|
|
1365
|
+
const posted = singleReviewMode
|
|
1366
|
+
? {
|
|
1367
|
+
...skippedPosted,
|
|
1368
|
+
...(Object.keys(activeOutputs).length
|
|
1369
|
+
? {
|
|
1370
|
+
consensus: input.dryRun
|
|
1371
|
+
? `dry-run:would-post-single-review:${verdict}`
|
|
1372
|
+
: await (async () => {
|
|
1373
|
+
const account = input.repository.account ?? "";
|
|
1374
|
+
await Promise.all(Object.values(activeOutputs).flatMap((output) => {
|
|
1375
|
+
if (!("resolve" in output))
|
|
1376
|
+
return [];
|
|
1377
|
+
return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
|
|
1378
|
+
}));
|
|
1379
|
+
await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
|
|
1380
|
+
if (!("followUps" in output))
|
|
1381
|
+
return [];
|
|
1382
|
+
return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
|
|
1383
|
+
`**Reviewer:** ${key}`,
|
|
1384
|
+
"",
|
|
1385
|
+
item.body,
|
|
1386
|
+
"",
|
|
1387
|
+
formatReviewMarker({
|
|
1388
|
+
head: meta.headRefOid,
|
|
1389
|
+
pr: input.pr,
|
|
1390
|
+
reviewer: key,
|
|
1391
|
+
verdict: output.verdict,
|
|
1392
|
+
}),
|
|
1393
|
+
].join("\n")));
|
|
1394
|
+
}));
|
|
1395
|
+
return postSingleConsensusReview({
|
|
1396
|
+
exec,
|
|
1397
|
+
headSha: meta.headRefOid,
|
|
1398
|
+
outputs,
|
|
1399
|
+
pr: input.pr,
|
|
1400
|
+
repository: input.repository,
|
|
1401
|
+
verdict,
|
|
1402
|
+
});
|
|
1403
|
+
})(),
|
|
1404
|
+
}
|
|
1405
|
+
: {}),
|
|
1406
|
+
}
|
|
1407
|
+
: {
|
|
1408
|
+
...skippedPosted,
|
|
1409
|
+
...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
|
|
1410
|
+
key,
|
|
1411
|
+
input.dryRun
|
|
1412
|
+
? dryRunReviewPost(key, output)
|
|
1413
|
+
: "resolve" in output
|
|
1414
|
+
? await postRereviewOutput({ ...input, exec }, key, output)
|
|
1415
|
+
: await postReviewOutput({ ...input, exec }, key, output),
|
|
1416
|
+
]))),
|
|
1417
|
+
};
|
|
1418
|
+
const automationAccount = singleReviewMode
|
|
1419
|
+
? input.repository.account
|
|
1420
|
+
: input.repository.agents.reviewers[0]?.account;
|
|
1076
1421
|
const enableReviewAutomation = input.enableReviewAutomation ?? true;
|
|
1077
1422
|
if (enableReviewAutomation &&
|
|
1078
1423
|
verdict === "MERGE" &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-magi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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>",
|
|
@@ -28,26 +28,26 @@
|
|
|
28
28
|
"schema.json"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@opencode-ai/plugin": "^1.15.
|
|
31
|
+
"@opencode-ai/plugin": "^1.15.10",
|
|
32
32
|
"ajv": "^8.20.0",
|
|
33
33
|
"picomatch": "^4.0.4",
|
|
34
|
-
"valibot": "^1.4.
|
|
34
|
+
"valibot": "^1.4.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@changesets/changelog-github": "^0.7.0",
|
|
38
38
|
"@changesets/cli": "^2.30.0",
|
|
39
39
|
"@commitlint/cli": "^21.0.1",
|
|
40
40
|
"@commitlint/config-conventional": "^21.0.1",
|
|
41
|
-
"@types/node": "^25.9.
|
|
41
|
+
"@types/node": "^25.9.1",
|
|
42
42
|
"@types/picomatch": "^4.0.3",
|
|
43
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
43
|
+
"@typescript/native-preview": "7.0.0-dev.20260525.1",
|
|
44
44
|
"@vitest/coverage-v8": "^4.1.7",
|
|
45
45
|
"@vitest/ui": "^4.1.7",
|
|
46
46
|
"eslint-plugin-perfectionist": "^5.9.0",
|
|
47
47
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
48
|
-
"lefthook": "^2.1.
|
|
49
|
-
"oxfmt": "^0.
|
|
50
|
-
"oxlint": "^1.
|
|
48
|
+
"lefthook": "^2.1.8",
|
|
49
|
+
"oxfmt": "^0.51.0",
|
|
50
|
+
"oxlint": "^1.66.0",
|
|
51
51
|
"oxlint-tsgolint": "^0.23.0",
|
|
52
52
|
"rimraf": "^6.1.3",
|
|
53
53
|
"vitest": "^4.1.7"
|
package/schema.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"additionalProperties": false,
|
|
6
6
|
"properties": {
|
|
7
7
|
"$schema": { "type": "string" },
|
|
8
|
+
"account": { "type": "string", "minLength": 1 },
|
|
8
9
|
"agents": {
|
|
9
10
|
"type": "object",
|
|
10
11
|
"additionalProperties": false,
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
},
|
|
45
46
|
"language": { "type": "string" },
|
|
46
47
|
"merge": { "$ref": "#/$defs/merge" },
|
|
48
|
+
"mode": { "enum": ["multi", "single"], "default": "single" },
|
|
47
49
|
"output": {
|
|
48
50
|
"type": "object",
|
|
49
51
|
"additionalProperties": false,
|
|
@@ -77,7 +79,7 @@
|
|
|
77
79
|
"reviewer": {
|
|
78
80
|
"type": "object",
|
|
79
81
|
"if": { "not": { "required": ["ref"] } },
|
|
80
|
-
"then": { "required": ["model"
|
|
82
|
+
"then": { "required": ["model"] },
|
|
81
83
|
"additionalProperties": false,
|
|
82
84
|
"properties": {
|
|
83
85
|
"ref": { "type": "string", "minLength": 1 },
|
|
@@ -113,7 +115,7 @@
|
|
|
113
115
|
"triageAgent": {
|
|
114
116
|
"type": "object",
|
|
115
117
|
"if": { "not": { "required": ["ref"] } },
|
|
116
|
-
"then": { "required": ["model"
|
|
118
|
+
"then": { "required": ["model"] },
|
|
117
119
|
"additionalProperties": false,
|
|
118
120
|
"properties": {
|
|
119
121
|
"ref": { "type": "string", "minLength": 1 },
|