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,836 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { prRunOutputDir } from "../config/output";
|
|
4
|
+
import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForMergeQueue, } from "../github/commands";
|
|
5
|
+
import { composeEditPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
|
|
6
|
+
import { parseEditOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, } from "../prompts/output";
|
|
7
|
+
import { throwIfAborted, withAbortSignal } from "./abort";
|
|
8
|
+
import { waitForChecksWithClassification } from "./ci";
|
|
9
|
+
import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
|
|
10
|
+
import { runModelWithRepair } from "./model";
|
|
11
|
+
import { mapPool } from "./pool";
|
|
12
|
+
import { formatMergeReport } from "./report";
|
|
13
|
+
import { runReview } from "./review";
|
|
14
|
+
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
15
|
+
function outputDir(input) {
|
|
16
|
+
return prRunOutputDir({
|
|
17
|
+
config: input.config,
|
|
18
|
+
directory: input.directory,
|
|
19
|
+
pr: input.pr,
|
|
20
|
+
runId: input.runId,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function errorMessage(error) {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
|
26
|
+
async function withEditorFailureProgress(input) {
|
|
27
|
+
try {
|
|
28
|
+
return await input.run();
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
await input.onProgress?.({
|
|
32
|
+
cycle: input.cycle,
|
|
33
|
+
error: errorMessage(error),
|
|
34
|
+
type: "editor_failed",
|
|
35
|
+
});
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function withReviewerFailureProgress(input) {
|
|
40
|
+
try {
|
|
41
|
+
return await input.run();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
await input.onProgress?.({
|
|
45
|
+
error: errorMessage(error),
|
|
46
|
+
reviewer: input.reviewer,
|
|
47
|
+
type: "reviewer_failed",
|
|
48
|
+
});
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
|
|
53
|
+
const editor = input.repository.agents.editor;
|
|
54
|
+
if (!editor)
|
|
55
|
+
throw new Error("agents.editor is required for magi_merge");
|
|
56
|
+
throwIfAborted(input.signal);
|
|
57
|
+
await configureGitIdentity(input.exec, worktreePath, {
|
|
58
|
+
email: editor.author?.email,
|
|
59
|
+
name: editor.author?.name,
|
|
60
|
+
});
|
|
61
|
+
const artifactDir = outputDir(input);
|
|
62
|
+
const prompt = await composeEditPrompt({
|
|
63
|
+
directory: input.directory,
|
|
64
|
+
pr: input.pr,
|
|
65
|
+
repository: input.repository,
|
|
66
|
+
unresolvedThreads: JSON.stringify(unresolvedThreads, null, 2),
|
|
67
|
+
worktreePath,
|
|
68
|
+
});
|
|
69
|
+
await input.onProgress?.({ cycle, type: "editor_started" });
|
|
70
|
+
const result = await withEditorFailureProgress({
|
|
71
|
+
cycle,
|
|
72
|
+
onProgress: input.onProgress,
|
|
73
|
+
run: () => runModelWithRepair({
|
|
74
|
+
client: input.client,
|
|
75
|
+
model: editor.model,
|
|
76
|
+
onProgress: async (progress) => {
|
|
77
|
+
if (progress.type === "session_created") {
|
|
78
|
+
await input.onProgress?.({
|
|
79
|
+
cycle,
|
|
80
|
+
options: progress.options,
|
|
81
|
+
sessionId: progress.sessionId,
|
|
82
|
+
type: "editor_session",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (progress.type === "repair") {
|
|
86
|
+
await input.onProgress?.({ cycle, type: "editor_repair" });
|
|
87
|
+
}
|
|
88
|
+
if (progress.type === "response") {
|
|
89
|
+
await input.onProgress?.({
|
|
90
|
+
cycle,
|
|
91
|
+
sessionId: progress.sessionId,
|
|
92
|
+
type: "editor_response",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
options: editor.options,
|
|
97
|
+
parse: parseEditOutput,
|
|
98
|
+
permission: editor.permission,
|
|
99
|
+
prompt,
|
|
100
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
101
|
+
schemaName: "edit",
|
|
102
|
+
signal: input.signal,
|
|
103
|
+
title: `magi edit ${input.repository.alias}#${input.pr} cycle ${cycle}`,
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
await writeFile(join(artifactDir, `editor.cycle-${cycle}.prompt.txt`), prompt);
|
|
107
|
+
await writeFile(join(artifactDir, `editor.cycle-${cycle}.raw.txt`), result.raw);
|
|
108
|
+
await writeFile(join(artifactDir, `editor.cycle-${cycle}.json`), JSON.stringify(result.value, null, 2));
|
|
109
|
+
await input.onProgress?.({ cycle, type: "editor_completed" });
|
|
110
|
+
if (!input.dryRun) {
|
|
111
|
+
if (result.value.mode === "EDITED") {
|
|
112
|
+
await pushHead(input.exec, input.repository, worktreePath, editor.account);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throwIfAborted(input.signal);
|
|
116
|
+
if (!input.dryRun) {
|
|
117
|
+
await Promise.all(result.value.responses.map((reply) => postReply(input.exec, input.repository, input.pr, editor.account, reply.commentId, reply.body)));
|
|
118
|
+
}
|
|
119
|
+
return result.value;
|
|
120
|
+
}
|
|
121
|
+
async function postRereviewOutput(input, reviewerKey, output) {
|
|
122
|
+
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
123
|
+
if (!reviewer)
|
|
124
|
+
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
125
|
+
if (input.dryRun) {
|
|
126
|
+
if (output.verdict === "MERGE")
|
|
127
|
+
return `dry-run:would-approve:${reviewerKey}`;
|
|
128
|
+
if (output.verdict === "CLOSE") {
|
|
129
|
+
return `dry-run:would-comment-close:${reviewerKey}`;
|
|
130
|
+
}
|
|
131
|
+
return `dry-run:would-request-changes:${reviewerKey}`;
|
|
132
|
+
}
|
|
133
|
+
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
|
|
134
|
+
const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
|
|
135
|
+
if (output.verdict === "MERGE") {
|
|
136
|
+
return postApproval(input.exec, input.repository, input.pr, reviewer.account);
|
|
137
|
+
}
|
|
138
|
+
if (output.verdict === "CLOSE") {
|
|
139
|
+
return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
|
|
140
|
+
}
|
|
141
|
+
if (output.newFindings.length) {
|
|
142
|
+
return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
|
|
143
|
+
fix: "Please address this before merging.",
|
|
144
|
+
issue: finding.body,
|
|
145
|
+
line: finding.line,
|
|
146
|
+
path: finding.path,
|
|
147
|
+
startLine: finding.startLine,
|
|
148
|
+
})));
|
|
149
|
+
}
|
|
150
|
+
return replies[0] ?? "";
|
|
151
|
+
}
|
|
152
|
+
async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
|
|
153
|
+
throwIfAborted(input.signal);
|
|
154
|
+
const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
|
|
155
|
+
const headSha = options.dryRunHeadSha ?? meta.headRefOid;
|
|
156
|
+
const artifactDir = outputDir(input);
|
|
157
|
+
let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
158
|
+
throwIfAborted(input.signal);
|
|
159
|
+
const unresolved = options.dryRunThreads?.[reviewer.key] ??
|
|
160
|
+
(await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewer.account));
|
|
161
|
+
const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
|
|
162
|
+
const prompt = await composeRereviewPrompt({
|
|
163
|
+
baseSha: meta.baseRefOid,
|
|
164
|
+
ciFailureContext,
|
|
165
|
+
directory: input.directory,
|
|
166
|
+
headSha,
|
|
167
|
+
includeReviewGuidelines: !hasReviewerSession,
|
|
168
|
+
includeSessionContext: !hasReviewerSession,
|
|
169
|
+
pr: input.pr,
|
|
170
|
+
previousHeadSha,
|
|
171
|
+
repository: input.repository,
|
|
172
|
+
reviewer,
|
|
173
|
+
unresolvedThreads: JSON.stringify(unresolved, null, 2),
|
|
174
|
+
worktreePath,
|
|
175
|
+
});
|
|
176
|
+
await input.onProgress?.({
|
|
177
|
+
reviewer: reviewer.key,
|
|
178
|
+
type: "reviewer_started",
|
|
179
|
+
});
|
|
180
|
+
const result = await withReviewerFailureProgress({
|
|
181
|
+
onProgress: input.onProgress,
|
|
182
|
+
reviewer: reviewer.key,
|
|
183
|
+
run: () => runModelWithRepair({
|
|
184
|
+
client: input.client,
|
|
185
|
+
model: reviewer.model,
|
|
186
|
+
onProgress: async (progress) => {
|
|
187
|
+
if (progress.type === "session_created") {
|
|
188
|
+
await input.onProgress?.({
|
|
189
|
+
reviewer: reviewer.key,
|
|
190
|
+
options: progress.options,
|
|
191
|
+
sessionId: progress.sessionId,
|
|
192
|
+
type: "reviewer_session",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (progress.type === "repair") {
|
|
196
|
+
await input.onProgress?.({
|
|
197
|
+
reviewer: reviewer.key,
|
|
198
|
+
type: "reviewer_repair",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (progress.type === "response") {
|
|
202
|
+
await input.onProgress?.({
|
|
203
|
+
reviewer: reviewer.key,
|
|
204
|
+
sessionId: progress.sessionId,
|
|
205
|
+
type: "reviewer_response",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
options: reviewer.options,
|
|
210
|
+
parse: parseRereviewOutput,
|
|
211
|
+
permission: reviewer.permission,
|
|
212
|
+
prompt,
|
|
213
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
214
|
+
schemaName: "rereview",
|
|
215
|
+
signal: input.signal,
|
|
216
|
+
sessionId: sessionIds[reviewer.key],
|
|
217
|
+
title: `magi rereview ${input.repository.alias}#${input.pr} ${reviewer.key} cycle ${cycle}`,
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
sessionIds[reviewer.key] = result.sessionId;
|
|
221
|
+
await writeFile(join(artifactDir, `${reviewer.key}.rereview.cycle-${cycle}.prompt.txt`), prompt);
|
|
222
|
+
await writeFile(join(artifactDir, `${reviewer.key}.rereview.cycle-${cycle}.raw.txt`), result.raw);
|
|
223
|
+
await writeFile(join(artifactDir, `${reviewer.key}.rereview.cycle-${cycle}.json`), JSON.stringify(result.value, null, 2));
|
|
224
|
+
await input.onProgress?.({
|
|
225
|
+
reviewer: reviewer.key,
|
|
226
|
+
sessionId: result.sessionId,
|
|
227
|
+
type: "reviewer_completed",
|
|
228
|
+
verdict: result.value.verdict,
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
output: result.value,
|
|
232
|
+
reviewer: reviewer.key,
|
|
233
|
+
sessionId: result.sessionId,
|
|
234
|
+
verdict: result.value.verdict,
|
|
235
|
+
};
|
|
236
|
+
}, { signal: input.signal });
|
|
237
|
+
const targets = closeMinorityReviewers(entries);
|
|
238
|
+
if (targets.length) {
|
|
239
|
+
await input.onProgress?.({
|
|
240
|
+
phase: `reconsidering close verdicts cycle ${cycle}`,
|
|
241
|
+
type: "phase",
|
|
242
|
+
});
|
|
243
|
+
entries = await Promise.all(entries.map(async (entry) => {
|
|
244
|
+
if (!targets.includes(entry.reviewer))
|
|
245
|
+
return entry;
|
|
246
|
+
const reviewer = input.repository.agents.reviewers.find((item) => item.key === entry.reviewer);
|
|
247
|
+
if (!reviewer)
|
|
248
|
+
return entry;
|
|
249
|
+
const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
|
|
250
|
+
const prompt = await composeRereviewCloseReconsiderationPrompt({
|
|
251
|
+
baseSha: meta.baseRefOid,
|
|
252
|
+
closeReason: entry.output.reason,
|
|
253
|
+
directory: input.directory,
|
|
254
|
+
headSha: meta.headRefOid,
|
|
255
|
+
includeReviewGuidelines: !hasReviewerSession,
|
|
256
|
+
includeSessionContext: !hasReviewerSession,
|
|
257
|
+
pr: input.pr,
|
|
258
|
+
previousHeadSha,
|
|
259
|
+
repository: input.repository,
|
|
260
|
+
reviewer,
|
|
261
|
+
worktreePath,
|
|
262
|
+
});
|
|
263
|
+
const result = await withReviewerFailureProgress({
|
|
264
|
+
onProgress: input.onProgress,
|
|
265
|
+
reviewer: reviewer.key,
|
|
266
|
+
run: () => runModelWithRepair({
|
|
267
|
+
client: input.client,
|
|
268
|
+
model: reviewer.model,
|
|
269
|
+
onProgress: async (progress) => {
|
|
270
|
+
if (progress.type === "session_created") {
|
|
271
|
+
await input.onProgress?.({
|
|
272
|
+
reviewer: reviewer.key,
|
|
273
|
+
options: progress.options,
|
|
274
|
+
sessionId: progress.sessionId,
|
|
275
|
+
type: "reviewer_session",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (progress.type === "repair") {
|
|
279
|
+
await input.onProgress?.({
|
|
280
|
+
reviewer: reviewer.key,
|
|
281
|
+
type: "reviewer_repair",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (progress.type === "response") {
|
|
285
|
+
await input.onProgress?.({
|
|
286
|
+
reviewer: reviewer.key,
|
|
287
|
+
sessionId: progress.sessionId,
|
|
288
|
+
type: "reviewer_response",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
options: reviewer.options,
|
|
293
|
+
parse: parseRereviewCloseReconsiderationOutput,
|
|
294
|
+
permission: reviewer.permission,
|
|
295
|
+
prompt,
|
|
296
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
297
|
+
schemaName: "rereview close reconsideration",
|
|
298
|
+
sessionId: sessionIds[reviewer.key],
|
|
299
|
+
signal: input.signal,
|
|
300
|
+
title: `magi reconsider close ${input.repository.alias}#${input.pr} ${reviewer.key} cycle ${cycle}`,
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
sessionIds[reviewer.key] = result.sessionId;
|
|
304
|
+
await writeFile(join(artifactDir, `${reviewer.key}.close-reconsideration.cycle-${cycle}.prompt.txt`), prompt);
|
|
305
|
+
await writeFile(join(artifactDir, `${reviewer.key}.close-reconsideration.cycle-${cycle}.raw.txt`), result.raw);
|
|
306
|
+
await writeFile(join(artifactDir, `${reviewer.key}.close-reconsideration.cycle-${cycle}.json`), JSON.stringify(result.value, null, 2));
|
|
307
|
+
await input.onProgress?.({
|
|
308
|
+
from: "CLOSE",
|
|
309
|
+
reviewer: reviewer.key,
|
|
310
|
+
to: result.value.verdict,
|
|
311
|
+
type: "reviewer_reconsidered",
|
|
312
|
+
});
|
|
313
|
+
await input.onProgress?.({
|
|
314
|
+
reviewer: reviewer.key,
|
|
315
|
+
sessionId: result.sessionId,
|
|
316
|
+
type: "reviewer_completed",
|
|
317
|
+
verdict: result.value.verdict,
|
|
318
|
+
});
|
|
319
|
+
return {
|
|
320
|
+
output: result.value,
|
|
321
|
+
reviewer: reviewer.key,
|
|
322
|
+
sessionId: result.sessionId,
|
|
323
|
+
verdict: result.value.verdict,
|
|
324
|
+
};
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
const posted = Object.fromEntries(await Promise.all(entries.map(async (entry) => [
|
|
328
|
+
entry.reviewer,
|
|
329
|
+
await postRereviewOutput(input, entry.reviewer, entry.output),
|
|
330
|
+
])));
|
|
331
|
+
const verdict = mergeVerdictForPolicy(entries.map((entry) => ({
|
|
332
|
+
reviewer: entry.reviewer,
|
|
333
|
+
verdict: entry.verdict,
|
|
334
|
+
})), input.repository.merge.approvalPolicy);
|
|
335
|
+
await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
|
|
336
|
+
approvalPolicy: input.repository.merge.approvalPolicy,
|
|
337
|
+
verdict,
|
|
338
|
+
verdicts: entries.map((entry) => ({
|
|
339
|
+
reviewer: entry.reviewer,
|
|
340
|
+
verdict: entry.verdict,
|
|
341
|
+
})),
|
|
342
|
+
}, null, 2));
|
|
343
|
+
return {
|
|
344
|
+
outputs: Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output])),
|
|
345
|
+
posted,
|
|
346
|
+
verdict,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function finishMergeRun(input, result, reportInput) {
|
|
350
|
+
const report = formatMergeReport({
|
|
351
|
+
ciReports: reportInput.ciReports,
|
|
352
|
+
dryRun: input.dryRun,
|
|
353
|
+
editorOutputs: reportInput.editorOutputs,
|
|
354
|
+
outputs: reportInput.outputs,
|
|
355
|
+
posted: reportInput.posted,
|
|
356
|
+
repository: input.repository,
|
|
357
|
+
status: result.status,
|
|
358
|
+
});
|
|
359
|
+
await writeFile(join(outputDir(input), "report.md"), `${report}\n`);
|
|
360
|
+
return { ...result, report };
|
|
361
|
+
}
|
|
362
|
+
async function mergeWithQueue(input, exec, editorAccount) {
|
|
363
|
+
await mergePullRequest(exec, input.repository, input.pr, editorAccount);
|
|
364
|
+
if (!input.repository.merge.mergeQueue)
|
|
365
|
+
return "merged";
|
|
366
|
+
return waitForMergeQueue(exec, input.repository, input.pr);
|
|
367
|
+
}
|
|
368
|
+
export function hasBlockingCiReports(reports) {
|
|
369
|
+
return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
|
|
370
|
+
}
|
|
371
|
+
function copyThreadAttempts(attempts) {
|
|
372
|
+
return Object.fromEntries(Object.entries(attempts).map(([key, value]) => [key, { ...value }]));
|
|
373
|
+
}
|
|
374
|
+
export function reviewThreadKey(thread) {
|
|
375
|
+
return thread.threadId || `comment:${thread.commentId}`;
|
|
376
|
+
}
|
|
377
|
+
export function recordReviewThreads(input) {
|
|
378
|
+
for (const thread of input.threads) {
|
|
379
|
+
const key = reviewThreadKey(thread);
|
|
380
|
+
const current = input.attempts[key];
|
|
381
|
+
input.attempts[key] = current
|
|
382
|
+
? { ...current, lastSeenCycle: input.cycle }
|
|
383
|
+
: {
|
|
384
|
+
attempts: 0,
|
|
385
|
+
firstSeenCycle: input.cycle,
|
|
386
|
+
lastSeenCycle: input.cycle,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
export function editableReviewThreads(input) {
|
|
391
|
+
if (input.maxThreadResolutionCycles === 0)
|
|
392
|
+
return input.threads;
|
|
393
|
+
return input.threads.filter((thread) => {
|
|
394
|
+
const attempt = input.attempts[reviewThreadKey(thread)];
|
|
395
|
+
return !attempt || attempt.attempts < input.maxThreadResolutionCycles;
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
export function exhaustedReviewThreads(input) {
|
|
399
|
+
if (input.maxThreadResolutionCycles === 0)
|
|
400
|
+
return [];
|
|
401
|
+
return input.threads.filter((thread) => {
|
|
402
|
+
const attempt = input.attempts[reviewThreadKey(thread)];
|
|
403
|
+
return !!attempt && attempt.attempts >= input.maxThreadResolutionCycles;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
export function incrementReviewThreadAttempts(input) {
|
|
407
|
+
const newlyExhausted = [];
|
|
408
|
+
for (const thread of input.threads) {
|
|
409
|
+
const key = reviewThreadKey(thread);
|
|
410
|
+
const current = input.attempts[key] ?? {
|
|
411
|
+
attempts: 0,
|
|
412
|
+
firstSeenCycle: input.cycle,
|
|
413
|
+
lastSeenCycle: input.cycle,
|
|
414
|
+
};
|
|
415
|
+
const attempts = current.attempts + 1;
|
|
416
|
+
const exhaustedAtCycle = input.maxThreadResolutionCycles !== 0 &&
|
|
417
|
+
attempts >= input.maxThreadResolutionCycles &&
|
|
418
|
+
current.exhaustedAtCycle == null
|
|
419
|
+
? input.cycle
|
|
420
|
+
: current.exhaustedAtCycle;
|
|
421
|
+
if (exhaustedAtCycle === input.cycle)
|
|
422
|
+
newlyExhausted.push(thread);
|
|
423
|
+
input.attempts[key] = {
|
|
424
|
+
...current,
|
|
425
|
+
attempts,
|
|
426
|
+
exhaustedAtCycle,
|
|
427
|
+
lastAttemptedCycle: input.cycle,
|
|
428
|
+
lastSeenCycle: input.cycle,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return newlyExhausted;
|
|
432
|
+
}
|
|
433
|
+
export function reviewThreadNotification(repository, pr, thread) {
|
|
434
|
+
const host = repository.github.host || "github.com";
|
|
435
|
+
return {
|
|
436
|
+
label: "GitHub thread",
|
|
437
|
+
url: `https://${host}/${repository.github.owner}/${repository.github.repo}/pull/${pr}#discussion_r${thread.commentId}`,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function syntheticReviewThreads(outputs) {
|
|
441
|
+
let nextCommentId = -1;
|
|
442
|
+
const threads = {};
|
|
443
|
+
for (const [reviewer, output] of Object.entries(outputs)) {
|
|
444
|
+
if ("findings" in output) {
|
|
445
|
+
threads[reviewer] = output.findings.map((finding) => {
|
|
446
|
+
const commentId = nextCommentId--;
|
|
447
|
+
return {
|
|
448
|
+
body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
|
|
449
|
+
commentId,
|
|
450
|
+
comments: [
|
|
451
|
+
{
|
|
452
|
+
author: reviewer,
|
|
453
|
+
body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
|
|
454
|
+
commentId,
|
|
455
|
+
createdAt: new Date(0).toISOString(),
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
line: finding.line,
|
|
459
|
+
path: finding.path,
|
|
460
|
+
threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
threads[reviewer] = output.newFindings.map((finding) => {
|
|
466
|
+
const commentId = nextCommentId--;
|
|
467
|
+
return {
|
|
468
|
+
body: finding.body,
|
|
469
|
+
commentId,
|
|
470
|
+
comments: [
|
|
471
|
+
{
|
|
472
|
+
author: reviewer,
|
|
473
|
+
body: finding.body,
|
|
474
|
+
commentId,
|
|
475
|
+
createdAt: new Date(0).toISOString(),
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
line: finding.line,
|
|
479
|
+
path: finding.path,
|
|
480
|
+
threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return threads;
|
|
485
|
+
}
|
|
486
|
+
function flattenSyntheticThreads(threads) {
|
|
487
|
+
return Object.values(threads).flat();
|
|
488
|
+
}
|
|
489
|
+
function appendDryRunEditorResponses(input) {
|
|
490
|
+
if (!input.threads || !input.output.responses.length)
|
|
491
|
+
return input.threads;
|
|
492
|
+
let nextCommentId = -10_000;
|
|
493
|
+
const responses = [...input.output.responses];
|
|
494
|
+
return Object.fromEntries(Object.entries(input.threads).map(([reviewer, threads]) => [
|
|
495
|
+
reviewer,
|
|
496
|
+
threads.map((thread) => {
|
|
497
|
+
const matched = responses.filter((response) => response.commentId === thread.commentId ||
|
|
498
|
+
thread.comments.some((comment) => comment.commentId === response.commentId));
|
|
499
|
+
if (!matched.length)
|
|
500
|
+
return thread;
|
|
501
|
+
return {
|
|
502
|
+
...thread,
|
|
503
|
+
comments: [
|
|
504
|
+
...thread.comments,
|
|
505
|
+
...matched.map((response, index) => ({
|
|
506
|
+
author: input.author,
|
|
507
|
+
body: response.body,
|
|
508
|
+
commentId: nextCommentId--,
|
|
509
|
+
createdAt: `9999-01-01T00:00:${String(index).padStart(2, "0")}Z`,
|
|
510
|
+
})),
|
|
511
|
+
],
|
|
512
|
+
};
|
|
513
|
+
}),
|
|
514
|
+
]));
|
|
515
|
+
}
|
|
516
|
+
export async function runMerge(input) {
|
|
517
|
+
const exec = withAbortSignal(input.exec, input.signal);
|
|
518
|
+
const abortableInput = { ...input, exec };
|
|
519
|
+
const editor = input.repository.agents.editor;
|
|
520
|
+
if (!editor)
|
|
521
|
+
throw new Error("agents.editor is required for magi_merge");
|
|
522
|
+
throwIfAborted(input.signal);
|
|
523
|
+
const artifactDir = outputDir(input);
|
|
524
|
+
await mkdir(artifactDir, { recursive: true });
|
|
525
|
+
if (hasSafetyGate(input.repository)) {
|
|
526
|
+
await input.onProgress?.({ phase: "checking safety", type: "phase" });
|
|
527
|
+
const safety = await checkSafetyGate({
|
|
528
|
+
exec,
|
|
529
|
+
pr: input.pr,
|
|
530
|
+
repository: input.repository,
|
|
531
|
+
});
|
|
532
|
+
if (!safety.ok) {
|
|
533
|
+
const report = formatMergeReport({
|
|
534
|
+
ciReports: [],
|
|
535
|
+
dryRun: input.dryRun,
|
|
536
|
+
editorOutputs: [],
|
|
537
|
+
outputs: {},
|
|
538
|
+
posted: {},
|
|
539
|
+
repository: input.repository,
|
|
540
|
+
safety,
|
|
541
|
+
status: "safety_blocked",
|
|
542
|
+
});
|
|
543
|
+
await writeFile(join(artifactDir, "report.md"), `${report}\n`);
|
|
544
|
+
await input.onProgress?.({
|
|
545
|
+
status: "safety_blocked",
|
|
546
|
+
type: "merge_completed",
|
|
547
|
+
});
|
|
548
|
+
return { cycles: 0, pr: input.pr, report, status: "safety_blocked" };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (input.repository.merge.mergeQueue) {
|
|
552
|
+
const meta = await fetchPullRequest(exec, input.repository, input.pr);
|
|
553
|
+
const requiresMergeQueue = await fetchMergeQueueRequirement(exec, input.repository, meta.baseRefName).catch(() => undefined);
|
|
554
|
+
if (requiresMergeQueue !== true) {
|
|
555
|
+
await input.onProgress?.({
|
|
556
|
+
message: requiresMergeQueue === false
|
|
557
|
+
? `Merge queue is not enabled for base branch ${meta.baseRefName}.`
|
|
558
|
+
: `Could not verify merge queue for base branch ${meta.baseRefName}.`,
|
|
559
|
+
type: "warning",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
await input.onProgress?.({ phase: "initial review", type: "phase" });
|
|
564
|
+
const review = await runReview({
|
|
565
|
+
...abortableInput,
|
|
566
|
+
allowAlreadyReviewed: true,
|
|
567
|
+
approvalPolicy: input.repository.merge.approvalPolicy,
|
|
568
|
+
onProgress: (progress) => input.onProgress?.(progress),
|
|
569
|
+
runId: input.runId,
|
|
570
|
+
dryRun: input.dryRun,
|
|
571
|
+
skipSafety: true,
|
|
572
|
+
});
|
|
573
|
+
try {
|
|
574
|
+
throwIfAborted(input.signal);
|
|
575
|
+
let reportOutputs = review.outputs;
|
|
576
|
+
let reportPosted = review.posted;
|
|
577
|
+
let reportCiReports = review.ciReports;
|
|
578
|
+
const editorOutputs = [];
|
|
579
|
+
const complete = (result) => finishMergeRun(input, result, {
|
|
580
|
+
ciReports: reportCiReports,
|
|
581
|
+
editorOutputs,
|
|
582
|
+
outputs: reportOutputs,
|
|
583
|
+
posted: reportPosted,
|
|
584
|
+
});
|
|
585
|
+
if (review.verdict === "SAFETY_BLOCKED") {
|
|
586
|
+
await input.onProgress?.({
|
|
587
|
+
status: "safety_blocked",
|
|
588
|
+
type: "merge_completed",
|
|
589
|
+
});
|
|
590
|
+
return complete({ cycles: 0, pr: input.pr, status: "safety_blocked" });
|
|
591
|
+
}
|
|
592
|
+
if (review.verdict === "CLOSE") {
|
|
593
|
+
if (!input.repository.automation.close || input.dryRun) {
|
|
594
|
+
await input.onProgress?.({
|
|
595
|
+
status: "close_requested",
|
|
596
|
+
type: "merge_completed",
|
|
597
|
+
});
|
|
598
|
+
return complete({ cycles: 0, pr: input.pr, status: "close_requested" });
|
|
599
|
+
}
|
|
600
|
+
await input.onProgress?.({ phase: "closing PR", type: "phase" });
|
|
601
|
+
await closePullRequest(exec, input.repository, input.pr, editor.account);
|
|
602
|
+
await input.onProgress?.({ status: "closed", type: "merge_completed" });
|
|
603
|
+
return complete({ cycles: 0, pr: input.pr, status: "closed" });
|
|
604
|
+
}
|
|
605
|
+
if (review.verdict === "MERGE") {
|
|
606
|
+
if (hasBlockingCiReports(review.ciReports)) {
|
|
607
|
+
await input.onProgress?.({
|
|
608
|
+
status: "ci_unresolved",
|
|
609
|
+
type: "merge_completed",
|
|
610
|
+
});
|
|
611
|
+
return complete({ cycles: 0, pr: input.pr, status: "ci_unresolved" });
|
|
612
|
+
}
|
|
613
|
+
if (!input.repository.automation.merge || input.dryRun) {
|
|
614
|
+
await input.onProgress?.({
|
|
615
|
+
status: "approved",
|
|
616
|
+
type: "merge_completed",
|
|
617
|
+
});
|
|
618
|
+
return complete({ cycles: 0, pr: input.pr, status: "approved" });
|
|
619
|
+
}
|
|
620
|
+
await input.onProgress?.({ phase: "merging PR", type: "phase" });
|
|
621
|
+
const status = await mergeWithQueue(input, exec, editor.account);
|
|
622
|
+
await input.onProgress?.({ status, type: "merge_completed" });
|
|
623
|
+
return complete({ cycles: 0, pr: input.pr, status });
|
|
624
|
+
}
|
|
625
|
+
let previousHeadSha = review.headSha;
|
|
626
|
+
const ciReports = [...review.ciReports];
|
|
627
|
+
const threadAttempts = {};
|
|
628
|
+
let dryRunThreads = input.dryRun
|
|
629
|
+
? syntheticReviewThreads(reportOutputs)
|
|
630
|
+
: undefined;
|
|
631
|
+
for (let cycle = 1;; cycle += 1) {
|
|
632
|
+
const unresolvedThreads = input.dryRun
|
|
633
|
+
? flattenSyntheticThreads(dryRunThreads ?? {})
|
|
634
|
+
: await fetchUnresolvedThreads(exec, input.repository, input.pr);
|
|
635
|
+
recordReviewThreads({
|
|
636
|
+
attempts: threadAttempts,
|
|
637
|
+
cycle,
|
|
638
|
+
threads: unresolvedThreads,
|
|
639
|
+
});
|
|
640
|
+
await input.onProgress?.({
|
|
641
|
+
attempts: copyThreadAttempts(threadAttempts),
|
|
642
|
+
type: "thread_attempts",
|
|
643
|
+
});
|
|
644
|
+
const editableThreads = editableReviewThreads({
|
|
645
|
+
attempts: threadAttempts,
|
|
646
|
+
maxThreadResolutionCycles: input.repository.merge.maxThreadResolutionCycles,
|
|
647
|
+
threads: unresolvedThreads,
|
|
648
|
+
});
|
|
649
|
+
if (!editableThreads.length) {
|
|
650
|
+
await input.onProgress?.({
|
|
651
|
+
status: "changes_unresolved",
|
|
652
|
+
type: "merge_completed",
|
|
653
|
+
});
|
|
654
|
+
return complete({
|
|
655
|
+
cycles: cycle - 1,
|
|
656
|
+
pr: input.pr,
|
|
657
|
+
status: "changes_unresolved",
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
await input.onProgress?.({
|
|
661
|
+
phase: `editing cycle ${cycle}`,
|
|
662
|
+
type: "phase",
|
|
663
|
+
});
|
|
664
|
+
const newlyExhausted = incrementReviewThreadAttempts({
|
|
665
|
+
attempts: threadAttempts,
|
|
666
|
+
cycle,
|
|
667
|
+
maxThreadResolutionCycles: input.repository.merge.maxThreadResolutionCycles,
|
|
668
|
+
threads: editableThreads,
|
|
669
|
+
});
|
|
670
|
+
await input.onProgress?.({
|
|
671
|
+
attempts: copyThreadAttempts(threadAttempts),
|
|
672
|
+
type: "thread_attempts",
|
|
673
|
+
});
|
|
674
|
+
if (!review.worktreePath)
|
|
675
|
+
throw new Error("Review worktree is missing");
|
|
676
|
+
const editorOutput = await runEditor(abortableInput, review.worktreePath, cycle, editableThreads);
|
|
677
|
+
editorOutputs.push(editorOutput);
|
|
678
|
+
dryRunThreads = input.dryRun
|
|
679
|
+
? appendDryRunEditorResponses({
|
|
680
|
+
author: editor.account,
|
|
681
|
+
output: editorOutput,
|
|
682
|
+
threads: dryRunThreads,
|
|
683
|
+
})
|
|
684
|
+
: dryRunThreads;
|
|
685
|
+
if (newlyExhausted.length) {
|
|
686
|
+
await input.onProgress?.({
|
|
687
|
+
threads: newlyExhausted.map((thread) => reviewThreadNotification(input.repository, input.pr, thread)),
|
|
688
|
+
type: "thread_limit_reached",
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
let ciFailureContext = "";
|
|
692
|
+
let reviewHeadSha = previousHeadSha;
|
|
693
|
+
if (editorOutput.mode === "EDITED") {
|
|
694
|
+
ciReports.length = 0;
|
|
695
|
+
const editedHeadSha = input.dryRun
|
|
696
|
+
? editorOutput.commitSha
|
|
697
|
+
: (await fetchPullRequest(exec, input.repository, input.pr))
|
|
698
|
+
.headRefOid;
|
|
699
|
+
if (!editedHeadSha)
|
|
700
|
+
throw new Error("Editor output did not include commitSha");
|
|
701
|
+
reviewHeadSha = editedHeadSha;
|
|
702
|
+
if (input.dryRun) {
|
|
703
|
+
await input.onProgress?.({
|
|
704
|
+
message: "Dry run skipped post-edit CI because editor changes were not pushed.",
|
|
705
|
+
type: "warning",
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
await input.onProgress?.({
|
|
710
|
+
phase: `waiting for checks after edit cycle ${cycle}`,
|
|
711
|
+
type: "phase",
|
|
712
|
+
});
|
|
713
|
+
const checkResult = await waitForChecksWithClassification({
|
|
714
|
+
afterEdit: {
|
|
715
|
+
cycle,
|
|
716
|
+
headSha: editedHeadSha,
|
|
717
|
+
previousHeadSha,
|
|
718
|
+
worktreePath: review.worktreePath,
|
|
719
|
+
},
|
|
720
|
+
client: input.client,
|
|
721
|
+
directory: input.directory,
|
|
722
|
+
exec,
|
|
723
|
+
headSha: editedHeadSha,
|
|
724
|
+
onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
|
|
725
|
+
pr: input.pr,
|
|
726
|
+
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
727
|
+
repository: input.repository,
|
|
728
|
+
signal: input.signal,
|
|
729
|
+
wait: input.repository.checks.waitAfterEdit,
|
|
730
|
+
});
|
|
731
|
+
ciFailureContext = checkResult?.ciFailureContext ?? "";
|
|
732
|
+
if (checkResult &&
|
|
733
|
+
(checkResult.report.scopeOutsideRecovered.length ||
|
|
734
|
+
checkResult.report.scopeOutsideUnresolved.length ||
|
|
735
|
+
checkResult.report.scopeInside.length)) {
|
|
736
|
+
ciReports.push(checkResult.report);
|
|
737
|
+
await input.onProgress?.({
|
|
738
|
+
report: checkResult.report,
|
|
739
|
+
type: "ci_report",
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
await input.onProgress?.({
|
|
745
|
+
phase: `rereview cycle ${cycle}`,
|
|
746
|
+
type: "phase",
|
|
747
|
+
});
|
|
748
|
+
reportCiReports = [...ciReports];
|
|
749
|
+
const rereview = await runRereview(abortableInput, review.worktreePath, previousHeadSha, cycle, review.sessionIds, ciFailureContext, {
|
|
750
|
+
dryRunHeadSha: input.dryRun ? reviewHeadSha : undefined,
|
|
751
|
+
dryRunThreads,
|
|
752
|
+
});
|
|
753
|
+
reportOutputs = rereview.outputs;
|
|
754
|
+
reportPosted = rereview.posted;
|
|
755
|
+
dryRunThreads = input.dryRun
|
|
756
|
+
? syntheticReviewThreads(reportOutputs)
|
|
757
|
+
: undefined;
|
|
758
|
+
previousHeadSha = input.dryRun
|
|
759
|
+
? reviewHeadSha
|
|
760
|
+
: (await fetchPullRequest(exec, input.repository, input.pr)).headRefOid;
|
|
761
|
+
if (rereview.verdict === "MERGE") {
|
|
762
|
+
const remainingThreads = input.dryRun
|
|
763
|
+
? flattenSyntheticThreads(dryRunThreads ?? {})
|
|
764
|
+
: await fetchUnresolvedThreads(exec, input.repository, input.pr);
|
|
765
|
+
recordReviewThreads({
|
|
766
|
+
attempts: threadAttempts,
|
|
767
|
+
cycle,
|
|
768
|
+
threads: remainingThreads,
|
|
769
|
+
});
|
|
770
|
+
await input.onProgress?.({
|
|
771
|
+
attempts: copyThreadAttempts(threadAttempts),
|
|
772
|
+
type: "thread_attempts",
|
|
773
|
+
});
|
|
774
|
+
if (exhaustedReviewThreads({
|
|
775
|
+
attempts: threadAttempts,
|
|
776
|
+
maxThreadResolutionCycles: input.repository.merge.maxThreadResolutionCycles,
|
|
777
|
+
threads: remainingThreads,
|
|
778
|
+
}).length) {
|
|
779
|
+
await input.onProgress?.({
|
|
780
|
+
status: "changes_unresolved",
|
|
781
|
+
type: "merge_completed",
|
|
782
|
+
});
|
|
783
|
+
return complete({
|
|
784
|
+
cycles: cycle,
|
|
785
|
+
pr: input.pr,
|
|
786
|
+
status: "changes_unresolved",
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (hasBlockingCiReports(ciReports)) {
|
|
790
|
+
await input.onProgress?.({
|
|
791
|
+
status: "ci_unresolved",
|
|
792
|
+
type: "merge_completed",
|
|
793
|
+
});
|
|
794
|
+
return complete({
|
|
795
|
+
cycles: cycle,
|
|
796
|
+
pr: input.pr,
|
|
797
|
+
status: "ci_unresolved",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (!input.repository.automation.merge || input.dryRun) {
|
|
801
|
+
await input.onProgress?.({
|
|
802
|
+
status: "approved",
|
|
803
|
+
type: "merge_completed",
|
|
804
|
+
});
|
|
805
|
+
return complete({ cycles: cycle, pr: input.pr, status: "approved" });
|
|
806
|
+
}
|
|
807
|
+
await input.onProgress?.({ phase: "merging PR", type: "phase" });
|
|
808
|
+
const status = await mergeWithQueue(input, exec, editor.account);
|
|
809
|
+
await input.onProgress?.({ status, type: "merge_completed" });
|
|
810
|
+
return complete({ cycles: cycle, pr: input.pr, status });
|
|
811
|
+
}
|
|
812
|
+
if (rereview.verdict === "CLOSE") {
|
|
813
|
+
if (!input.repository.automation.close || input.dryRun) {
|
|
814
|
+
await input.onProgress?.({
|
|
815
|
+
status: "close_requested",
|
|
816
|
+
type: "merge_completed",
|
|
817
|
+
});
|
|
818
|
+
return complete({
|
|
819
|
+
cycles: cycle,
|
|
820
|
+
pr: input.pr,
|
|
821
|
+
status: "close_requested",
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
await input.onProgress?.({ phase: "closing PR", type: "phase" });
|
|
825
|
+
await closePullRequest(exec, input.repository, input.pr, editor.account);
|
|
826
|
+
await input.onProgress?.({ status: "closed", type: "merge_completed" });
|
|
827
|
+
return complete({ cycles: cycle, pr: input.pr, status: "closed" });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
finally {
|
|
832
|
+
if (review.worktreePath) {
|
|
833
|
+
await removeWorktree(input.exec, review.worktreePath).catch(() => undefined);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|