opencode-magi 0.0.0-dev-20260525101932 → 0.0.0-dev-20260525154011
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/dist/config/resolve.js +6 -0
- package/dist/config/validate.js +40 -12
- package/dist/github/commands.js +16 -6
- package/dist/orchestrator/merge.js +57 -13
- package/dist/orchestrator/review.js +388 -45
- package/package.json +1 -1
- package/schema.json +3 -1
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
|
+
- 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
|
|
package/dist/config/resolve.js
CHANGED
|
@@ -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,
|
package/dist/config/validate.js
CHANGED
|
@@ -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
|
-
|
|
912
|
-
|
|
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
|
-
|
|
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,
|
|
987
|
+
const permissions = await fetchPermissions(config, exec, account);
|
|
962
988
|
if (!permissions.pull) {
|
|
963
|
-
errors.push(`GitHub account cannot read repository for PR review: ${
|
|
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: ${
|
|
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) {
|
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.review?.mode === "single";
|
|
217
|
+
const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
|
|
218
|
+
const singleModeThreads = singleReviewMode
|
|
219
|
+
? assignThreadsByReviewFindingMarker({
|
|
220
|
+
fallbackReviewerKeys: reviewerKeys,
|
|
221
|
+
pr: input.pr,
|
|
222
|
+
reviewerKeys,
|
|
223
|
+
threads: options.dryRunThreads == null
|
|
224
|
+
? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.review?.account ?? "")
|
|
225
|
+
: Object.values(options.dryRunThreads).flat(),
|
|
226
|
+
})
|
|
227
|
+
: undefined;
|
|
215
228
|
let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
216
229
|
throwIfAborted(input.signal);
|
|
217
|
-
const unresolved =
|
|
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.review?.account ?? "";
|
|
416
|
+
await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
|
|
417
|
+
await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
|
|
418
|
+
`**Reviewer:** ${entry.reviewer}`,
|
|
419
|
+
"",
|
|
420
|
+
item.body,
|
|
421
|
+
"",
|
|
422
|
+
formatReviewMarker({
|
|
423
|
+
head: headSha,
|
|
424
|
+
pr: input.pr,
|
|
425
|
+
reviewer: entry.reviewer,
|
|
426
|
+
verdict: entry.output.verdict,
|
|
427
|
+
}),
|
|
428
|
+
].join("\n")))));
|
|
429
|
+
return postSingleConsensusReview({
|
|
430
|
+
exec: input.exec,
|
|
431
|
+
headSha,
|
|
432
|
+
outputs,
|
|
433
|
+
pr: input.pr,
|
|
434
|
+
repository: input.repository,
|
|
435
|
+
verdict,
|
|
436
|
+
});
|
|
437
|
+
})(),
|
|
438
|
+
}
|
|
439
|
+
: Object.fromEntries(await Promise.all(entries.map(async (entry) => [
|
|
440
|
+
entry.reviewer,
|
|
441
|
+
await postRereviewOutput(input, entry.reviewer, entry.output),
|
|
442
|
+
])));
|
|
399
443
|
await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
|
|
400
444
|
approvalPolicy: input.repository.merge.approvalPolicy,
|
|
401
445
|
verdict,
|
|
@@ -405,7 +449,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
|
|
|
405
449
|
})),
|
|
406
450
|
}, null, 2));
|
|
407
451
|
return {
|
|
408
|
-
outputs
|
|
452
|
+
outputs,
|
|
409
453
|
posted,
|
|
410
454
|
verdict,
|
|
411
455
|
};
|
|
@@ -15,6 +15,85 @@ import { mapPool } from "./pool";
|
|
|
15
15
|
import { formatReviewReport } from "./report";
|
|
16
16
|
import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
|
|
17
17
|
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
18
|
+
function resolvedReviewMode(repository) {
|
|
19
|
+
return repository.review?.mode === "single" ? "single" : "multi";
|
|
20
|
+
}
|
|
21
|
+
export function reviewPostingAccount(repository, reviewer) {
|
|
22
|
+
return resolvedReviewMode(repository) === "single"
|
|
23
|
+
? (repository.review?.account ?? reviewer.account)
|
|
24
|
+
: reviewer.account;
|
|
25
|
+
}
|
|
26
|
+
function reviewAssignmentKey(repository, reviewer) {
|
|
27
|
+
return resolvedReviewMode(repository) === "single"
|
|
28
|
+
? reviewer.key
|
|
29
|
+
: reviewer.account;
|
|
30
|
+
}
|
|
31
|
+
function parseMarkerFields(text) {
|
|
32
|
+
const fields = Object.fromEntries(text
|
|
33
|
+
.trim()
|
|
34
|
+
.split(/\s+/)
|
|
35
|
+
.flatMap((part) => {
|
|
36
|
+
const index = part.indexOf("=");
|
|
37
|
+
return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
|
|
38
|
+
}));
|
|
39
|
+
return fields.v === "1" && fields.mode === "single" ? fields : undefined;
|
|
40
|
+
}
|
|
41
|
+
function isMarkerVerdict(value) {
|
|
42
|
+
return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
|
|
43
|
+
}
|
|
44
|
+
export function formatReviewMarker(marker) {
|
|
45
|
+
return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
|
|
46
|
+
}
|
|
47
|
+
export function parseReviewMarkers(body) {
|
|
48
|
+
const markers = [];
|
|
49
|
+
const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
|
|
50
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
51
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
52
|
+
const pr = Number(fields?.pr);
|
|
53
|
+
if (!fields ||
|
|
54
|
+
!Number.isInteger(pr) ||
|
|
55
|
+
!fields.reviewer ||
|
|
56
|
+
!fields.head ||
|
|
57
|
+
!isMarkerVerdict(fields.verdict)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
markers.push({
|
|
61
|
+
head: fields.head,
|
|
62
|
+
pr,
|
|
63
|
+
reviewer: fields.reviewer,
|
|
64
|
+
verdict: fields.verdict,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return markers;
|
|
68
|
+
}
|
|
69
|
+
export function formatReviewFindingMarker(marker) {
|
|
70
|
+
return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
|
|
71
|
+
}
|
|
72
|
+
export function parseReviewFindingMarkers(body) {
|
|
73
|
+
const markers = [];
|
|
74
|
+
const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
|
|
75
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
76
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
77
|
+
const pr = Number(fields?.pr);
|
|
78
|
+
const finding = Number(fields?.finding);
|
|
79
|
+
if (!fields ||
|
|
80
|
+
!Number.isInteger(pr) ||
|
|
81
|
+
!Number.isInteger(finding) ||
|
|
82
|
+
!fields.reviewer ||
|
|
83
|
+
!fields.head) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
|
|
87
|
+
}
|
|
88
|
+
return markers;
|
|
89
|
+
}
|
|
90
|
+
function markerReviewState(verdict) {
|
|
91
|
+
if (verdict === "MERGE")
|
|
92
|
+
return "APPROVED";
|
|
93
|
+
if (verdict === "CHANGES_REQUESTED")
|
|
94
|
+
return "CHANGES_REQUESTED";
|
|
95
|
+
return "CLOSE";
|
|
96
|
+
}
|
|
18
97
|
function errorMessage(error) {
|
|
19
98
|
return error instanceof Error ? error.message : String(error);
|
|
20
99
|
}
|
|
@@ -35,11 +114,12 @@ async function postReviewOutput(input, reviewerKey, output) {
|
|
|
35
114
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
36
115
|
if (!reviewer)
|
|
37
116
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
117
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
38
118
|
if (output.verdict === "MERGE")
|
|
39
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
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,
|
|
42
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
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
|
|
430
|
+
const visibleBody = body
|
|
431
|
+
.replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
|
|
432
|
+
.trim();
|
|
433
|
+
const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]*?)(?:\s*\r?\n\r?\n\*\*Reviewer:\*\*[\s\S]*)?\s*$/.exec(visibleBody);
|
|
299
434
|
if (!match)
|
|
300
435
|
return undefined;
|
|
301
436
|
return {
|
|
@@ -350,19 +485,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
|
350
485
|
comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
|
|
351
486
|
});
|
|
352
487
|
}
|
|
488
|
+
export function assignThreadsByReviewFindingMarker(input) {
|
|
489
|
+
const reviewerKeys = new Set(input.reviewerKeys);
|
|
490
|
+
const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
|
|
491
|
+
for (const thread of input.threads) {
|
|
492
|
+
const markers = [
|
|
493
|
+
thread.body,
|
|
494
|
+
thread.latestBody,
|
|
495
|
+
...thread.comments.map((comment) => comment.body),
|
|
496
|
+
]
|
|
497
|
+
.flatMap(parseReviewFindingMarkers)
|
|
498
|
+
.filter((marker) => {
|
|
499
|
+
return (marker.pr === input.pr &&
|
|
500
|
+
reviewerKeys.has(marker.reviewer) &&
|
|
501
|
+
(!input.headSha || marker.head === input.headSha));
|
|
502
|
+
});
|
|
503
|
+
const reviewers = markers.length
|
|
504
|
+
? [...new Set(markers.map((marker) => marker.reviewer))]
|
|
505
|
+
: input.fallbackReviewerKeys;
|
|
506
|
+
for (const reviewer of reviewers)
|
|
507
|
+
assigned[reviewer]?.push(thread);
|
|
508
|
+
}
|
|
509
|
+
return assigned;
|
|
510
|
+
}
|
|
511
|
+
function outputFindings(reviewer, output) {
|
|
512
|
+
if (output.verdict !== "CHANGES_REQUESTED")
|
|
513
|
+
return [];
|
|
514
|
+
if ("findings" in output) {
|
|
515
|
+
return output.findings.map((finding, index) => ({
|
|
516
|
+
finding,
|
|
517
|
+
index,
|
|
518
|
+
reviewer,
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
return output.newFindings.map((finding, index) => ({
|
|
522
|
+
finding: {
|
|
523
|
+
fix: "Please address this before merging.",
|
|
524
|
+
issue: finding.body,
|
|
525
|
+
line: finding.line,
|
|
526
|
+
path: finding.path,
|
|
527
|
+
startLine: finding.startLine,
|
|
528
|
+
},
|
|
529
|
+
index,
|
|
530
|
+
reviewer,
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
function singleReviewBody(input) {
|
|
534
|
+
const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
|
|
535
|
+
const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
|
|
536
|
+
? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
|
|
537
|
+
: []);
|
|
538
|
+
const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
|
|
539
|
+
const line = finding.startLine == null || finding.startLine === finding.line
|
|
540
|
+
? String(finding.line)
|
|
541
|
+
: `${finding.startLine}-${finding.line}`;
|
|
542
|
+
return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
|
|
543
|
+
}));
|
|
544
|
+
const lines = [
|
|
545
|
+
`Magi single-account review result: ${input.verdict}.`,
|
|
546
|
+
"",
|
|
547
|
+
"Logical reviewer verdicts:",
|
|
548
|
+
...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
|
|
549
|
+
...(input.verdict === "CLOSE" && closeReasons.length
|
|
550
|
+
? ["", "Close reasons:", ...closeReasons]
|
|
551
|
+
: []),
|
|
552
|
+
...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
|
|
553
|
+
? ["", "Accepted change requests:", ...acceptedFindings]
|
|
554
|
+
: []),
|
|
555
|
+
"",
|
|
556
|
+
...outputs.map(([reviewer, output]) => formatReviewMarker({
|
|
557
|
+
head: input.headSha,
|
|
558
|
+
pr: input.pr,
|
|
559
|
+
reviewer,
|
|
560
|
+
verdict: output.verdict,
|
|
561
|
+
})),
|
|
562
|
+
];
|
|
563
|
+
return lines.join("\n");
|
|
564
|
+
}
|
|
565
|
+
function singleFindingBody(input) {
|
|
566
|
+
return [
|
|
567
|
+
`**Issue:** ${input.finding.issue}`,
|
|
568
|
+
"",
|
|
569
|
+
`**Fix:** ${input.finding.fix}`,
|
|
570
|
+
"",
|
|
571
|
+
`**Reviewer:** ${input.reviewer}`,
|
|
572
|
+
"",
|
|
573
|
+
formatReviewFindingMarker({
|
|
574
|
+
finding: input.index,
|
|
575
|
+
head: input.headSha,
|
|
576
|
+
pr: input.pr,
|
|
577
|
+
reviewer: input.reviewer,
|
|
578
|
+
}),
|
|
579
|
+
].join("\n");
|
|
580
|
+
}
|
|
581
|
+
export async function postSingleConsensusReview(input) {
|
|
582
|
+
const account = input.repository.review?.account;
|
|
583
|
+
if (!account)
|
|
584
|
+
throw new Error("review.account is required for single review mode");
|
|
585
|
+
const body = singleReviewBody(input);
|
|
586
|
+
if (input.verdict === "MERGE") {
|
|
587
|
+
return postApproval(input.exec, input.repository, input.pr, account, body);
|
|
588
|
+
}
|
|
589
|
+
if (input.verdict === "CLOSE") {
|
|
590
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, body);
|
|
591
|
+
}
|
|
592
|
+
const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
|
|
593
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
|
|
594
|
+
body,
|
|
595
|
+
commentBodies: findings.map((item) => singleFindingBody({
|
|
596
|
+
finding: item.finding,
|
|
597
|
+
headSha: input.headSha,
|
|
598
|
+
index: item.index,
|
|
599
|
+
pr: input.pr,
|
|
600
|
+
reviewer: item.reviewer,
|
|
601
|
+
})),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
353
604
|
async function postRereviewOutput(input, reviewerKey, output) {
|
|
354
605
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
355
606
|
if (!reviewer)
|
|
356
607
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
357
|
-
|
|
358
|
-
await Promise.all(output.
|
|
608
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
609
|
+
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
|
|
610
|
+
await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
|
|
359
611
|
if (output.verdict === "MERGE")
|
|
360
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
612
|
+
return postApproval(input.exec, input.repository, input.pr, account);
|
|
361
613
|
if (output.verdict === "CLOSE")
|
|
362
|
-
return postCloseComment(input.exec, input.repository, input.pr,
|
|
614
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
363
615
|
if (!output.newFindings.length)
|
|
364
616
|
return "";
|
|
365
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
617
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
|
|
366
618
|
fix: "Please address this before merging.",
|
|
367
619
|
issue: finding.body,
|
|
368
620
|
path: finding.path,
|
|
@@ -670,23 +922,68 @@ export async function runReview(input) {
|
|
|
670
922
|
const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
|
|
671
923
|
const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
|
|
672
924
|
const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
|
|
925
|
+
const singleReviewMode = resolvedReviewMode(input.repository) === "single";
|
|
926
|
+
const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
|
|
673
927
|
const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
|
|
674
|
-
const preliminaryMode =
|
|
928
|
+
const preliminaryMode = singleReviewMode
|
|
929
|
+
? resolveSingleAccountReviewMode({
|
|
930
|
+
account: input.repository.review?.account ?? "",
|
|
931
|
+
current: freshnessTarget,
|
|
932
|
+
pr: input.pr,
|
|
933
|
+
reviewerKeys,
|
|
934
|
+
reviews,
|
|
935
|
+
})
|
|
936
|
+
: resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
|
|
675
937
|
const unresolvedThreadsByAccount = new Map();
|
|
938
|
+
const unresolvedThreadsByReviewer = new Map();
|
|
676
939
|
const pendingThreadReplyAccounts = new Set();
|
|
940
|
+
const pendingThreadReplyReviewers = new Set();
|
|
677
941
|
const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
|
|
678
|
-
return preliminaryMode.assignments.get(reviewer
|
|
942
|
+
return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
|
|
679
943
|
});
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
944
|
+
if (singleReviewMode) {
|
|
945
|
+
const account = input.repository.review?.account ?? "";
|
|
946
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
|
|
947
|
+
const assigned = assignThreadsByReviewFindingMarker({
|
|
948
|
+
fallbackReviewerKeys: reviewerKeys,
|
|
949
|
+
pr: input.pr,
|
|
950
|
+
reviewerKeys,
|
|
951
|
+
threads,
|
|
952
|
+
});
|
|
953
|
+
for (const reviewer of input.repository.agents.reviewers) {
|
|
954
|
+
const reviewerThreads = assigned[reviewer.key] ?? [];
|
|
955
|
+
unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
|
|
956
|
+
if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (hasPendingThreadReply(reviewerThreads, account)) {
|
|
960
|
+
pendingThreadReplyReviewers.add(reviewer.key);
|
|
961
|
+
}
|
|
685
962
|
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
966
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
|
|
967
|
+
unresolvedThreadsByAccount.set(reviewer.account, threads);
|
|
968
|
+
if (hasPendingThreadReply(threads, reviewer.account)) {
|
|
969
|
+
pendingThreadReplyAccounts.add(reviewer.account);
|
|
970
|
+
}
|
|
971
|
+
}, { signal: input.signal });
|
|
972
|
+
}
|
|
973
|
+
const mode = singleReviewMode
|
|
974
|
+
? pendingThreadReplyReviewers.size
|
|
975
|
+
? resolveSingleAccountReviewMode({
|
|
976
|
+
account: input.repository.review?.account ?? "",
|
|
977
|
+
current: freshnessTarget,
|
|
978
|
+
pendingReviewers: pendingThreadReplyReviewers,
|
|
979
|
+
pr: input.pr,
|
|
980
|
+
reviewerKeys,
|
|
981
|
+
reviews,
|
|
982
|
+
})
|
|
983
|
+
: preliminaryMode
|
|
984
|
+
: pendingThreadReplyAccounts.size
|
|
985
|
+
? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
|
|
986
|
+
: preliminaryMode;
|
|
690
987
|
if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
|
|
691
988
|
throw new Error("PR has already been reviewed by all configured accounts");
|
|
692
989
|
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
@@ -776,7 +1073,7 @@ export async function runReview(input) {
|
|
|
776
1073
|
try {
|
|
777
1074
|
throwIfAborted(input.signal);
|
|
778
1075
|
const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
779
|
-
const assignment = mode.assignments.get(reviewer
|
|
1076
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
780
1077
|
if (!assignment || assignment.type === "skip")
|
|
781
1078
|
return [];
|
|
782
1079
|
return [{ assignment, reviewer }];
|
|
@@ -801,7 +1098,7 @@ export async function runReview(input) {
|
|
|
801
1098
|
worktreePath,
|
|
802
1099
|
});
|
|
803
1100
|
for (const reviewer of input.repository.agents.reviewers) {
|
|
804
|
-
const assignment = mode.assignments.get(reviewer
|
|
1101
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
805
1102
|
if (assignment?.type !== "skip")
|
|
806
1103
|
continue;
|
|
807
1104
|
await input.onProgress?.({
|
|
@@ -834,8 +1131,9 @@ export async function runReview(input) {
|
|
|
834
1131
|
const rereviewInlineCommentTargets = mergeConflictContext
|
|
835
1132
|
? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
|
|
836
1133
|
: inlineCommentTargets;
|
|
837
|
-
const unresolved =
|
|
838
|
-
(
|
|
1134
|
+
const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
|
|
1135
|
+
unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
1136
|
+
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
|
|
839
1137
|
const prompt = await composeRereviewPrompt({
|
|
840
1138
|
baseSha: meta.baseRefOid,
|
|
841
1139
|
ciFailureContext,
|
|
@@ -981,7 +1279,7 @@ export async function runReview(input) {
|
|
|
981
1279
|
throwIfAborted(input.signal);
|
|
982
1280
|
const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
|
|
983
1281
|
const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
984
|
-
const assignment = mode.assignments.get(reviewer
|
|
1282
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
985
1283
|
if (assignment?.type !== "skip")
|
|
986
1284
|
return [];
|
|
987
1285
|
return [
|
|
@@ -999,7 +1297,7 @@ export async function runReview(input) {
|
|
|
999
1297
|
})),
|
|
1000
1298
|
]);
|
|
1001
1299
|
const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1002
|
-
const assignment = mode.assignments.get(reviewer
|
|
1300
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1003
1301
|
if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
|
|
1004
1302
|
return [];
|
|
1005
1303
|
return [
|
|
@@ -1033,14 +1331,14 @@ export async function runReview(input) {
|
|
|
1033
1331
|
});
|
|
1034
1332
|
const activeOutputs = validation.outputs;
|
|
1035
1333
|
const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1036
|
-
const assignment = mode.assignments.get(reviewer
|
|
1334
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1037
1335
|
return assignment?.type === "skip"
|
|
1038
1336
|
? [[reviewer.key, reviewOutputFromState(assignment.review)]]
|
|
1039
1337
|
: [];
|
|
1040
1338
|
}));
|
|
1041
1339
|
const outputs = { ...skippedOutputs, ...activeOutputs };
|
|
1042
1340
|
const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1043
|
-
const assignment = mode.assignments.get(reviewer
|
|
1341
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1044
1342
|
if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
|
|
1045
1343
|
return [];
|
|
1046
1344
|
return [
|
|
@@ -1056,23 +1354,68 @@ export async function runReview(input) {
|
|
|
1056
1354
|
}));
|
|
1057
1355
|
const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
|
|
1058
1356
|
await input.onProgress?.({ phase: "posting reviews", type: "phase" });
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
?
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1357
|
+
const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1358
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1359
|
+
return assignment?.type === "skip"
|
|
1360
|
+
? [[reviewer.key, "skipped: already reviewed current head"]]
|
|
1361
|
+
: [];
|
|
1362
|
+
}));
|
|
1363
|
+
const posted = singleReviewMode
|
|
1364
|
+
? {
|
|
1365
|
+
...skippedPosted,
|
|
1366
|
+
...(Object.keys(activeOutputs).length
|
|
1367
|
+
? {
|
|
1368
|
+
consensus: input.dryRun
|
|
1369
|
+
? `dry-run:would-post-single-review:${verdict}`
|
|
1370
|
+
: await (async () => {
|
|
1371
|
+
const account = input.repository.review?.account ?? "";
|
|
1372
|
+
await Promise.all(Object.values(activeOutputs).flatMap((output) => {
|
|
1373
|
+
if (!("resolve" in output))
|
|
1374
|
+
return [];
|
|
1375
|
+
return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
|
|
1376
|
+
}));
|
|
1377
|
+
await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
|
|
1378
|
+
if (!("followUps" in output))
|
|
1379
|
+
return [];
|
|
1380
|
+
return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
|
|
1381
|
+
`**Reviewer:** ${key}`,
|
|
1382
|
+
"",
|
|
1383
|
+
item.body,
|
|
1384
|
+
"",
|
|
1385
|
+
formatReviewMarker({
|
|
1386
|
+
head: meta.headRefOid,
|
|
1387
|
+
pr: input.pr,
|
|
1388
|
+
reviewer: key,
|
|
1389
|
+
verdict: output.verdict,
|
|
1390
|
+
}),
|
|
1391
|
+
].join("\n")));
|
|
1392
|
+
}));
|
|
1393
|
+
return postSingleConsensusReview({
|
|
1394
|
+
exec,
|
|
1395
|
+
headSha: meta.headRefOid,
|
|
1396
|
+
outputs,
|
|
1397
|
+
pr: input.pr,
|
|
1398
|
+
repository: input.repository,
|
|
1399
|
+
verdict,
|
|
1400
|
+
});
|
|
1401
|
+
})(),
|
|
1402
|
+
}
|
|
1403
|
+
: {}),
|
|
1404
|
+
}
|
|
1405
|
+
: {
|
|
1406
|
+
...skippedPosted,
|
|
1407
|
+
...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
|
|
1408
|
+
key,
|
|
1409
|
+
input.dryRun
|
|
1410
|
+
? dryRunReviewPost(key, output)
|
|
1411
|
+
: "resolve" in output
|
|
1412
|
+
? await postRereviewOutput({ ...input, exec }, key, output)
|
|
1413
|
+
: await postReviewOutput({ ...input, exec }, key, output),
|
|
1414
|
+
]))),
|
|
1415
|
+
};
|
|
1416
|
+
const automationAccount = singleReviewMode
|
|
1417
|
+
? input.repository.review?.account
|
|
1418
|
+
: input.repository.agents.reviewers[0]?.account;
|
|
1076
1419
|
const enableReviewAutomation = input.enableReviewAutomation ?? true;
|
|
1077
1420
|
if (enableReviewAutomation &&
|
|
1078
1421
|
verdict === "MERGE" &&
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-magi",
|
|
3
|
-
"version": "0.0.0-dev-
|
|
3
|
+
"version": "0.0.0-dev-20260525154011",
|
|
4
4
|
"description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
|
package/schema.json
CHANGED
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"reviewer": {
|
|
78
78
|
"type": "object",
|
|
79
79
|
"if": { "not": { "required": ["ref"] } },
|
|
80
|
-
"then": { "required": ["model"
|
|
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,
|