opencode-magi 0.1.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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/commands.js +18 -0
- package/dist/config/load.js +62 -0
- package/dist/config/output.js +16 -0
- package/dist/config/resolve.js +113 -0
- package/dist/config/validate.js +567 -0
- package/dist/github/commands.js +398 -0
- package/dist/github/retry.js +44 -0
- package/dist/index.js +536 -0
- package/dist/orchestrator/abort.js +9 -0
- package/dist/orchestrator/ci.js +568 -0
- package/dist/orchestrator/findings.js +66 -0
- package/dist/orchestrator/majority.js +48 -0
- package/dist/orchestrator/merge.js +836 -0
- package/dist/orchestrator/model.js +202 -0
- package/dist/orchestrator/pool.js +15 -0
- package/dist/orchestrator/report.js +168 -0
- package/dist/orchestrator/review.js +790 -0
- package/dist/orchestrator/run-manager.js +1663 -0
- package/dist/orchestrator/safety.js +44 -0
- package/dist/permissions/common.json +24 -0
- package/dist/permissions/editor.json +7 -0
- package/dist/prompts/compose.js +298 -0
- package/dist/prompts/contracts.js +189 -0
- package/dist/prompts/output.js +260 -0
- package/dist/prompts/templates/ci-classification-after-edit.md +16 -0
- package/dist/prompts/templates/ci-classification.md +9 -0
- package/dist/prompts/templates/close-reconsideration.md +6 -0
- package/dist/prompts/templates/edit.md +9 -0
- package/dist/prompts/templates/finding-validation.md +7 -0
- package/dist/prompts/templates/rereview-close-reconsideration.md +6 -0
- package/dist/prompts/templates/rereview.md +16 -0
- package/dist/prompts/templates/review.md +7 -0
- package/dist/types.js +1 -0
- package/package.json +69 -0
- package/schema.json +200 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, } from "../github/commands";
|
|
4
|
+
import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
|
|
5
|
+
import { prRunOutputDir } from "../config/output";
|
|
6
|
+
import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
|
|
7
|
+
import { throwIfAborted, withAbortSignal } from "./abort";
|
|
8
|
+
import { waitForChecksWithClassification } from "./ci";
|
|
9
|
+
import { applyFindingValidation, reviewFindingTargets, validateFindingVotes, } from "./findings";
|
|
10
|
+
import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
|
|
11
|
+
import { runModelWithRepair } from "./model";
|
|
12
|
+
import { mapPool } from "./pool";
|
|
13
|
+
import { formatReviewReport } from "./report";
|
|
14
|
+
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
15
|
+
function errorMessage(error) {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
async function withReviewerFailureProgress(input) {
|
|
19
|
+
try {
|
|
20
|
+
return await input.run();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
await input.onProgress?.({
|
|
24
|
+
error: errorMessage(error),
|
|
25
|
+
reviewer: input.reviewer,
|
|
26
|
+
type: "reviewer_failed",
|
|
27
|
+
});
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function postReviewOutput(input, reviewerKey, output) {
|
|
32
|
+
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
33
|
+
if (!reviewer)
|
|
34
|
+
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
35
|
+
if (output.verdict === "MERGE")
|
|
36
|
+
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
37
|
+
if (output.verdict === "CLOSE")
|
|
38
|
+
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
39
|
+
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
|
|
40
|
+
}
|
|
41
|
+
function dryRunReviewPost(key, output) {
|
|
42
|
+
if (output.verdict === "MERGE")
|
|
43
|
+
return `dry-run:would-approve:${key}`;
|
|
44
|
+
if (output.verdict === "CLOSE")
|
|
45
|
+
return `dry-run:would-comment-close:${key}`;
|
|
46
|
+
return `dry-run:would-request-changes:${key}`;
|
|
47
|
+
}
|
|
48
|
+
function latestReviewsByAccount(reviews, accounts) {
|
|
49
|
+
const accountSet = new Set(accounts);
|
|
50
|
+
const latest = new Map();
|
|
51
|
+
for (const review of reviews) {
|
|
52
|
+
if (!accountSet.has(review.author.login))
|
|
53
|
+
continue;
|
|
54
|
+
if (review.state === "DISMISSED")
|
|
55
|
+
continue;
|
|
56
|
+
const current = latest.get(review.author.login);
|
|
57
|
+
if (!current || current.submittedAt.localeCompare(review.submittedAt) < 0)
|
|
58
|
+
latest.set(review.author.login, review);
|
|
59
|
+
}
|
|
60
|
+
return latest;
|
|
61
|
+
}
|
|
62
|
+
export function resolveReviewMode(reviews, accounts, current, accountsWithPendingThreadReplies = new Set()) {
|
|
63
|
+
const latest = latestReviewsByAccount(reviews, accounts);
|
|
64
|
+
const reviewedHead = accounts.every((account) => {
|
|
65
|
+
return (isReviewCurrent(latest.get(account), current) &&
|
|
66
|
+
!accountsWithPendingThreadReplies.has(account));
|
|
67
|
+
});
|
|
68
|
+
const assignments = new Map();
|
|
69
|
+
for (const account of accounts) {
|
|
70
|
+
const review = latest.get(account);
|
|
71
|
+
if (!review) {
|
|
72
|
+
assignments.set(account, { type: "initial" });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (isReviewCurrent(review, current) &&
|
|
76
|
+
!accountsWithPendingThreadReplies.has(account)) {
|
|
77
|
+
assignments.set(account, { review, type: "skip" });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
assignments.set(account, { review, type: "rereview" });
|
|
81
|
+
}
|
|
82
|
+
if (latest.size && reviewedHead)
|
|
83
|
+
return { assignments, type: "already_reviewed" };
|
|
84
|
+
return { assignments, type: "active" };
|
|
85
|
+
}
|
|
86
|
+
export function reviewFreshnessTarget(commits, headSha) {
|
|
87
|
+
const latestNonMerge = [...commits]
|
|
88
|
+
.reverse()
|
|
89
|
+
.find((commit) => commit.parentCount < 2);
|
|
90
|
+
return latestNonMerge
|
|
91
|
+
? {
|
|
92
|
+
committedAt: latestNonMerge.committedDate,
|
|
93
|
+
fallbackHeadSha: headSha,
|
|
94
|
+
type: "timestamp",
|
|
95
|
+
}
|
|
96
|
+
: { headSha, type: "head" };
|
|
97
|
+
}
|
|
98
|
+
function isReviewCurrent(review, current) {
|
|
99
|
+
if (!review)
|
|
100
|
+
return false;
|
|
101
|
+
if (current.type === "head")
|
|
102
|
+
return review.commit?.oid === current.headSha;
|
|
103
|
+
if (review.submittedAt.localeCompare(current.committedAt) >= 0)
|
|
104
|
+
return true;
|
|
105
|
+
return review.commit?.oid === current.fallbackHeadSha;
|
|
106
|
+
}
|
|
107
|
+
function reviewStateToVerdict(state) {
|
|
108
|
+
if (state === "APPROVED")
|
|
109
|
+
return "MERGE";
|
|
110
|
+
if (state === "CHANGES_REQUESTED")
|
|
111
|
+
return "CHANGES_REQUESTED";
|
|
112
|
+
return "CLOSE";
|
|
113
|
+
}
|
|
114
|
+
function previousReviewText(review) {
|
|
115
|
+
return JSON.stringify({
|
|
116
|
+
body: review.body ?? "",
|
|
117
|
+
commit: review.commit?.oid,
|
|
118
|
+
state: review.state,
|
|
119
|
+
submittedAt: review.submittedAt,
|
|
120
|
+
}, null, 2);
|
|
121
|
+
}
|
|
122
|
+
function reviewOutputFromState(review) {
|
|
123
|
+
const verdict = reviewStateToVerdict(review.state);
|
|
124
|
+
return verdict === "CLOSE"
|
|
125
|
+
? { findings: [], reason: review.body || "Close requested.", verdict }
|
|
126
|
+
: { findings: [], verdict };
|
|
127
|
+
}
|
|
128
|
+
export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
129
|
+
return threads.some((thread) => {
|
|
130
|
+
const comments = [...thread.comments].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
131
|
+
const latestReviewerComment = comments
|
|
132
|
+
.filter((comment) => comment.author === reviewerAccount)
|
|
133
|
+
.at(-1);
|
|
134
|
+
if (!latestReviewerComment)
|
|
135
|
+
return false;
|
|
136
|
+
return comments.some((comment) => comment.author !== reviewerAccount &&
|
|
137
|
+
comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function postRereviewOutput(input, reviewerKey, output) {
|
|
141
|
+
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
142
|
+
if (!reviewer)
|
|
143
|
+
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
144
|
+
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
|
|
145
|
+
await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
|
|
146
|
+
if (output.verdict === "MERGE")
|
|
147
|
+
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
148
|
+
if (output.verdict === "CLOSE")
|
|
149
|
+
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
150
|
+
if (!output.newFindings.length)
|
|
151
|
+
return "";
|
|
152
|
+
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
153
|
+
fix: "Please address this before merging.",
|
|
154
|
+
issue: finding.body,
|
|
155
|
+
line: finding.line,
|
|
156
|
+
path: finding.path,
|
|
157
|
+
startLine: finding.startLine,
|
|
158
|
+
})));
|
|
159
|
+
}
|
|
160
|
+
function isReviewOutput(output) {
|
|
161
|
+
return "findings" in output;
|
|
162
|
+
}
|
|
163
|
+
async function runFindingValidation(input) {
|
|
164
|
+
const reviewOutputs = Object.fromEntries(input.entries.flatMap((entry) => isReviewOutput(entry.value) ? [[entry.key, entry.value]] : []));
|
|
165
|
+
const targets = reviewFindingTargets(reviewOutputs);
|
|
166
|
+
if (!targets.length) {
|
|
167
|
+
return {
|
|
168
|
+
outputs: Object.fromEntries(input.entries.map((entry) => [entry.key, entry.value])),
|
|
169
|
+
summary: { discarded: [], kept: [] },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
await input.reviewInput.onProgress?.({
|
|
173
|
+
phase: "validating review findings",
|
|
174
|
+
type: "phase",
|
|
175
|
+
});
|
|
176
|
+
const validations = Object.fromEntries(await mapPool(input.reviewInput.repository.agents.reviewers, input.reviewInput.repository.concurrency.reviewers, async (reviewer) => {
|
|
177
|
+
const reviewerTargets = targets.filter((target) => target.reviewer !== reviewer.key);
|
|
178
|
+
if (!reviewerTargets.length)
|
|
179
|
+
return [reviewer.key, { votes: [] }];
|
|
180
|
+
const hasReviewerSession = Boolean(input.sessionIds[reviewer.key]);
|
|
181
|
+
const prompt = await composeFindingValidationPrompt({
|
|
182
|
+
baseSha: input.meta.baseRefOid,
|
|
183
|
+
directory: input.reviewInput.directory,
|
|
184
|
+
findings: JSON.stringify(reviewerTargets, null, 2),
|
|
185
|
+
headSha: input.meta.headRefOid,
|
|
186
|
+
includeReviewGuidelines: !hasReviewerSession,
|
|
187
|
+
includeSessionContext: !hasReviewerSession,
|
|
188
|
+
pr: input.reviewInput.pr,
|
|
189
|
+
repository: input.reviewInput.repository,
|
|
190
|
+
reviewer,
|
|
191
|
+
worktreePath: input.worktreePath,
|
|
192
|
+
});
|
|
193
|
+
const result = await withReviewerFailureProgress({
|
|
194
|
+
onProgress: input.reviewInput.onProgress,
|
|
195
|
+
reviewer: reviewer.key,
|
|
196
|
+
run: () => runModelWithRepair({
|
|
197
|
+
client: input.reviewInput.client,
|
|
198
|
+
model: reviewer.model,
|
|
199
|
+
onProgress: async (progress) => {
|
|
200
|
+
if (progress.type === "session_created") {
|
|
201
|
+
await input.reviewInput.onProgress?.({
|
|
202
|
+
reviewer: reviewer.key,
|
|
203
|
+
options: progress.options,
|
|
204
|
+
sessionId: progress.sessionId,
|
|
205
|
+
type: "reviewer_session",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (progress.type === "repair") {
|
|
209
|
+
await input.reviewInput.onProgress?.({
|
|
210
|
+
reviewer: reviewer.key,
|
|
211
|
+
type: "reviewer_repair",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (progress.type === "response") {
|
|
215
|
+
await input.reviewInput.onProgress?.({
|
|
216
|
+
reviewer: reviewer.key,
|
|
217
|
+
sessionId: progress.sessionId,
|
|
218
|
+
type: "reviewer_response",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
options: reviewer.options,
|
|
223
|
+
parse: (text) => {
|
|
224
|
+
const output = parseFindingValidationOutput(text);
|
|
225
|
+
validateFindingVotes({
|
|
226
|
+
output,
|
|
227
|
+
targets,
|
|
228
|
+
validator: reviewer.key,
|
|
229
|
+
});
|
|
230
|
+
return output;
|
|
231
|
+
},
|
|
232
|
+
permission: reviewer.permission,
|
|
233
|
+
prompt,
|
|
234
|
+
repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
|
|
235
|
+
schemaName: "finding validation",
|
|
236
|
+
sessionId: input.sessionIds[reviewer.key],
|
|
237
|
+
signal: input.reviewInput.signal,
|
|
238
|
+
title: `magi validate findings ${input.reviewInput.repository.alias}#${input.reviewInput.pr} ${reviewer.key}`,
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.prompt.txt`), prompt);
|
|
242
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.raw.txt`), result.raw);
|
|
243
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.json`), JSON.stringify(result.value, null, 2));
|
|
244
|
+
input.sessionIds[reviewer.key] = result.sessionId;
|
|
245
|
+
return [reviewer.key, result.value];
|
|
246
|
+
}, { signal: input.reviewInput.signal }));
|
|
247
|
+
const filtered = applyFindingValidation({
|
|
248
|
+
outputs: reviewOutputs,
|
|
249
|
+
reviewerKeys: input.reviewInput.repository.agents.reviewers.map((reviewer) => reviewer.key),
|
|
250
|
+
validations,
|
|
251
|
+
});
|
|
252
|
+
await writeFile(join(input.outputDir, "finding-validation.json"), JSON.stringify({ validations, ...filtered.summary }, null, 2));
|
|
253
|
+
await input.reviewInput.onProgress?.({
|
|
254
|
+
discarded: filtered.summary.discarded.length,
|
|
255
|
+
kept: filtered.summary.kept.length,
|
|
256
|
+
reviewersChangedToMerge: Object.entries(reviewOutputs)
|
|
257
|
+
.filter(([reviewer, output]) => {
|
|
258
|
+
return (output.verdict === "CHANGES_REQUESTED" &&
|
|
259
|
+
filtered.outputs[reviewer]?.verdict === "MERGE");
|
|
260
|
+
})
|
|
261
|
+
.map(([reviewer]) => reviewer),
|
|
262
|
+
type: "findings_validated",
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
outputs: Object.fromEntries(input.entries.map((entry) => [
|
|
266
|
+
entry.key,
|
|
267
|
+
filtered.outputs[entry.key] ?? entry.value,
|
|
268
|
+
])),
|
|
269
|
+
summary: filtered.summary,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async function runCloseReconsideration(input) {
|
|
273
|
+
const targets = input.targets ??
|
|
274
|
+
closeMinorityReviewers(input.entries.map((entry) => ({
|
|
275
|
+
reviewer: entry.key,
|
|
276
|
+
verdict: entry.value.verdict,
|
|
277
|
+
})));
|
|
278
|
+
if (!targets.length)
|
|
279
|
+
return input.entries;
|
|
280
|
+
await input.reviewInput.onProgress?.({
|
|
281
|
+
phase: "reconsidering close verdicts",
|
|
282
|
+
type: "phase",
|
|
283
|
+
});
|
|
284
|
+
return Promise.all(input.entries.map(async (entry) => {
|
|
285
|
+
if (!targets.includes(entry.key) || !isReviewOutput(entry.value)) {
|
|
286
|
+
return entry;
|
|
287
|
+
}
|
|
288
|
+
const reviewer = input.reviewInput.repository.agents.reviewers.find((item) => item.key === entry.key);
|
|
289
|
+
if (!reviewer)
|
|
290
|
+
return entry;
|
|
291
|
+
const hasReviewerSession = Boolean(input.sessionIds[reviewer.key]);
|
|
292
|
+
const prompt = await composeCloseReconsiderationPrompt({
|
|
293
|
+
baseSha: input.meta.baseRefOid,
|
|
294
|
+
ciFailureContext: undefined,
|
|
295
|
+
closeReason: entry.value.reason,
|
|
296
|
+
directory: input.reviewInput.directory,
|
|
297
|
+
headSha: input.meta.headRefOid,
|
|
298
|
+
includeReviewGuidelines: !hasReviewerSession,
|
|
299
|
+
includeSessionContext: !hasReviewerSession,
|
|
300
|
+
pr: input.reviewInput.pr,
|
|
301
|
+
repository: input.reviewInput.repository,
|
|
302
|
+
reviewer,
|
|
303
|
+
worktreePath: input.worktreePath,
|
|
304
|
+
});
|
|
305
|
+
const result = await withReviewerFailureProgress({
|
|
306
|
+
onProgress: input.reviewInput.onProgress,
|
|
307
|
+
reviewer: reviewer.key,
|
|
308
|
+
run: () => runModelWithRepair({
|
|
309
|
+
client: input.reviewInput.client,
|
|
310
|
+
model: reviewer.model,
|
|
311
|
+
onProgress: async (progress) => {
|
|
312
|
+
if (progress.type === "session_created") {
|
|
313
|
+
await input.reviewInput.onProgress?.({
|
|
314
|
+
reviewer: reviewer.key,
|
|
315
|
+
options: progress.options,
|
|
316
|
+
sessionId: progress.sessionId,
|
|
317
|
+
type: "reviewer_session",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (progress.type === "repair") {
|
|
321
|
+
await input.reviewInput.onProgress?.({
|
|
322
|
+
reviewer: reviewer.key,
|
|
323
|
+
type: "reviewer_repair",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (progress.type === "response") {
|
|
327
|
+
await input.reviewInput.onProgress?.({
|
|
328
|
+
reviewer: reviewer.key,
|
|
329
|
+
sessionId: progress.sessionId,
|
|
330
|
+
type: "reviewer_response",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
options: reviewer.options,
|
|
335
|
+
parse: parseCloseReconsiderationOutput,
|
|
336
|
+
permission: reviewer.permission,
|
|
337
|
+
prompt,
|
|
338
|
+
repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
|
|
339
|
+
schemaName: "close reconsideration",
|
|
340
|
+
sessionId: input.sessionIds[reviewer.key],
|
|
341
|
+
signal: input.reviewInput.signal,
|
|
342
|
+
title: `magi reconsider close ${input.reviewInput.repository.alias}#${input.reviewInput.pr} ${reviewer.key}`,
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.prompt.txt`), prompt);
|
|
346
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.raw.txt`), result.raw);
|
|
347
|
+
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.json`), JSON.stringify(result.value, null, 2));
|
|
348
|
+
await input.reviewInput.onProgress?.({
|
|
349
|
+
from: "CLOSE",
|
|
350
|
+
reviewer: reviewer.key,
|
|
351
|
+
to: result.value.verdict,
|
|
352
|
+
type: "reviewer_reconsidered",
|
|
353
|
+
});
|
|
354
|
+
await input.reviewInput.onProgress?.({
|
|
355
|
+
reviewer: reviewer.key,
|
|
356
|
+
sessionId: result.sessionId,
|
|
357
|
+
type: "reviewer_completed",
|
|
358
|
+
verdict: result.value.verdict,
|
|
359
|
+
});
|
|
360
|
+
input.sessionIds[reviewer.key] = result.sessionId;
|
|
361
|
+
return {
|
|
362
|
+
key: entry.key,
|
|
363
|
+
raw: result.raw,
|
|
364
|
+
sessionId: result.sessionId,
|
|
365
|
+
value: result.value,
|
|
366
|
+
};
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
export async function runReview(input) {
|
|
370
|
+
const exec = withAbortSignal(input.exec, input.signal);
|
|
371
|
+
throwIfAborted(input.signal);
|
|
372
|
+
await input.onProgress?.({ phase: "fetching PR metadata", type: "phase" });
|
|
373
|
+
const meta = await fetchPullRequest(exec, input.repository, input.pr);
|
|
374
|
+
if (meta.isDraft)
|
|
375
|
+
throw new Error(`PR #${input.pr} is a draft`);
|
|
376
|
+
if (!input.skipSafety && hasSafetyGate(input.repository)) {
|
|
377
|
+
await input.onProgress?.({ phase: "checking safety", type: "phase" });
|
|
378
|
+
const safety = await checkSafetyGate({
|
|
379
|
+
exec,
|
|
380
|
+
pr: input.pr,
|
|
381
|
+
repository: input.repository,
|
|
382
|
+
});
|
|
383
|
+
if (!safety.ok) {
|
|
384
|
+
const outputDir = prRunOutputDir({
|
|
385
|
+
config: input.config,
|
|
386
|
+
directory: input.directory,
|
|
387
|
+
pr: input.pr,
|
|
388
|
+
runId: input.runId,
|
|
389
|
+
});
|
|
390
|
+
await mkdir(outputDir, { recursive: true });
|
|
391
|
+
const report = formatReviewReport({
|
|
392
|
+
ciReports: [],
|
|
393
|
+
dryRun: input.dryRun,
|
|
394
|
+
outputs: {},
|
|
395
|
+
posted: {},
|
|
396
|
+
repository: input.repository,
|
|
397
|
+
safety,
|
|
398
|
+
});
|
|
399
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
400
|
+
await input.onProgress?.({ type: "completed", verdict: "SAFETY_BLOCKED" });
|
|
401
|
+
return {
|
|
402
|
+
baseSha: meta.baseRefOid,
|
|
403
|
+
ciReports: [],
|
|
404
|
+
discardedFindings: [],
|
|
405
|
+
headSha: meta.headRefOid,
|
|
406
|
+
outputs: {},
|
|
407
|
+
posted: {},
|
|
408
|
+
pr: input.pr,
|
|
409
|
+
report,
|
|
410
|
+
sessionIds: {},
|
|
411
|
+
verdict: "SAFETY_BLOCKED",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
await input.onProgress?.({
|
|
416
|
+
phase: "fetching existing reviews",
|
|
417
|
+
type: "phase",
|
|
418
|
+
});
|
|
419
|
+
const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
|
|
420
|
+
const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
|
|
421
|
+
const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
|
|
422
|
+
const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
|
|
423
|
+
const preliminaryMode = resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
|
|
424
|
+
const unresolvedThreadsByAccount = new Map();
|
|
425
|
+
const pendingThreadReplyAccounts = new Set();
|
|
426
|
+
const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
|
|
427
|
+
return preliminaryMode.assignments.get(reviewer.account)?.type === "skip";
|
|
428
|
+
});
|
|
429
|
+
await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
430
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
|
|
431
|
+
unresolvedThreadsByAccount.set(reviewer.account, threads);
|
|
432
|
+
if (hasPendingThreadReply(threads, reviewer.account)) {
|
|
433
|
+
pendingThreadReplyAccounts.add(reviewer.account);
|
|
434
|
+
}
|
|
435
|
+
}, { signal: input.signal });
|
|
436
|
+
const mode = pendingThreadReplyAccounts.size
|
|
437
|
+
? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
|
|
438
|
+
: preliminaryMode;
|
|
439
|
+
if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
|
|
440
|
+
throw new Error("PR has already been reviewed by all configured accounts");
|
|
441
|
+
const outputDir = join(prRunOutputDir({
|
|
442
|
+
config: input.config,
|
|
443
|
+
directory: input.directory,
|
|
444
|
+
pr: input.pr,
|
|
445
|
+
}), ...(input.runId ? [input.runId] : []));
|
|
446
|
+
await mkdir(outputDir, { recursive: true });
|
|
447
|
+
await input.onProgress?.({ phase: "waiting for checks", type: "phase" });
|
|
448
|
+
const checkResult = await waitForChecksWithClassification({
|
|
449
|
+
client: input.client,
|
|
450
|
+
directory: input.directory,
|
|
451
|
+
exec,
|
|
452
|
+
headSha: meta.headRefOid,
|
|
453
|
+
onClassifierProgress: (progress) => {
|
|
454
|
+
if (progress.type === "classifier_started") {
|
|
455
|
+
return input.onProgress?.({
|
|
456
|
+
...progress,
|
|
457
|
+
type: "ci_classifier_started",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (progress.type === "classifier_session") {
|
|
461
|
+
return input.onProgress?.({
|
|
462
|
+
...progress,
|
|
463
|
+
type: "ci_classifier_session",
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (progress.type === "classifier_repair") {
|
|
467
|
+
return input.onProgress?.({
|
|
468
|
+
...progress,
|
|
469
|
+
type: "ci_classifier_repair",
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (progress.type === "classifier_completed") {
|
|
473
|
+
return input.onProgress?.({
|
|
474
|
+
...progress,
|
|
475
|
+
type: "ci_classifier_completed",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return input.onProgress?.({
|
|
479
|
+
...progress,
|
|
480
|
+
type: "ci_classifier_failed",
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
|
|
484
|
+
outputDir,
|
|
485
|
+
pr: input.pr,
|
|
486
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
487
|
+
repository: input.repository,
|
|
488
|
+
dryRun: input.dryRun,
|
|
489
|
+
signal: input.signal,
|
|
490
|
+
wait: input.repository.checks.waitBeforeReview,
|
|
491
|
+
});
|
|
492
|
+
const ciFailureContext = checkResult?.ciFailureContext ?? "";
|
|
493
|
+
const ciReports = checkResult ? [checkResult.report] : [];
|
|
494
|
+
if (checkResult &&
|
|
495
|
+
(checkResult.report.scopeOutsideRecovered.length ||
|
|
496
|
+
checkResult.report.scopeOutsideUnresolved.length ||
|
|
497
|
+
checkResult.report.scopeInside.length)) {
|
|
498
|
+
await input.onProgress?.({ report: checkResult.report, type: "ci_report" });
|
|
499
|
+
}
|
|
500
|
+
const worktreeRoot = join(input.directory, input.config.worktree?.dir ?? ".magi/worktrees");
|
|
501
|
+
await input.onProgress?.({ phase: "creating worktree", type: "phase" });
|
|
502
|
+
const worktree = await createWorktree(exec, input.repository, input.pr, worktreeRoot);
|
|
503
|
+
const worktreePath = worktree.path;
|
|
504
|
+
await input.onProgress?.({
|
|
505
|
+
branch: worktree.branch,
|
|
506
|
+
type: "worktree_created",
|
|
507
|
+
worktreePath,
|
|
508
|
+
});
|
|
509
|
+
try {
|
|
510
|
+
throwIfAborted(input.signal);
|
|
511
|
+
const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
512
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
513
|
+
if (!assignment || assignment.type === "skip")
|
|
514
|
+
return [];
|
|
515
|
+
return [{ assignment, reviewer }];
|
|
516
|
+
});
|
|
517
|
+
for (const reviewer of input.repository.agents.reviewers) {
|
|
518
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
519
|
+
if (assignment?.type !== "skip")
|
|
520
|
+
continue;
|
|
521
|
+
await input.onProgress?.({
|
|
522
|
+
reviewer: reviewer.key,
|
|
523
|
+
type: "reviewer_skipped",
|
|
524
|
+
verdict: reviewStateToVerdict(assignment.review.state),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
let entries = await mapPool(activeReviewers, input.repository.concurrency.reviewers, async ({ assignment, reviewer }) => {
|
|
528
|
+
await input.onProgress?.({
|
|
529
|
+
reviewer: reviewer.key,
|
|
530
|
+
type: "reviewer_started",
|
|
531
|
+
});
|
|
532
|
+
if (assignment.type === "rereview") {
|
|
533
|
+
const previous = assignment.review;
|
|
534
|
+
if (!previous.commit?.oid)
|
|
535
|
+
throw new Error(`Missing previous review commit for ${reviewer.account}`);
|
|
536
|
+
const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
537
|
+
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
|
|
538
|
+
const prompt = await composeRereviewPrompt({
|
|
539
|
+
baseSha: meta.baseRefOid,
|
|
540
|
+
ciFailureContext,
|
|
541
|
+
directory: input.directory,
|
|
542
|
+
headSha: meta.headRefOid,
|
|
543
|
+
pr: input.pr,
|
|
544
|
+
previousReview: previousReviewText(previous),
|
|
545
|
+
previousHeadSha: previous.commit.oid,
|
|
546
|
+
repository: input.repository,
|
|
547
|
+
reviewer,
|
|
548
|
+
unresolvedThreads: JSON.stringify(unresolved, null, 2),
|
|
549
|
+
worktreePath,
|
|
550
|
+
});
|
|
551
|
+
const result = await withReviewerFailureProgress({
|
|
552
|
+
onProgress: input.onProgress,
|
|
553
|
+
reviewer: reviewer.key,
|
|
554
|
+
run: () => runModelWithRepair({
|
|
555
|
+
client: input.client,
|
|
556
|
+
model: reviewer.model,
|
|
557
|
+
onProgress: async (progress) => {
|
|
558
|
+
if (progress.type === "session_created") {
|
|
559
|
+
await input.onProgress?.({
|
|
560
|
+
reviewer: reviewer.key,
|
|
561
|
+
options: progress.options,
|
|
562
|
+
sessionId: progress.sessionId,
|
|
563
|
+
type: "reviewer_session",
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
if (progress.type === "repair") {
|
|
567
|
+
await input.onProgress?.({
|
|
568
|
+
reviewer: reviewer.key,
|
|
569
|
+
type: "reviewer_repair",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (progress.type === "response") {
|
|
573
|
+
await input.onProgress?.({
|
|
574
|
+
reviewer: reviewer.key,
|
|
575
|
+
sessionId: progress.sessionId,
|
|
576
|
+
type: "reviewer_response",
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
options: reviewer.options,
|
|
581
|
+
parse: parseRereviewOutput,
|
|
582
|
+
permission: reviewer.permission,
|
|
583
|
+
prompt,
|
|
584
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
585
|
+
schemaName: "rereview",
|
|
586
|
+
signal: input.signal,
|
|
587
|
+
title: `magi rereview ${input.repository.alias}#${input.pr} ${reviewer.key}`,
|
|
588
|
+
}),
|
|
589
|
+
});
|
|
590
|
+
await writeFile(join(outputDir, `${reviewer.key}.rereview.prompt.txt`), prompt);
|
|
591
|
+
await writeFile(join(outputDir, `${reviewer.key}.rereview.raw.txt`), result.raw);
|
|
592
|
+
await writeFile(join(outputDir, `${reviewer.key}.rereview.json`), JSON.stringify(result.value, null, 2));
|
|
593
|
+
await input.onProgress?.({
|
|
594
|
+
reviewer: reviewer.key,
|
|
595
|
+
sessionId: result.sessionId,
|
|
596
|
+
type: "reviewer_completed",
|
|
597
|
+
verdict: result.value.verdict,
|
|
598
|
+
});
|
|
599
|
+
return {
|
|
600
|
+
key: reviewer.key,
|
|
601
|
+
raw: result.raw,
|
|
602
|
+
sessionId: result.sessionId,
|
|
603
|
+
value: result.value,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const prompt = await composeReviewPrompt({
|
|
607
|
+
baseSha: meta.baseRefOid,
|
|
608
|
+
ciFailureContext,
|
|
609
|
+
directory: input.directory,
|
|
610
|
+
headSha: meta.headRefOid,
|
|
611
|
+
pr: input.pr,
|
|
612
|
+
repository: input.repository,
|
|
613
|
+
reviewer,
|
|
614
|
+
worktreePath,
|
|
615
|
+
});
|
|
616
|
+
const result = await withReviewerFailureProgress({
|
|
617
|
+
onProgress: input.onProgress,
|
|
618
|
+
reviewer: reviewer.key,
|
|
619
|
+
run: () => runModelWithRepair({
|
|
620
|
+
client: input.client,
|
|
621
|
+
model: reviewer.model,
|
|
622
|
+
onProgress: async (progress) => {
|
|
623
|
+
if (progress.type === "session_created") {
|
|
624
|
+
await input.onProgress?.({
|
|
625
|
+
reviewer: reviewer.key,
|
|
626
|
+
options: progress.options,
|
|
627
|
+
sessionId: progress.sessionId,
|
|
628
|
+
type: "reviewer_session",
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (progress.type === "repair") {
|
|
632
|
+
await input.onProgress?.({
|
|
633
|
+
reviewer: reviewer.key,
|
|
634
|
+
type: "reviewer_repair",
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
if (progress.type === "response") {
|
|
638
|
+
await input.onProgress?.({
|
|
639
|
+
reviewer: reviewer.key,
|
|
640
|
+
sessionId: progress.sessionId,
|
|
641
|
+
type: "reviewer_response",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
},
|
|
645
|
+
options: reviewer.options,
|
|
646
|
+
parse: parseReviewOutput,
|
|
647
|
+
permission: reviewer.permission,
|
|
648
|
+
prompt,
|
|
649
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
650
|
+
schemaName: "review",
|
|
651
|
+
signal: input.signal,
|
|
652
|
+
title: `magi review ${input.repository.alias}#${input.pr} ${reviewer.key}`,
|
|
653
|
+
}),
|
|
654
|
+
});
|
|
655
|
+
await writeFile(join(outputDir, `${reviewer.key}.review.prompt.txt`), prompt);
|
|
656
|
+
await writeFile(join(outputDir, `${reviewer.key}.review.raw.txt`), result.raw);
|
|
657
|
+
await writeFile(join(outputDir, `${reviewer.key}.review.json`), JSON.stringify(result.value, null, 2));
|
|
658
|
+
await input.onProgress?.({
|
|
659
|
+
reviewer: reviewer.key,
|
|
660
|
+
sessionId: result.sessionId,
|
|
661
|
+
type: "reviewer_completed",
|
|
662
|
+
verdict: result.value.verdict,
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
key: reviewer.key,
|
|
666
|
+
raw: result.raw,
|
|
667
|
+
sessionId: result.sessionId,
|
|
668
|
+
value: result.value,
|
|
669
|
+
};
|
|
670
|
+
}, { signal: input.signal });
|
|
671
|
+
throwIfAborted(input.signal);
|
|
672
|
+
const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
|
|
673
|
+
const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
674
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
675
|
+
if (assignment?.type !== "skip")
|
|
676
|
+
return [];
|
|
677
|
+
return [
|
|
678
|
+
{
|
|
679
|
+
reviewer: reviewer.key,
|
|
680
|
+
verdict: reviewStateToVerdict(assignment.review.state),
|
|
681
|
+
},
|
|
682
|
+
];
|
|
683
|
+
});
|
|
684
|
+
const closeTargets = closeMinorityReviewers([
|
|
685
|
+
...skippedVerdicts,
|
|
686
|
+
...entries.map((entry) => ({
|
|
687
|
+
reviewer: entry.key,
|
|
688
|
+
verdict: entry.value.verdict,
|
|
689
|
+
})),
|
|
690
|
+
]);
|
|
691
|
+
const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
692
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
693
|
+
if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
|
|
694
|
+
return [];
|
|
695
|
+
return [
|
|
696
|
+
{
|
|
697
|
+
key: reviewer.key,
|
|
698
|
+
raw: assignment.review.body ?? "",
|
|
699
|
+
sessionId: "",
|
|
700
|
+
value: reviewOutputFromState(assignment.review),
|
|
701
|
+
},
|
|
702
|
+
];
|
|
703
|
+
});
|
|
704
|
+
entries = await runCloseReconsideration({
|
|
705
|
+
entries: [...entries, ...skippedCloseEntries],
|
|
706
|
+
meta,
|
|
707
|
+
outputDir,
|
|
708
|
+
reviewInput: { ...input, exec },
|
|
709
|
+
sessionIds,
|
|
710
|
+
targets: closeTargets,
|
|
711
|
+
worktreePath,
|
|
712
|
+
});
|
|
713
|
+
const validation = await runFindingValidation({
|
|
714
|
+
entries,
|
|
715
|
+
meta,
|
|
716
|
+
outputDir,
|
|
717
|
+
reviewInput: { ...input, exec },
|
|
718
|
+
sessionIds,
|
|
719
|
+
worktreePath,
|
|
720
|
+
});
|
|
721
|
+
const outputs = validation.outputs;
|
|
722
|
+
const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
723
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
724
|
+
if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
|
|
725
|
+
return [];
|
|
726
|
+
return [
|
|
727
|
+
{
|
|
728
|
+
reviewer: reviewer.key,
|
|
729
|
+
verdict: reviewStateToVerdict(assignment.review.state),
|
|
730
|
+
},
|
|
731
|
+
];
|
|
732
|
+
});
|
|
733
|
+
const activeVerdicts = Object.entries(outputs).map(([reviewer, output]) => ({
|
|
734
|
+
reviewer,
|
|
735
|
+
verdict: output.verdict,
|
|
736
|
+
}));
|
|
737
|
+
const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
|
|
738
|
+
await input.onProgress?.({ phase: "posting reviews", type: "phase" });
|
|
739
|
+
const posted = {
|
|
740
|
+
...Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
741
|
+
const assignment = mode.assignments.get(reviewer.account);
|
|
742
|
+
return assignment?.type === "skip"
|
|
743
|
+
? [[reviewer.key, "skipped: already reviewed current head"]]
|
|
744
|
+
: [];
|
|
745
|
+
})),
|
|
746
|
+
...Object.fromEntries(await Promise.all(Object.entries(outputs).map(async ([key, output]) => [
|
|
747
|
+
key,
|
|
748
|
+
input.dryRun
|
|
749
|
+
? dryRunReviewPost(key, output)
|
|
750
|
+
: "resolve" in output
|
|
751
|
+
? await postRereviewOutput({ ...input, exec }, key, output)
|
|
752
|
+
: await postReviewOutput({ ...input, exec }, key, output),
|
|
753
|
+
]))),
|
|
754
|
+
};
|
|
755
|
+
await writeFile(join(outputDir, "majority.json"), JSON.stringify({
|
|
756
|
+
approvalPolicy: input.approvalPolicy ?? "majority",
|
|
757
|
+
verdict,
|
|
758
|
+
verdicts: [...remainingSkippedVerdicts, ...activeVerdicts],
|
|
759
|
+
}, null, 2));
|
|
760
|
+
await writeFile(join(outputDir, "sessions.json"), JSON.stringify(sessionIds, null, 2));
|
|
761
|
+
await writeFile(join(outputDir, "posted.json"), JSON.stringify(posted, null, 2));
|
|
762
|
+
const report = formatReviewReport({
|
|
763
|
+
ciReports,
|
|
764
|
+
discardedFindings: validation.summary.discarded,
|
|
765
|
+
dryRun: input.dryRun,
|
|
766
|
+
outputs,
|
|
767
|
+
posted,
|
|
768
|
+
repository: input.repository,
|
|
769
|
+
});
|
|
770
|
+
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
771
|
+
await input.onProgress?.({ type: "completed", verdict });
|
|
772
|
+
return {
|
|
773
|
+
baseSha: meta.baseRefOid,
|
|
774
|
+
ciReports,
|
|
775
|
+
discardedFindings: validation.summary.discarded,
|
|
776
|
+
headSha: meta.headRefOid,
|
|
777
|
+
outputs,
|
|
778
|
+
posted,
|
|
779
|
+
pr: input.pr,
|
|
780
|
+
report,
|
|
781
|
+
sessionIds,
|
|
782
|
+
verdict,
|
|
783
|
+
worktreePath,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
await removeWorktree(input.exec, worktreePath).catch(() => undefined);
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|