opencode-magi 0.10.0 → 0.11.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.ja.md +254 -0
- package/README.md +31 -26
- package/dist/commands.js +11 -9
- package/dist/config/index.js +2 -0
- package/dist/config/resolve.js +120 -246
- package/dist/config/validate.js +103 -1110
- package/dist/constant.js +135 -0
- package/dist/graphql/index.generated.js +125 -0
- package/dist/graphql/index.js +49 -0
- package/dist/index.js +12 -800
- package/dist/magi.js +357 -0
- package/dist/permissions/common.json +22 -22
- package/dist/permissions/editor.json +12 -12
- package/dist/prompts/index.js +98 -0
- package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
- package/dist/prompts/merge/ci-classification/task.md +10 -0
- package/dist/prompts/merge/ci-classification/validate.json +20 -0
- package/dist/prompts/merge/conflict/output-contract.md +12 -0
- package/dist/prompts/merge/conflict/task.md +9 -0
- package/dist/prompts/merge/conflict/validate.json +4 -0
- package/dist/prompts/merge/edit/output-contract.md +26 -0
- package/dist/prompts/merge/edit/task.md +10 -0
- package/dist/prompts/merge/edit/validate.json +60 -0
- package/dist/prompts/review/ci-classification/output-contract.md +21 -0
- package/dist/prompts/review/ci-classification/task.md +7 -0
- package/dist/prompts/review/ci-classification/validate.json +20 -0
- package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
- package/dist/prompts/review/close-reconsideration/task.md +6 -0
- package/dist/prompts/review/close-reconsideration/validate.json +44 -0
- package/dist/prompts/review/comment/output-contract.md +11 -0
- package/dist/prompts/review/comment/task.md +3 -0
- package/dist/prompts/review/comment/validate.json +8 -0
- package/dist/prompts/review/finding-validation/output-contract.md +25 -0
- package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
- package/dist/prompts/review/finding-validation/validate.json +21 -0
- package/dist/prompts/review/rereview/output-contract.md +30 -0
- package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
- package/dist/prompts/review/rereview/validate.json +87 -0
- package/dist/prompts/review/review/output-contract.md +25 -0
- package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
- package/dist/prompts/review/review/validate.json +54 -0
- package/dist/prompts/triage/acceptance/output-contract.md +16 -0
- package/dist/prompts/triage/acceptance/validate.json +23 -0
- package/dist/prompts/triage/category/output-contract.md +16 -0
- package/dist/prompts/triage/category/validate.json +23 -0
- package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
- package/dist/prompts/triage/comment-classification/validate.json +28 -0
- package/dist/prompts/triage/create/output-contract.md +28 -0
- package/dist/prompts/triage/create/validate.json +73 -0
- package/dist/prompts/triage/deplicate/output-contract.md +16 -0
- package/dist/prompts/triage/deplicate/validate.json +20 -0
- package/dist/prompts/triage/existing/output-contract.md +16 -0
- package/dist/prompts/triage/existing/validate.json +13 -0
- package/dist/prompts/triage/reconsider/output-contract.md +16 -0
- package/dist/prompts/triage/reconsider/validate.json +23 -0
- package/dist/prompts/triage/signal/output-contract.md +19 -0
- package/dist/prompts/triage/signal/validate.json +18 -0
- package/dist/tools/clear/index.js +21 -0
- package/dist/tools/index.js +12 -0
- package/dist/tools/merge/action.js +47 -0
- package/dist/tools/merge/context.js +118 -0
- package/dist/tools/merge/editor.js +264 -0
- package/dist/tools/merge/index.js +151 -0
- package/dist/tools/merge/index.type.js +1 -0
- package/dist/tools/merge/merge.js +42 -0
- package/dist/tools/merge/report.js +73 -0
- package/dist/tools/review/action.js +429 -0
- package/dist/tools/review/check.js +295 -0
- package/dist/tools/review/context.js +258 -0
- package/dist/tools/review/index.js +104 -0
- package/dist/tools/review/index.type.js +1 -0
- package/dist/tools/review/report.js +143 -0
- package/dist/tools/review/review.js +145 -0
- package/dist/tools/review/reviewer.js +409 -0
- package/dist/tools/triage/index.js +16 -0
- package/dist/tools/validate/index.js +28 -0
- package/dist/utils/array.js +6 -0
- package/dist/utils/assertion.js +38 -0
- package/dist/utils/exec.js +14 -0
- package/dist/utils/fs.js +24 -0
- package/dist/utils/function.js +37 -0
- package/dist/utils/github.js +63 -0
- package/dist/utils/index.js +10 -0
- package/dist/utils/index.type.js +1 -0
- package/dist/utils/object.js +21 -0
- package/dist/utils/opencode.js +25 -0
- package/dist/utils/string.js +38 -0
- package/dist/utils/worker.js +30 -0
- package/package.json +20 -8
- package/schema.json +6 -4
- package/dist/config/load.js +0 -62
- package/dist/config/output.js +0 -25
- package/dist/config/worktree.js +0 -25
- package/dist/github/commands.js +0 -905
- package/dist/github/retry.js +0 -44
- package/dist/orchestrator/abort.js +0 -9
- package/dist/orchestrator/ci.js +0 -579
- package/dist/orchestrator/findings.js +0 -88
- package/dist/orchestrator/inline-comments.js +0 -73
- package/dist/orchestrator/majority.js +0 -62
- package/dist/orchestrator/merge.js +0 -1260
- package/dist/orchestrator/model.js +0 -216
- package/dist/orchestrator/pool.js +0 -15
- package/dist/orchestrator/report.js +0 -175
- package/dist/orchestrator/review-context.js +0 -342
- package/dist/orchestrator/review.js +0 -1480
- package/dist/orchestrator/run-manager.js +0 -2132
- package/dist/orchestrator/safety.js +0 -44
- package/dist/orchestrator/triage.js +0 -1370
- package/dist/prompts/compose.js +0 -473
- package/dist/prompts/contracts.js +0 -300
- package/dist/prompts/output.js +0 -401
- package/dist/prompts/templates/merge/ci-classification.md +0 -16
- package/dist/prompts/templates/merge/conflict.md +0 -10
- package/dist/prompts/templates/merge/edit.md +0 -15
- package/dist/prompts/templates/review/ci-classification.md +0 -9
- package/dist/prompts/templates/review/close-reconsideration.md +0 -6
- /package/dist/{types.js → config/index.type.js} +0 -0
- /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
- /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
- /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
- /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
- /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
- /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
- /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
- /package/dist/prompts/{templates/triage/signal.md → triage/signal/task.md} +0 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { MagiError } from "@/magi";
|
|
2
|
+
import { Prompt } from "@/prompts";
|
|
3
|
+
import { filterEmpty, marker, omitNullish, retry, toTitleCase, Worker, } from "@/utils";
|
|
4
|
+
export async function review() {
|
|
5
|
+
this.context.abort.throwIfAborted();
|
|
6
|
+
await this.updateEvent(`Reviewing.`);
|
|
7
|
+
if (!this.config.review.reviewers?.length)
|
|
8
|
+
throw new MagiError("blocked", "No reviewers configured.");
|
|
9
|
+
const worker = new Worker(this.config.review.concurrency.reviewers);
|
|
10
|
+
const reviewers = Object.fromEntries(await Promise.all(this.config.review.reviewers.map(({ account, id, persona }) => worker.run(async () => {
|
|
11
|
+
if (!this.state.pr?.metadata)
|
|
12
|
+
throw new MagiError("blocked", "PR metadata not found.");
|
|
13
|
+
if (!this.state.worktree)
|
|
14
|
+
throw new MagiError("blocked", "PR worktree not found.");
|
|
15
|
+
if (!this.state.pr.checks)
|
|
16
|
+
throw new MagiError("blocked", "PR checks not found.");
|
|
17
|
+
if (!this.state.pr.threads)
|
|
18
|
+
throw new MagiError("blocked", "PR threads not found.");
|
|
19
|
+
if (!this.state.reviewers)
|
|
20
|
+
throw new MagiError("blocked", "Reviewers not found.");
|
|
21
|
+
const { outputs, review, sessionId, status } = this.state.reviewers[id];
|
|
22
|
+
if (status === "skip") {
|
|
23
|
+
await this.updateEvent(`Skipping review with reviewer ${id}.`);
|
|
24
|
+
return [id, {}];
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
if (!sessionId)
|
|
28
|
+
throw new MagiError("blocked", `No session ID found for reviewer ${id}.`);
|
|
29
|
+
if (status === "rereview" && !review?.commit_id)
|
|
30
|
+
throw new MagiError("blocked", `Missing previous review commit for reviewer ${id}.`);
|
|
31
|
+
const rereview = status !== "initial";
|
|
32
|
+
const label = rereview ? "rereview" : "review";
|
|
33
|
+
const cycle = (outputs?.length ?? 0) + 1;
|
|
34
|
+
await this.updateEvent(`Running ${label} with reviewer ${id}.`);
|
|
35
|
+
const sha = rereview
|
|
36
|
+
? (review?.commit_id ?? this.state.pr.metadata.base.sha)
|
|
37
|
+
: this.state.pr.metadata.base.sha;
|
|
38
|
+
const inlineCommentTargets = this.state.pr.inlineCommentTargets?.[sha] ?? {};
|
|
39
|
+
const failedChecks = this.state.pr.checks.failed.filter(({ scope }) => scope);
|
|
40
|
+
const unresolvedThreads = this.state.pr.threads.filter(({ comments, isResolved }) => {
|
|
41
|
+
if (isResolved)
|
|
42
|
+
return false;
|
|
43
|
+
if (this.config.mode === "single")
|
|
44
|
+
return comments.some(({ body }) => {
|
|
45
|
+
const markers = marker.parse(body);
|
|
46
|
+
const { reviewer } = markers[0] ?? {};
|
|
47
|
+
return reviewer === id;
|
|
48
|
+
});
|
|
49
|
+
else
|
|
50
|
+
return comments.some(({ author }) => author?.login === account);
|
|
51
|
+
});
|
|
52
|
+
const reviewContext = JSON.stringify(omitNullish({
|
|
53
|
+
checks: this.state.pr.checks,
|
|
54
|
+
comments: this.state.pr.comments,
|
|
55
|
+
files: this.state.pr.files,
|
|
56
|
+
issues: this.state.pr.issues,
|
|
57
|
+
metadata: this.state.pr.metadata,
|
|
58
|
+
threads: this.state.pr.threads,
|
|
59
|
+
}), null, 2);
|
|
60
|
+
const tags = [
|
|
61
|
+
"output_contract",
|
|
62
|
+
["review", reviewContext],
|
|
63
|
+
];
|
|
64
|
+
if (review) {
|
|
65
|
+
const previousReviewContext = JSON.stringify(omitNullish({
|
|
66
|
+
body: review.body || undefined,
|
|
67
|
+
commitId: review.commit_id,
|
|
68
|
+
state: review.state,
|
|
69
|
+
submittedAt: review.submitted_at,
|
|
70
|
+
}), null, 2);
|
|
71
|
+
tags.push(["previous_review", previousReviewContext]);
|
|
72
|
+
}
|
|
73
|
+
tags.push([
|
|
74
|
+
"unresolved_threads",
|
|
75
|
+
JSON.stringify(unresolvedThreads, null, 2),
|
|
76
|
+
]);
|
|
77
|
+
if (failedChecks.length) {
|
|
78
|
+
const ciFailureContext = JSON.stringify(failedChecks, null, 2);
|
|
79
|
+
tags.push(["ci_failure", ciFailureContext]);
|
|
80
|
+
}
|
|
81
|
+
if (persona)
|
|
82
|
+
tags.push(["persona", persona]);
|
|
83
|
+
const prompt = await Prompt.init(this.magi, this.config, `review/${rereview ? "rereview" : "review"}`);
|
|
84
|
+
const taskMessage = await prompt.create(!rereview
|
|
85
|
+
? this.config.review.prompts?.review
|
|
86
|
+
: this.config.review.prompts?.rereview, tags, omitNullish({
|
|
87
|
+
baseSha: this.state.pr.metadata.base.sha,
|
|
88
|
+
headSha: this.state.pr.metadata.head.sha,
|
|
89
|
+
owner: this.config.github.owner,
|
|
90
|
+
pr: this.number.toString(),
|
|
91
|
+
previousHeadSha: rereview
|
|
92
|
+
? (review?.commit_id ?? this.state.pr.metadata.head.sha)
|
|
93
|
+
: undefined,
|
|
94
|
+
repo: this.config.github.repo,
|
|
95
|
+
worktreePath: this.state.worktree.path,
|
|
96
|
+
}));
|
|
97
|
+
const output = await retry(async (count, e) => {
|
|
98
|
+
const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
|
|
99
|
+
await this.createAgentFile(rereview ? "rereview" : "review", id, raw, count, cycle);
|
|
100
|
+
const parsed = prompt.parse(raw);
|
|
101
|
+
if (!prompt.validate(parsed))
|
|
102
|
+
throw new Error(`Invalid output for reviewer ${id}.`);
|
|
103
|
+
validateThreadTargets(parsed, unresolvedThreads);
|
|
104
|
+
validateInlineCommentTargets(status, parsed, inlineCommentTargets);
|
|
105
|
+
const findings = parsed.findings ?? parsed.newFindings ?? [];
|
|
106
|
+
await this.updateEvent(filterEmpty([
|
|
107
|
+
`Finished ${label} with reviewer ${id}.`,
|
|
108
|
+
`Verdict: ${toTitleCase(parsed.verdict.toLocaleLowerCase())}.`,
|
|
109
|
+
parsed.comment ? `Comment:\n${parsed.comment}` : undefined,
|
|
110
|
+
findings.length
|
|
111
|
+
? `Findings:\n${findings
|
|
112
|
+
.map(({ body, line, path, startLine }) => `- ${path}:${startLine != null ? `${startLine}-` : ""}${line}: ${body}`)
|
|
113
|
+
.join("\n")}`
|
|
114
|
+
: undefined,
|
|
115
|
+
parsed.followUps?.length
|
|
116
|
+
? `FollowUps:\n${parsed.followUps
|
|
117
|
+
.map(({ body, commentId }) => `- Comment ${commentId}: ${body}`)
|
|
118
|
+
.join("\n")}`
|
|
119
|
+
: undefined,
|
|
120
|
+
parsed.resolves?.length
|
|
121
|
+
? `Resolved threads:\n${parsed.resolves
|
|
122
|
+
.map(({ commentId, threadId }) => `- Comment ${commentId} in thread ${threadId}`)
|
|
123
|
+
.join("\n")}`
|
|
124
|
+
: undefined,
|
|
125
|
+
]).join("\n\n"));
|
|
126
|
+
return parsed;
|
|
127
|
+
}, {
|
|
128
|
+
error: async (e, count) => {
|
|
129
|
+
if (e instanceof MagiError)
|
|
130
|
+
throw e;
|
|
131
|
+
await this.updateEvent(`Attempt ${count} failed to ${label} with reviewer ${id}. Retrying...`);
|
|
132
|
+
},
|
|
133
|
+
retries: this.config.output.repairAttempts,
|
|
134
|
+
signal: this.context.abort,
|
|
135
|
+
});
|
|
136
|
+
if (!output)
|
|
137
|
+
throw new MagiError("blocked", `Invalid output for reviewer ${id}.`);
|
|
138
|
+
output.findings = output.findings?.map((finding) => ({
|
|
139
|
+
...finding,
|
|
140
|
+
state: "accepted",
|
|
141
|
+
}));
|
|
142
|
+
return [id, { outputs: [...(outputs ?? []), output] }];
|
|
143
|
+
}
|
|
144
|
+
}))));
|
|
145
|
+
await this.updateState({ reviewers });
|
|
146
|
+
await this.updateEvent(`Finished reviewing.`);
|
|
147
|
+
}
|
|
148
|
+
export async function validateFindings() {
|
|
149
|
+
this.context.abort.throwIfAborted();
|
|
150
|
+
if (!this.config.review.reviewers?.length)
|
|
151
|
+
throw new MagiError("blocked", "No reviewers configured.");
|
|
152
|
+
if (!this.state.reviewers)
|
|
153
|
+
throw new MagiError("blocked", "Reviewers not found.");
|
|
154
|
+
const findings = Object.entries(this.state.reviewers).flatMap(([reviewer, { outputs }]) => {
|
|
155
|
+
const output = outputs?.at(-1);
|
|
156
|
+
if (output?.verdict !== "CHANGES_REQUESTED")
|
|
157
|
+
return [];
|
|
158
|
+
return (output.findings ?? output.newFindings ?? []).map((finding, index) => ({ finding, index, reviewer }));
|
|
159
|
+
});
|
|
160
|
+
if (!findings.length)
|
|
161
|
+
return;
|
|
162
|
+
await this.updateEvent(`Validating review findings.`);
|
|
163
|
+
const accepted = await collectAcceptedFindings.call(this, findings);
|
|
164
|
+
const reviewers = Object.fromEntries(Object.entries(this.state.reviewers ?? {}).map(([id, reviewer]) => [
|
|
165
|
+
id,
|
|
166
|
+
transformState(reviewer, id, accepted),
|
|
167
|
+
]));
|
|
168
|
+
await notifyVerdictChanges.call(this, this.state.reviewers ?? {}, reviewers, "after majority finding validation");
|
|
169
|
+
await this.updateState({ reviewers });
|
|
170
|
+
await this.updateEvent(`Finished validating review findings.`);
|
|
171
|
+
}
|
|
172
|
+
export async function reconsiderClose() {
|
|
173
|
+
this.context.abort.throwIfAborted();
|
|
174
|
+
if (this.config.review.merge.approvalPolicy !== "unanimous")
|
|
175
|
+
return;
|
|
176
|
+
if (!this.config.review.reviewers?.length)
|
|
177
|
+
throw new MagiError("blocked", "No reviewers configured.");
|
|
178
|
+
if (!this.state.reviewers)
|
|
179
|
+
throw new MagiError("blocked", "Reviewers not found.");
|
|
180
|
+
const threshold = Math.floor(this.config.review.reviewers.length / 2) + 1;
|
|
181
|
+
const targetReviewers = Object.entries(this.state.reviewers).filter(([, { outputs }]) => outputs?.at(-1)?.verdict === "CLOSED");
|
|
182
|
+
const count = targetReviewers.length;
|
|
183
|
+
if (!count || count >= threshold)
|
|
184
|
+
return;
|
|
185
|
+
await this.updateEvent(`Reconsidering close verdicts.`);
|
|
186
|
+
const worker = new Worker(this.config.review.concurrency.reviewers);
|
|
187
|
+
const prompt = await Prompt.init(this.magi, this.config, "review/close-reconsideration");
|
|
188
|
+
const reviewers = Object.fromEntries(await Promise.all(targetReviewers.map(([id, { outputs, review, sessionId, status }]) => worker.run(async () => {
|
|
189
|
+
if (!this.state.pr?.metadata)
|
|
190
|
+
throw new MagiError("blocked", "PR metadata not found.");
|
|
191
|
+
if (!sessionId)
|
|
192
|
+
throw new MagiError("blocked", `No session ID found for reviewer ${id}.`);
|
|
193
|
+
if (status === "rereview" && !review?.commit_id)
|
|
194
|
+
throw new MagiError("blocked", `Missing previous review commit for reviewer ${id}.`);
|
|
195
|
+
const cycle = (outputs?.length ?? 0) + 1;
|
|
196
|
+
const sha = status === "initial"
|
|
197
|
+
? this.state.pr.metadata.base.sha
|
|
198
|
+
: (review?.commit_id ?? this.state.pr.metadata.base.sha);
|
|
199
|
+
const inlineCommentTargets = this.state.pr.inlineCommentTargets?.[sha] ?? {};
|
|
200
|
+
const taskMessage = await prompt.create(this.config.review.prompts?.closeReconsideration, ["output_contract"], {
|
|
201
|
+
owner: this.config.github.owner,
|
|
202
|
+
pr: this.number.toString(),
|
|
203
|
+
repo: this.config.github.repo,
|
|
204
|
+
});
|
|
205
|
+
await this.updateEvent(`Reconsidering close verdict with reviewer ${id}.`);
|
|
206
|
+
const output = await retry(async (count, e) => {
|
|
207
|
+
const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
|
|
208
|
+
await this.createAgentFile("close-reconsideration", id, raw, count, cycle);
|
|
209
|
+
const parsed = prompt.parse(raw);
|
|
210
|
+
if (!prompt.validate(parsed))
|
|
211
|
+
throw new Error(`Invalid close reconsideration output for reviewer ${id}.`);
|
|
212
|
+
validateInlineCommentTargets("initial", parsed, inlineCommentTargets);
|
|
213
|
+
return parsed;
|
|
214
|
+
}, {
|
|
215
|
+
error: async (e, count) => {
|
|
216
|
+
if (e instanceof MagiError)
|
|
217
|
+
throw e;
|
|
218
|
+
await this.updateEvent(`Attempt ${count} failed to reconsider close verdict with reviewer ${id}. Retrying...`);
|
|
219
|
+
},
|
|
220
|
+
retries: this.config.output.repairAttempts,
|
|
221
|
+
signal: this.context.abort,
|
|
222
|
+
});
|
|
223
|
+
if (!output)
|
|
224
|
+
throw new MagiError("blocked", `Invalid close reconsideration output for reviewer ${id}.`);
|
|
225
|
+
output.findings = output.findings?.map((finding) => ({
|
|
226
|
+
...finding,
|
|
227
|
+
state: "accepted",
|
|
228
|
+
}));
|
|
229
|
+
return [id, { outputs: [...(outputs ?? []), output] }];
|
|
230
|
+
}))));
|
|
231
|
+
const findings = Object.entries(reviewers).flatMap(([reviewer, { outputs }]) => {
|
|
232
|
+
const output = outputs?.at(-1);
|
|
233
|
+
if (output?.verdict !== "CHANGES_REQUESTED")
|
|
234
|
+
return [];
|
|
235
|
+
return (output.findings ?? []).map((finding, index) => ({
|
|
236
|
+
finding,
|
|
237
|
+
index,
|
|
238
|
+
reviewer,
|
|
239
|
+
}));
|
|
240
|
+
});
|
|
241
|
+
await notifyVerdictChanges.call(this, this.state.reviewers ?? {}, reviewers, "after close reconsideration");
|
|
242
|
+
const accepted = await collectAcceptedFindings.call(this, findings);
|
|
243
|
+
const validatedReviewers = Object.fromEntries(Object.entries(reviewers).map(([id, reviewer]) => [
|
|
244
|
+
id,
|
|
245
|
+
transformState(reviewer, id, accepted),
|
|
246
|
+
]));
|
|
247
|
+
await notifyVerdictChanges.call(this, reviewers, validatedReviewers, "after majority finding validation");
|
|
248
|
+
await this.updateState({ reviewers: validatedReviewers });
|
|
249
|
+
await this.updateEvent(`Finished reconsidering close verdicts.`);
|
|
250
|
+
}
|
|
251
|
+
async function collectAcceptedFindings(findings) {
|
|
252
|
+
const accepted = new Set();
|
|
253
|
+
if (!findings.length)
|
|
254
|
+
return accepted;
|
|
255
|
+
if (!this.config.review.reviewers?.length)
|
|
256
|
+
throw new MagiError("blocked", "No reviewers configured.");
|
|
257
|
+
if (!this.state.reviewers)
|
|
258
|
+
throw new MagiError("blocked", "Reviewers not found.");
|
|
259
|
+
const worker = new Worker(this.config.review.concurrency.reviewers);
|
|
260
|
+
const prompt = await Prompt.init(this.magi, this.config, "review/finding-validation");
|
|
261
|
+
const validations = Object.fromEntries(await Promise.all(this.config.review.reviewers.map(({ id }) => worker.run(async () => {
|
|
262
|
+
const { sessionId } = this.state.reviewers[id];
|
|
263
|
+
if (!sessionId)
|
|
264
|
+
throw new MagiError("blocked", `No session ID found for reviewer ${id}.`);
|
|
265
|
+
await this.updateEvent(`Validating review findings with reviewer ${id}.`);
|
|
266
|
+
const targetFindings = findings.filter((target) => target.reviewer !== id);
|
|
267
|
+
const expectedKeys = new Set(targetFindings.map(({ index, reviewer }) => `${reviewer}:${index}`));
|
|
268
|
+
const taskMessage = await prompt.create(this.config.review.prompts?.findingValidation, [
|
|
269
|
+
"output_contract",
|
|
270
|
+
["findings", JSON.stringify(targetFindings, null, 2)],
|
|
271
|
+
], {
|
|
272
|
+
owner: this.config.github.owner,
|
|
273
|
+
pr: this.number.toString(),
|
|
274
|
+
repo: this.config.github.repo,
|
|
275
|
+
});
|
|
276
|
+
const output = await retry(async (count, e) => {
|
|
277
|
+
const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
|
|
278
|
+
await this.createAgentFile("finding-validation", id, raw, count);
|
|
279
|
+
const parsed = prompt.parse(raw);
|
|
280
|
+
if (!prompt.validate(parsed))
|
|
281
|
+
throw new Error(`Invalid finding validation output for reviewer ${id}.`);
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
for (const vote of parsed.votes) {
|
|
284
|
+
if (vote.reviewer === id)
|
|
285
|
+
throw new Error(`${id} must not vote on its own findings.`);
|
|
286
|
+
const key = `${vote.reviewer}:${vote.index}`;
|
|
287
|
+
if (!expectedKeys.has(key))
|
|
288
|
+
throw new Error(`Unexpected finding vote: ${key}.`);
|
|
289
|
+
if (seen.has(key))
|
|
290
|
+
throw new Error(`Duplicate finding vote: ${key}.`);
|
|
291
|
+
seen.add(key);
|
|
292
|
+
}
|
|
293
|
+
for (const key of expectedKeys)
|
|
294
|
+
if (!seen.has(key))
|
|
295
|
+
throw new Error(`Missing finding vote: ${key}.`);
|
|
296
|
+
return parsed;
|
|
297
|
+
}, {
|
|
298
|
+
error: async (e, count) => {
|
|
299
|
+
if (e instanceof MagiError)
|
|
300
|
+
throw e;
|
|
301
|
+
await this.updateEvent(`Attempt ${count} failed to validate review findings with reviewer ${id}. Retrying...`);
|
|
302
|
+
},
|
|
303
|
+
retries: this.config.output.repairAttempts,
|
|
304
|
+
signal: this.context.abort,
|
|
305
|
+
});
|
|
306
|
+
if (!output)
|
|
307
|
+
throw new MagiError("blocked", `Invalid finding validation output for reviewer ${id}.`);
|
|
308
|
+
return [id, output];
|
|
309
|
+
}))));
|
|
310
|
+
const threshold = Math.floor(this.config.review.reviewers.length / 2) + 1;
|
|
311
|
+
await Promise.all(findings.map(async ({ finding, index, reviewer }) => {
|
|
312
|
+
const votes = Object.entries(validations).flatMap(([validator, validation]) => {
|
|
313
|
+
if (validator === reviewer)
|
|
314
|
+
return [];
|
|
315
|
+
const vote = validation.votes.find((vote) => vote.reviewer === reviewer && vote.index === index);
|
|
316
|
+
return vote ? [{ validator, vote }] : [];
|
|
317
|
+
});
|
|
318
|
+
const agrees = 1 + votes.filter(({ vote }) => vote.vote === "AGREE").length;
|
|
319
|
+
const key = `${reviewer}:${index}`;
|
|
320
|
+
const acceptedComments = votes
|
|
321
|
+
.filter(({ vote }) => vote.vote === "AGREE")
|
|
322
|
+
.map(({ validator, vote }) => `- ${validator}: ${vote.comment}`);
|
|
323
|
+
const rejectedComments = votes
|
|
324
|
+
.filter(({ vote }) => vote.vote === "DISAGREE")
|
|
325
|
+
.map(({ validator, vote }) => `- ${validator}: ${vote.comment}`);
|
|
326
|
+
if (agrees >= threshold)
|
|
327
|
+
accepted.add(key);
|
|
328
|
+
await this.updateEvent(filterEmpty([
|
|
329
|
+
`Finding ${reviewer} #${index + 1} was ${agrees >= threshold ? "accepted" : "rejected"} by majority vote.`,
|
|
330
|
+
`Finding: ${finding.path}:${finding.line}\n${finding.body}`,
|
|
331
|
+
acceptedComments.length
|
|
332
|
+
? `Accepted by:\n${acceptedComments.join("\n")}`
|
|
333
|
+
: undefined,
|
|
334
|
+
rejectedComments.length
|
|
335
|
+
? `Rejected by:\n${rejectedComments.join("\n")}`
|
|
336
|
+
: undefined,
|
|
337
|
+
]).join("\n\n"));
|
|
338
|
+
}));
|
|
339
|
+
return accepted;
|
|
340
|
+
}
|
|
341
|
+
function transformState(reviewer, id, accepted) {
|
|
342
|
+
const outputs = [...(reviewer.outputs ?? [])];
|
|
343
|
+
const prevOutput = outputs[outputs.length - 1];
|
|
344
|
+
if (prevOutput?.verdict !== "CHANGES_REQUESTED")
|
|
345
|
+
return reviewer;
|
|
346
|
+
const nextOutput = { ...prevOutput };
|
|
347
|
+
const findings = (prevOutput.findings ??
|
|
348
|
+
prevOutput.newFindings ??
|
|
349
|
+
[]).map((finding, index) => ({
|
|
350
|
+
...finding,
|
|
351
|
+
state: accepted.has(`${id}:${index}`) ? "accepted" : "discarded",
|
|
352
|
+
}));
|
|
353
|
+
if (prevOutput.findings)
|
|
354
|
+
nextOutput.findings = findings;
|
|
355
|
+
else if (prevOutput.newFindings)
|
|
356
|
+
nextOutput.newFindings = findings;
|
|
357
|
+
outputs[outputs.length - 1] = nextOutput;
|
|
358
|
+
if (!findings.some(({ state }) => state === "accepted"))
|
|
359
|
+
outputs.push({ verdict: "APPROVED" });
|
|
360
|
+
return { ...reviewer, outputs };
|
|
361
|
+
}
|
|
362
|
+
async function notifyVerdictChanges(prev, next, reason) {
|
|
363
|
+
await Promise.all(Object.entries(next).map(async ([id, reviewer]) => {
|
|
364
|
+
const prevVerdict = prev[id]?.outputs?.at(-1)?.verdict;
|
|
365
|
+
const nextVerdict = reviewer.outputs?.at(-1)?.verdict;
|
|
366
|
+
if (!prevVerdict || !nextVerdict || prevVerdict === nextVerdict)
|
|
367
|
+
return;
|
|
368
|
+
await this.updateEvent(`Reviewer ${id} verdict changed from ${toTitleCase(prevVerdict.toLocaleLowerCase())} to ${toTitleCase(nextVerdict.toLocaleLowerCase())} ${reason}.`);
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
function validateInlineCommentTargets(status, output, inlineCommentTargets) {
|
|
372
|
+
const target = status === "initial" ? "findings" : "newFindings";
|
|
373
|
+
const findings = output[target] ?? [];
|
|
374
|
+
for (const [index, finding] of findings.entries()) {
|
|
375
|
+
const name = `${target}[${index}]`;
|
|
376
|
+
if (!Number.isInteger(finding.line) || finding.line < 1)
|
|
377
|
+
throw new Error(`${name}.line must be a positive integer.`);
|
|
378
|
+
if (finding.startLine != null) {
|
|
379
|
+
if (!Number.isInteger(finding.startLine) || finding.startLine < 1)
|
|
380
|
+
throw new Error(`${name}.startLine must be a positive integer.`);
|
|
381
|
+
if (finding.startLine > finding.line)
|
|
382
|
+
throw new Error(`${name}.startLine must be before or equal to ${name}.line.`);
|
|
383
|
+
}
|
|
384
|
+
const lines = inlineCommentTargets[finding.path];
|
|
385
|
+
if (!lines) {
|
|
386
|
+
throw new Error(`${name} targets ${finding.path}:${finding.line}, but path is not in the PR diff.`);
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
const startLine = finding.startLine ?? finding.line;
|
|
390
|
+
for (let line = startLine; line <= finding.line; line++)
|
|
391
|
+
if (!lines.includes(line))
|
|
392
|
+
throw new Error(`${name} targets ${finding.path}:${line}, but line is not in a right-side PR diff hunk.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function validateThreadTargets({ followUps, resolves }, threads) {
|
|
397
|
+
const targets = new Map(threads.flatMap(({ comments, id }) => comments.flatMap(({ databaseId }) => databaseId == null ? [] : [[databaseId, id]])));
|
|
398
|
+
const allowedTargets = [...targets.entries()]
|
|
399
|
+
.map(([commentId, threadId]) => `- ${commentId}:${threadId}`)
|
|
400
|
+
.join("\n") || "none";
|
|
401
|
+
if (followUps)
|
|
402
|
+
for (const [index, { commentId }] of followUps.entries())
|
|
403
|
+
if (!targets.has(commentId))
|
|
404
|
+
throw new Error(`followUps[${index}].commentId must target an unresolved thread owned by the reviewer.\n\nAllowed targets:\n${allowedTargets}`);
|
|
405
|
+
if (resolves)
|
|
406
|
+
for (const [index, { commentId, threadId }] of resolves.entries())
|
|
407
|
+
if (targets.get(commentId) !== threadId)
|
|
408
|
+
throw new Error(`resolves[${index}] must target an unresolved thread owned by the reviewer.\n\nAllowed targets:\n${allowedTargets}`);
|
|
409
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
export const triage = function (magi) {
|
|
3
|
+
return {
|
|
4
|
+
magi_triage: tool({
|
|
5
|
+
args: {
|
|
6
|
+
dryRun: tool.schema.boolean().optional(),
|
|
7
|
+
issues: tool.schema.string(),
|
|
8
|
+
},
|
|
9
|
+
description: "Triage one or more issues with configured Magi triage voters.",
|
|
10
|
+
async execute({ dryRun: _dryRun = false, issues: _issues }) {
|
|
11
|
+
const _config = await magi.getConfig({ creator: true, voters: true });
|
|
12
|
+
return "";
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { getConfig, validateConfig } from "@/config";
|
|
3
|
+
function createReport(errors) {
|
|
4
|
+
return [
|
|
5
|
+
`Magi config validation: ${errors.length ? "failed" : "passed"}`,
|
|
6
|
+
"",
|
|
7
|
+
"Errors:",
|
|
8
|
+
...(errors.length ? errors.map((message) => `- ${message}`) : ["- None"]),
|
|
9
|
+
].join("\n");
|
|
10
|
+
}
|
|
11
|
+
export const validate = function (magi) {
|
|
12
|
+
return {
|
|
13
|
+
magi_validate: tool({
|
|
14
|
+
args: {},
|
|
15
|
+
description: "Validate global and project Magi config presence, merged settings, reviewer rules, model IDs, and account authentication.",
|
|
16
|
+
async execute() {
|
|
17
|
+
try {
|
|
18
|
+
const config = await getConfig(magi.input);
|
|
19
|
+
const errors = await validateConfig(config, { exec: magi.exec });
|
|
20
|
+
return createReport(errors);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return createReport([e.message]);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function isNumber(value) {
|
|
2
|
+
return typeof value === "number";
|
|
3
|
+
}
|
|
4
|
+
export function isString(value) {
|
|
5
|
+
return Object.prototype.toString.call(value) === "[object String]";
|
|
6
|
+
}
|
|
7
|
+
export function isBoolean(value) {
|
|
8
|
+
return typeof value === "boolean";
|
|
9
|
+
}
|
|
10
|
+
export function isUndefined(value) {
|
|
11
|
+
return typeof value === "undefined";
|
|
12
|
+
}
|
|
13
|
+
export function isNull(value) {
|
|
14
|
+
return value === null;
|
|
15
|
+
}
|
|
16
|
+
export function isRegExp(value) {
|
|
17
|
+
return value instanceof RegExp;
|
|
18
|
+
}
|
|
19
|
+
export function isObject(value) {
|
|
20
|
+
return (value != null &&
|
|
21
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
22
|
+
!isArray(value));
|
|
23
|
+
}
|
|
24
|
+
export function isDate(value) {
|
|
25
|
+
return value instanceof Date;
|
|
26
|
+
}
|
|
27
|
+
export function isArray(value) {
|
|
28
|
+
return Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
export function isEmpty(value) {
|
|
31
|
+
return !isArray(value) || !value.length || value.every((v) => v == null);
|
|
32
|
+
}
|
|
33
|
+
export function isEmptyObject(value) {
|
|
34
|
+
return isObject(value) && !Object.keys(value).length;
|
|
35
|
+
}
|
|
36
|
+
export function isFunction(value) {
|
|
37
|
+
return typeof value === "function";
|
|
38
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
export const execAsync = promisify(exec);
|
|
4
|
+
export function createExec(defaultCwd) {
|
|
5
|
+
return async function (command, { cwd, env, signal } = {}) {
|
|
6
|
+
const { stdout } = await execAsync(command, {
|
|
7
|
+
cwd: cwd ?? defaultCwd,
|
|
8
|
+
env: { ...process.env, ...env },
|
|
9
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
10
|
+
signal,
|
|
11
|
+
});
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
};
|
|
14
|
+
}
|
package/dist/utils/fs.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { rm as originalRm, readdir, rmdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { isString } from "./assertion";
|
|
4
|
+
export async function rm(path, { prune, ...options } = {}) {
|
|
5
|
+
await originalRm(path, options);
|
|
6
|
+
if (!prune)
|
|
7
|
+
return;
|
|
8
|
+
const root = resolve(isString(prune) ? prune : process.cwd());
|
|
9
|
+
let dir = dirname(resolve(path));
|
|
10
|
+
while (dir !== root) {
|
|
11
|
+
const value = relative(root, dir);
|
|
12
|
+
if (!value || value.startsWith("..") || isAbsolute(value))
|
|
13
|
+
break;
|
|
14
|
+
try {
|
|
15
|
+
if ((await readdir(dir)).length)
|
|
16
|
+
break;
|
|
17
|
+
await rmdir(dir);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
dir = dirname(dir);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function wait(ms = 0, signal) {
|
|
2
|
+
signal?.throwIfAborted();
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const abort = () => {
|
|
5
|
+
clearTimeout(timeout);
|
|
6
|
+
reject(signal?.reason);
|
|
7
|
+
};
|
|
8
|
+
const timeout = setTimeout(() => {
|
|
9
|
+
signal?.removeEventListener("abort", abort);
|
|
10
|
+
resolve();
|
|
11
|
+
}, ms);
|
|
12
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function loop(cb, ms = 0) {
|
|
16
|
+
for (;;) {
|
|
17
|
+
const value = await cb();
|
|
18
|
+
if (value != null)
|
|
19
|
+
return value;
|
|
20
|
+
await wait(ms);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function retry(cb, { error: errorCb, retries = 1, signal }) {
|
|
24
|
+
let count = 1;
|
|
25
|
+
let error;
|
|
26
|
+
while (count <= retries)
|
|
27
|
+
try {
|
|
28
|
+
signal?.throwIfAborted();
|
|
29
|
+
return await cb(count, error);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
await errorCb?.(e, count);
|
|
34
|
+
error = e;
|
|
35
|
+
count += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { filterEmpty } from "./array";
|
|
2
|
+
import { isObject } from "./assertion";
|
|
3
|
+
export function parseIssues(value) {
|
|
4
|
+
const numbers = filterEmpty(value
|
|
5
|
+
.split(/[\s,]+/)
|
|
6
|
+
.map((item) => item.match(/(?:issues\/|#)?(\d+)$/)?.[1])).map(Number);
|
|
7
|
+
if (!numbers.length)
|
|
8
|
+
throw new Error("Specify one or more issue numbers or issue URLs.");
|
|
9
|
+
return numbers;
|
|
10
|
+
}
|
|
11
|
+
export function parsePrs(value) {
|
|
12
|
+
let options = false;
|
|
13
|
+
const numbers = filterEmpty(value.split(/[\s,]+/).map((item) => {
|
|
14
|
+
if (item.startsWith("--"))
|
|
15
|
+
options = true;
|
|
16
|
+
if (options)
|
|
17
|
+
return;
|
|
18
|
+
return item.match(/(?:pull\/|#)?(\d+)$/)?.[1];
|
|
19
|
+
})).map(Number);
|
|
20
|
+
if (!numbers.length)
|
|
21
|
+
throw new Error("Specify one or more PR numbers or PR URLs.");
|
|
22
|
+
return numbers;
|
|
23
|
+
}
|
|
24
|
+
function getErrorMessage(e) {
|
|
25
|
+
if (!e || !isObject(e))
|
|
26
|
+
return String(e);
|
|
27
|
+
const value = e;
|
|
28
|
+
return [value.message, value.stderr, value.stdout]
|
|
29
|
+
.filter((item) => typeof item === "string")
|
|
30
|
+
.join("\n");
|
|
31
|
+
}
|
|
32
|
+
function isGitHubCommand(command) {
|
|
33
|
+
return /(^|\s)gh\s+(api|auth|pr|run)\b/.test(command);
|
|
34
|
+
}
|
|
35
|
+
function isRateLimitError(e) {
|
|
36
|
+
return /rate limit/i.test(getErrorMessage(e));
|
|
37
|
+
}
|
|
38
|
+
export function createExecWithGitHubApiRetry(exec, retryAttempts, delay = 1_000) {
|
|
39
|
+
return async (command, options) => {
|
|
40
|
+
for (let attempt = 0;; attempt += 1)
|
|
41
|
+
try {
|
|
42
|
+
return await exec(command, options);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (attempt >= retryAttempts ||
|
|
46
|
+
!isGitHubCommand(command) ||
|
|
47
|
+
!isRateLimitError(e))
|
|
48
|
+
throw e;
|
|
49
|
+
const ms = delay * 2 ** attempt;
|
|
50
|
+
if (0 < ms) {
|
|
51
|
+
if (options?.signal?.aborted)
|
|
52
|
+
throw new DOMException("Aborted", "AbortError");
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const timeout = setTimeout(resolve, ms);
|
|
55
|
+
options?.signal?.addEventListener("abort", () => {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
58
|
+
}, { once: true });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from "./array";
|
|
2
|
+
export * from "./assertion";
|
|
3
|
+
export * from "./exec";
|
|
4
|
+
export * from "./fs";
|
|
5
|
+
export * from "./function";
|
|
6
|
+
export * from "./github";
|
|
7
|
+
export * from "./object";
|
|
8
|
+
export * from "./opencode";
|
|
9
|
+
export * from "./string";
|
|
10
|
+
export * from "./worker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|