opencode-magi 0.10.0 → 0.12.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.
Files changed (126) hide show
  1. package/README.ja.md +254 -0
  2. package/README.md +31 -26
  3. package/dist/commands.js +11 -9
  4. package/dist/config/index.js +2 -0
  5. package/dist/config/resolve.js +120 -246
  6. package/dist/config/validate.js +98 -1110
  7. package/dist/constant.js +131 -0
  8. package/dist/graphql/index.generated.js +125 -0
  9. package/dist/graphql/index.js +49 -0
  10. package/dist/index.js +12 -800
  11. package/dist/magi.js +357 -0
  12. package/dist/permissions/common.json +22 -22
  13. package/dist/permissions/editor.json +12 -12
  14. package/dist/prompts/index.js +98 -0
  15. package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
  16. package/dist/prompts/merge/ci-classification/task.md +10 -0
  17. package/dist/prompts/merge/ci-classification/validate.json +20 -0
  18. package/dist/prompts/merge/conflict/output-contract.md +12 -0
  19. package/dist/prompts/merge/conflict/task.md +9 -0
  20. package/dist/prompts/merge/conflict/validate.json +4 -0
  21. package/dist/prompts/merge/edit/output-contract.md +26 -0
  22. package/dist/prompts/merge/edit/task.md +10 -0
  23. package/dist/prompts/merge/edit/validate.json +60 -0
  24. package/dist/prompts/review/ci-classification/output-contract.md +21 -0
  25. package/dist/prompts/review/ci-classification/task.md +7 -0
  26. package/dist/prompts/review/ci-classification/validate.json +20 -0
  27. package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
  28. package/dist/prompts/review/close-reconsideration/task.md +6 -0
  29. package/dist/prompts/review/close-reconsideration/validate.json +44 -0
  30. package/dist/prompts/review/comment/output-contract.md +11 -0
  31. package/dist/prompts/review/comment/task.md +3 -0
  32. package/dist/prompts/review/comment/validate.json +8 -0
  33. package/dist/prompts/review/finding-validation/output-contract.md +25 -0
  34. package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
  35. package/dist/prompts/review/finding-validation/validate.json +21 -0
  36. package/dist/prompts/review/rereview/output-contract.md +30 -0
  37. package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
  38. package/dist/prompts/review/rereview/validate.json +87 -0
  39. package/dist/prompts/review/review/output-contract.md +25 -0
  40. package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
  41. package/dist/prompts/review/review/validate.json +54 -0
  42. package/dist/prompts/triage/acceptance/output-contract.md +16 -0
  43. package/dist/prompts/triage/acceptance/validate.json +23 -0
  44. package/dist/prompts/triage/category/output-contract.md +16 -0
  45. package/dist/prompts/triage/category/validate.json +23 -0
  46. package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
  47. package/dist/prompts/triage/comment-classification/validate.json +28 -0
  48. package/dist/prompts/triage/create/output-contract.md +28 -0
  49. package/dist/prompts/triage/create/validate.json +73 -0
  50. package/dist/prompts/triage/deplicate/output-contract.md +16 -0
  51. package/dist/prompts/triage/deplicate/validate.json +20 -0
  52. package/dist/prompts/triage/existing/output-contract.md +16 -0
  53. package/dist/prompts/triage/existing/validate.json +13 -0
  54. package/dist/prompts/triage/reconsider/output-contract.md +16 -0
  55. package/dist/prompts/triage/reconsider/validate.json +23 -0
  56. package/dist/prompts/triage/signal/output-contract.md +19 -0
  57. package/dist/prompts/triage/signal/validate.json +18 -0
  58. package/dist/tools/clear/index.js +21 -0
  59. package/dist/tools/index.js +12 -0
  60. package/dist/tools/merge/action.js +47 -0
  61. package/dist/tools/merge/context.js +116 -0
  62. package/dist/tools/merge/editor.js +256 -0
  63. package/dist/tools/merge/index.js +151 -0
  64. package/dist/tools/merge/index.type.js +1 -0
  65. package/dist/tools/merge/merge.js +42 -0
  66. package/dist/tools/merge/report.js +73 -0
  67. package/dist/tools/review/action.js +459 -0
  68. package/dist/tools/review/check.js +335 -0
  69. package/dist/tools/review/context.js +254 -0
  70. package/dist/tools/review/index.js +104 -0
  71. package/dist/tools/review/index.type.js +1 -0
  72. package/dist/tools/review/report.js +143 -0
  73. package/dist/tools/review/review.js +146 -0
  74. package/dist/tools/review/reviewer.js +409 -0
  75. package/dist/tools/triage/index.js +16 -0
  76. package/dist/tools/validate/index.js +28 -0
  77. package/dist/utils/array.js +6 -0
  78. package/dist/utils/assertion.js +38 -0
  79. package/dist/utils/exec.js +14 -0
  80. package/dist/utils/fs.js +24 -0
  81. package/dist/utils/function.js +46 -0
  82. package/dist/utils/github.js +63 -0
  83. package/dist/utils/index.js +10 -0
  84. package/dist/utils/index.type.js +1 -0
  85. package/dist/utils/object.js +21 -0
  86. package/dist/utils/opencode.js +25 -0
  87. package/dist/utils/string.js +38 -0
  88. package/dist/utils/worker.js +30 -0
  89. package/package.json +36 -23
  90. package/schema.json +116 -20
  91. package/dist/config/load.js +0 -62
  92. package/dist/config/output.js +0 -25
  93. package/dist/config/worktree.js +0 -25
  94. package/dist/github/commands.js +0 -905
  95. package/dist/github/retry.js +0 -44
  96. package/dist/orchestrator/abort.js +0 -9
  97. package/dist/orchestrator/ci.js +0 -579
  98. package/dist/orchestrator/findings.js +0 -88
  99. package/dist/orchestrator/inline-comments.js +0 -73
  100. package/dist/orchestrator/majority.js +0 -62
  101. package/dist/orchestrator/merge.js +0 -1260
  102. package/dist/orchestrator/model.js +0 -216
  103. package/dist/orchestrator/pool.js +0 -15
  104. package/dist/orchestrator/report.js +0 -175
  105. package/dist/orchestrator/review-context.js +0 -342
  106. package/dist/orchestrator/review.js +0 -1480
  107. package/dist/orchestrator/run-manager.js +0 -2132
  108. package/dist/orchestrator/safety.js +0 -44
  109. package/dist/orchestrator/triage.js +0 -1370
  110. package/dist/prompts/compose.js +0 -473
  111. package/dist/prompts/contracts.js +0 -300
  112. package/dist/prompts/output.js +0 -401
  113. package/dist/prompts/templates/merge/ci-classification.md +0 -16
  114. package/dist/prompts/templates/merge/conflict.md +0 -10
  115. package/dist/prompts/templates/merge/edit.md +0 -15
  116. package/dist/prompts/templates/review/ci-classification.md +0 -9
  117. package/dist/prompts/templates/review/close-reconsideration.md +0 -6
  118. /package/dist/{types.js → config/index.type.js} +0 -0
  119. /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
  120. /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
  121. /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
  122. /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
  123. /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
  124. /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
  125. /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
  126. /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,6 @@
1
+ export function filterDuplicates(value) {
2
+ return [...new Set(value)];
3
+ }
4
+ export function filterEmpty(array) {
5
+ return array.filter((value) => value != null);
6
+ }
@@ -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
+ }
@@ -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,46 @@
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 ignoreError(cb, shouldIgnore) {
16
+ try {
17
+ return await cb();
18
+ }
19
+ catch (e) {
20
+ if (!shouldIgnore(e))
21
+ throw e;
22
+ }
23
+ }
24
+ export async function loop(cb, ms = 0) {
25
+ for (;;) {
26
+ const value = await cb();
27
+ if (value != null)
28
+ return value;
29
+ await wait(ms);
30
+ }
31
+ }
32
+ export async function retry(cb, { error: errorCb, retries = 1, signal }) {
33
+ let count = 1;
34
+ let error;
35
+ while (count <= retries)
36
+ try {
37
+ signal?.throwIfAborted();
38
+ return await cb(count, error);
39
+ }
40
+ catch (e) {
41
+ signal?.throwIfAborted();
42
+ await errorCb?.(e, count);
43
+ error = e;
44
+ count += 1;
45
+ }
46
+ }
@@ -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 {};