opencode-magi 0.0.0-dev-20260519011027
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 +161 -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 +580 -0
- package/dist/config/worktree.js +13 -0
- package/dist/github/commands.js +398 -0
- package/dist/github/retry.js +44 -0
- package/dist/index.js +540 -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 +791 -0
- package/dist/orchestrator/run-manager.js +1670 -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 +67 -0
- package/schema.json +206 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { applyCheckExclusions, checkJobId, checkRunId, fetchCheckFailureLog, fetchPullRequestChecks, fetchWorkflowRunMeta, isCancelledCheck, isFailedCheck, rerunCheckJob, watchChecks, watchRun, } from "../github/commands";
|
|
4
|
+
import { composeCiClassificationAfterEditPrompt, composeCiClassificationPrompt, } from "../prompts/compose";
|
|
5
|
+
import { parseCiClassificationOutput } from "../prompts/output";
|
|
6
|
+
import { majorityThreshold } from "./majority";
|
|
7
|
+
import { runModelWithRepair } from "./model";
|
|
8
|
+
import { mapPool } from "./pool";
|
|
9
|
+
function cleanLogLine(line) {
|
|
10
|
+
return (line
|
|
11
|
+
// oxlint-disable-next-line no-control-regex
|
|
12
|
+
.replaceAll(/\u001B\[[0-9;]*m/g, "")
|
|
13
|
+
.replace(/^\S+\s+UNKNOWN STEP\s+/, "")
|
|
14
|
+
.replace(/^\d{4}-\d{2}-\d{2}T\S+Z\s*/, "")
|
|
15
|
+
.replace(/^##\[error\]/, "")
|
|
16
|
+
.trim());
|
|
17
|
+
}
|
|
18
|
+
function uniqueLimited(values, limit) {
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const result = [];
|
|
21
|
+
for (const value of values) {
|
|
22
|
+
const clean = value.trim();
|
|
23
|
+
if (!clean || seen.has(clean))
|
|
24
|
+
continue;
|
|
25
|
+
seen.add(clean);
|
|
26
|
+
result.push(clean);
|
|
27
|
+
if (result.length >= limit)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function compactRepeated(values, limit) {
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
const order = [];
|
|
35
|
+
for (const value of values) {
|
|
36
|
+
const clean = value.trim();
|
|
37
|
+
if (!clean)
|
|
38
|
+
continue;
|
|
39
|
+
if (!counts.has(clean))
|
|
40
|
+
order.push(clean);
|
|
41
|
+
counts.set(clean, (counts.get(clean) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
return order.slice(0, limit).map((value) => {
|
|
44
|
+
const count = counts.get(value) ?? 1;
|
|
45
|
+
return count > 1 ? `${value} (repeated ${count} times)` : value;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function representativeLog(lines, maxChars = 2_000) {
|
|
49
|
+
const selected = uniqueLimited(lines.filter((line) => {
|
|
50
|
+
return (/\b(error|failed|failure|exception|traceback|panic)\b/i.test(line) ||
|
|
51
|
+
/\b(assertionerror|rangeerror|timeouterror|typeerror)\b/i.test(line) ||
|
|
52
|
+
/\bFAIL(?:ED)?\b/.test(line) ||
|
|
53
|
+
/(?:\u276f|\bat\s+).+\.\w+(?::\d+)/.test(line));
|
|
54
|
+
}), 40).join("\n");
|
|
55
|
+
if (selected.length <= maxChars)
|
|
56
|
+
return selected;
|
|
57
|
+
return `${selected.slice(0, maxChars).trimEnd()}\n...`;
|
|
58
|
+
}
|
|
59
|
+
export function extractFailureEvidence(log) {
|
|
60
|
+
const lines = log
|
|
61
|
+
.replaceAll("\r\n", "\n")
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map(cleanLogLine)
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
const files = [];
|
|
66
|
+
const frames = [];
|
|
67
|
+
const errors = [];
|
|
68
|
+
const tests = [];
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
if (/\bFAIL(?:ED)?\b/.test(line))
|
|
71
|
+
tests.push(line);
|
|
72
|
+
if (/\b(?:RangeError|TypeError|ReferenceError|SyntaxError|AssertionError|TimeoutError|Error):\s+.+/i.test(line)) {
|
|
73
|
+
errors.push(line.replace(/^.*?((?:RangeError|TypeError|ReferenceError|SyntaxError|AssertionError|TimeoutError|Error):\s+.+)$/i, "$1"));
|
|
74
|
+
}
|
|
75
|
+
if (/\b(error|failed|failure|exception|traceback|panic|command failed|exit code)\b/i.test(line)) {
|
|
76
|
+
errors.push(line);
|
|
77
|
+
}
|
|
78
|
+
if (/(?:\u276f|\bat\s+).+\.\w+(?::\d+)/.test(line))
|
|
79
|
+
frames.push(line);
|
|
80
|
+
for (const match of line.matchAll(/(?:^|\s)([A-Za-z0-9_./-]+\.(?:cjs|css|jsx|mdx|mjs|scss|tsx|ts|js|vue|svelte))(?::\d+(?::\d+)?)?/g)) {
|
|
81
|
+
files.push(match[1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
errorMessages: uniqueLimited(errors, 5),
|
|
86
|
+
failingFiles: uniqueLimited(files, 8),
|
|
87
|
+
failingTests: uniqueLimited(tests, 8),
|
|
88
|
+
relevantFrames: compactRepeated(frames, 8),
|
|
89
|
+
representativeLog: representativeLog(lines),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function emptyReport() {
|
|
93
|
+
return {
|
|
94
|
+
attempts: 0,
|
|
95
|
+
classifierRuns: [],
|
|
96
|
+
dryRunRerun: [],
|
|
97
|
+
excluded: [],
|
|
98
|
+
failed: [],
|
|
99
|
+
rerun: [],
|
|
100
|
+
scopeInside: [],
|
|
101
|
+
scopeOutsideRecovered: [],
|
|
102
|
+
scopeOutsideUnresolved: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function compactLog(log, maxChars = 18_000) {
|
|
106
|
+
const lines = log.replaceAll("\r\n", "\n").split("\n");
|
|
107
|
+
const patterns = [
|
|
108
|
+
/\b(error|failed|failure|exception|traceback|panic)\b/i,
|
|
109
|
+
/\b(assertionerror|timeouterror|typeerror|referenceerror)\b/i,
|
|
110
|
+
/\b(exit code|exited with|command failed)\b/i,
|
|
111
|
+
/^\s*(FAIL|FAILED)\b/i,
|
|
112
|
+
];
|
|
113
|
+
const selected = new Map();
|
|
114
|
+
function includeRange(start, end) {
|
|
115
|
+
for (let index = Math.max(0, start); index < Math.min(lines.length, end); index += 1) {
|
|
116
|
+
selected.set(index, lines[index]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
includeRange(0, Math.min(40, lines.length));
|
|
120
|
+
includeRange(Math.max(0, lines.length - 220), lines.length);
|
|
121
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
122
|
+
if (patterns.some((pattern) => pattern.test(lines[index]))) {
|
|
123
|
+
includeRange(index - 12, index + 35);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const compacted = [...selected]
|
|
127
|
+
.sort(([left], [right]) => left - right)
|
|
128
|
+
.map(([index, line], position, all) => {
|
|
129
|
+
const previous = all[position - 1]?.[0];
|
|
130
|
+
const prefix = previous != null && index > previous + 1 ? "\n...\n" : "";
|
|
131
|
+
return `${prefix}${line}`;
|
|
132
|
+
})
|
|
133
|
+
.join("\n")
|
|
134
|
+
.trim();
|
|
135
|
+
if (compacted.length <= maxChars)
|
|
136
|
+
return compacted;
|
|
137
|
+
return [
|
|
138
|
+
compacted.slice(0, Math.floor(maxChars * 0.35)).trimEnd(),
|
|
139
|
+
"\n... log truncated ...\n",
|
|
140
|
+
compacted.slice(compacted.length - Math.floor(maxChars * 0.65)).trimStart(),
|
|
141
|
+
].join("");
|
|
142
|
+
}
|
|
143
|
+
function ciFailureContextForClassified(items, classified) {
|
|
144
|
+
const classifiedByName = new Map(classified.map((item) => [item.check.name, item]));
|
|
145
|
+
const sections = items
|
|
146
|
+
.filter((item) => classifiedByName.has(item.check.name))
|
|
147
|
+
.map((item) => {
|
|
148
|
+
const classifiedCheck = classifiedByName.get(item.check.name);
|
|
149
|
+
const evidence = item.evidence;
|
|
150
|
+
const lines = [
|
|
151
|
+
`## ${item.check.name} (${item.check.workflow || "unknown workflow"})`,
|
|
152
|
+
`State: ${item.check.state}`,
|
|
153
|
+
item.check.link ? `Link: ${item.check.link}` : "",
|
|
154
|
+
classifiedCheck?.reason
|
|
155
|
+
? `Classifier reason: ${classifiedCheck.reason}`
|
|
156
|
+
: "",
|
|
157
|
+
evidence.errorMessages.length
|
|
158
|
+
? `Errors:\n${evidence.errorMessages.map((line) => `- ${line}`).join("\n")}`
|
|
159
|
+
: "",
|
|
160
|
+
evidence.failingFiles.length
|
|
161
|
+
? `Files mentioned:\n${evidence.failingFiles.map((line) => `- ${line}`).join("\n")}`
|
|
162
|
+
: "",
|
|
163
|
+
evidence.failingTests.length
|
|
164
|
+
? `Failing tests:\n${evidence.failingTests.map((line) => `- ${line}`).join("\n")}`
|
|
165
|
+
: "",
|
|
166
|
+
evidence.relevantFrames.length
|
|
167
|
+
? `Relevant frames:\n${evidence.relevantFrames.map((line) => `- ${line}`).join("\n")}`
|
|
168
|
+
: "",
|
|
169
|
+
evidence.representativeLog
|
|
170
|
+
? `Representative log:\n\`\`\`text\n${evidence.representativeLog}\n\`\`\``
|
|
171
|
+
: "",
|
|
172
|
+
];
|
|
173
|
+
return lines.filter(Boolean).join("\n");
|
|
174
|
+
});
|
|
175
|
+
if (!sections.length)
|
|
176
|
+
return "";
|
|
177
|
+
return [
|
|
178
|
+
"CI has scope-in failures that may be caused by this PR.",
|
|
179
|
+
"Use this as a review hint; still inspect the PR diff before reporting findings.",
|
|
180
|
+
"",
|
|
181
|
+
...sections,
|
|
182
|
+
].join("\n\n");
|
|
183
|
+
}
|
|
184
|
+
async function checksWithLogs(exec, repository, checks) {
|
|
185
|
+
return Promise.all(checks.map(async (check) => {
|
|
186
|
+
const jobId = checkJobId(check);
|
|
187
|
+
const rawLog = jobId
|
|
188
|
+
? await fetchCheckFailureLog(exec, repository, jobId).catch((error) => `Could not fetch failed log: ${error.message}`)
|
|
189
|
+
: "This check is not a GitHub Actions job and cannot be rerun.";
|
|
190
|
+
const log = compactLog(rawLog);
|
|
191
|
+
return { check, evidence: extractFailureEvidence(log), jobId };
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
function errorMessage(error) {
|
|
195
|
+
return error instanceof Error ? error.message : String(error);
|
|
196
|
+
}
|
|
197
|
+
function isPendingCheck(check) {
|
|
198
|
+
return (check.bucket === "pending" ||
|
|
199
|
+
check.state === "ACTION_REQUIRED" ||
|
|
200
|
+
check.state === "EXPECTED" ||
|
|
201
|
+
check.state === "IN_PROGRESS" ||
|
|
202
|
+
check.state === "PENDING" ||
|
|
203
|
+
check.state === "QUEUED" ||
|
|
204
|
+
check.state === "REQUESTED" ||
|
|
205
|
+
check.state === "WAITING");
|
|
206
|
+
}
|
|
207
|
+
function cancelledClassification(check) {
|
|
208
|
+
return {
|
|
209
|
+
check,
|
|
210
|
+
classification: "SCOPE_OUT",
|
|
211
|
+
reason: "Check was cancelled; rerun without CI scope classification.",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function watchRerunRuns(exec, repository, checks) {
|
|
215
|
+
const runIds = [
|
|
216
|
+
...new Set(checks.flatMap((item) => {
|
|
217
|
+
const runId = checkRunId(item.check);
|
|
218
|
+
return runId ? [runId] : [];
|
|
219
|
+
})),
|
|
220
|
+
];
|
|
221
|
+
await Promise.all(runIds.map((runId) => watchRun(exec, repository, runId)));
|
|
222
|
+
}
|
|
223
|
+
async function checksForHead(input) {
|
|
224
|
+
const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr);
|
|
225
|
+
const targetChecks = [];
|
|
226
|
+
let hasAnyActionCheck = false;
|
|
227
|
+
let hasTargetActionCheck = false;
|
|
228
|
+
for (const check of checks) {
|
|
229
|
+
const runId = checkRunId(check);
|
|
230
|
+
if (!input.headSha || !runId) {
|
|
231
|
+
targetChecks.push(check);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
hasAnyActionCheck = true;
|
|
235
|
+
const runHead = await runHeadSha({
|
|
236
|
+
exec: input.exec,
|
|
237
|
+
repository: input.repository,
|
|
238
|
+
runHeadCache: input.runHeadCache,
|
|
239
|
+
runId,
|
|
240
|
+
});
|
|
241
|
+
if (runHead === input.headSha) {
|
|
242
|
+
hasTargetActionCheck = true;
|
|
243
|
+
targetChecks.push(check);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
blocking: targetChecks.filter((check) => isFailedCheck(check) || isCancelledCheck(check)),
|
|
248
|
+
hasAnyActionCheck,
|
|
249
|
+
hasAnyCheck: checks.length > 0,
|
|
250
|
+
hasPending: targetChecks.some(isPendingCheck),
|
|
251
|
+
hasTargetActionCheck,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function runHeadSha(input) {
|
|
255
|
+
const cached = input.runHeadCache.get(input.runId);
|
|
256
|
+
if (cached)
|
|
257
|
+
return cached;
|
|
258
|
+
const promise = fetchWorkflowRunMeta(input.exec, input.repository, input.runId)
|
|
259
|
+
.then((run) => run.headSha || undefined)
|
|
260
|
+
.catch(() => undefined);
|
|
261
|
+
input.runHeadCache.set(input.runId, promise);
|
|
262
|
+
return promise;
|
|
263
|
+
}
|
|
264
|
+
async function sleep(ms) {
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
+
}
|
|
267
|
+
async function classifyChecks(input) {
|
|
268
|
+
const reviewers = input.repository.agents.reviewers;
|
|
269
|
+
const classifierRuns = [];
|
|
270
|
+
const names = new Set(input.checks.map((item) => item.check.name));
|
|
271
|
+
const checks = input.checks.map((item) => ({
|
|
272
|
+
evidence: item.evidence,
|
|
273
|
+
link: item.check.link,
|
|
274
|
+
name: item.check.name,
|
|
275
|
+
state: item.check.state,
|
|
276
|
+
workflow: item.check.workflow,
|
|
277
|
+
}));
|
|
278
|
+
const prompt = input.afterEdit
|
|
279
|
+
? await composeCiClassificationAfterEditPrompt({
|
|
280
|
+
...input.afterEdit,
|
|
281
|
+
checks,
|
|
282
|
+
directory: input.directory,
|
|
283
|
+
pr: input.pr,
|
|
284
|
+
repository: input.repository,
|
|
285
|
+
})
|
|
286
|
+
: await composeCiClassificationPrompt({
|
|
287
|
+
checks,
|
|
288
|
+
directory: input.directory,
|
|
289
|
+
pr: input.pr,
|
|
290
|
+
repository: input.repository,
|
|
291
|
+
});
|
|
292
|
+
if (!reviewers.length) {
|
|
293
|
+
return {
|
|
294
|
+
classified: input.checks.map((item) => ({
|
|
295
|
+
check: item.check,
|
|
296
|
+
classification: "SCOPE_IN",
|
|
297
|
+
reason: "No reviewer model is configured for CI classification.",
|
|
298
|
+
})),
|
|
299
|
+
classifierRuns,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
if (input.outputDir)
|
|
303
|
+
await mkdir(input.outputDir, { recursive: true });
|
|
304
|
+
const votes = await mapPool(reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
305
|
+
const run = {
|
|
306
|
+
repairAttempts: 0,
|
|
307
|
+
reviewer: reviewer.key,
|
|
308
|
+
status: "running",
|
|
309
|
+
};
|
|
310
|
+
const promptPath = input.outputDir
|
|
311
|
+
? join(input.outputDir, `${reviewer.key}.ci-classification.prompt.txt`)
|
|
312
|
+
: undefined;
|
|
313
|
+
run.promptPath = promptPath;
|
|
314
|
+
classifierRuns.push(run);
|
|
315
|
+
if (promptPath)
|
|
316
|
+
await writeFile(promptPath, prompt);
|
|
317
|
+
await input.onClassifierProgress?.({
|
|
318
|
+
promptPath,
|
|
319
|
+
reviewer: reviewer.key,
|
|
320
|
+
type: "classifier_started",
|
|
321
|
+
});
|
|
322
|
+
try {
|
|
323
|
+
const result = await runModelWithRepair({
|
|
324
|
+
client: input.client,
|
|
325
|
+
model: reviewer.model,
|
|
326
|
+
onProgress: async (progress) => {
|
|
327
|
+
if (progress.type === "session_created") {
|
|
328
|
+
run.sessionId = progress.sessionId;
|
|
329
|
+
await input.onClassifierProgress?.({
|
|
330
|
+
reviewer: reviewer.key,
|
|
331
|
+
runAttempt: progress.runAttempt,
|
|
332
|
+
sessionId: progress.sessionId,
|
|
333
|
+
type: "classifier_session",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
if (progress.type === "repair") {
|
|
337
|
+
run.status = "repairing";
|
|
338
|
+
run.repairAttempts += 1;
|
|
339
|
+
await input.onClassifierProgress?.({
|
|
340
|
+
reviewer: reviewer.key,
|
|
341
|
+
type: "classifier_repair",
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
options: reviewer.options,
|
|
346
|
+
parse: (text) => {
|
|
347
|
+
const output = parseCiClassificationOutput(text);
|
|
348
|
+
for (const check of output.checks) {
|
|
349
|
+
if (!names.has(check.name))
|
|
350
|
+
throw new Error(`unexpected CI check classification: ${check.name}`);
|
|
351
|
+
}
|
|
352
|
+
for (const name of names) {
|
|
353
|
+
if (!output.checks.some((check) => check.name === name))
|
|
354
|
+
throw new Error(`missing CI check classification: ${name}`);
|
|
355
|
+
}
|
|
356
|
+
return output;
|
|
357
|
+
},
|
|
358
|
+
permission: reviewer.permission,
|
|
359
|
+
prompt,
|
|
360
|
+
repairAttempts: input.repairAttempts,
|
|
361
|
+
runAttempts: 2,
|
|
362
|
+
schemaName: "CI classification",
|
|
363
|
+
signal: input.signal,
|
|
364
|
+
title: `magi classify ci ${input.repository.alias}#${input.pr} ${reviewer.key}`,
|
|
365
|
+
});
|
|
366
|
+
const rawPath = input.outputDir
|
|
367
|
+
? join(input.outputDir, `${reviewer.key}.ci-classification.raw.txt`)
|
|
368
|
+
: undefined;
|
|
369
|
+
const check = result.value.checks[0];
|
|
370
|
+
if (rawPath)
|
|
371
|
+
await writeFile(rawPath, result.raw);
|
|
372
|
+
run.classification = check?.classification;
|
|
373
|
+
run.rawPath = rawPath;
|
|
374
|
+
run.reason = check?.reason;
|
|
375
|
+
run.sessionId = result.sessionId;
|
|
376
|
+
run.status = "completed";
|
|
377
|
+
await input.onClassifierProgress?.({
|
|
378
|
+
classification: check?.classification ?? "SCOPE_IN",
|
|
379
|
+
rawPath,
|
|
380
|
+
reason: check?.reason ?? "No classification reason was provided.",
|
|
381
|
+
reviewer: reviewer.key,
|
|
382
|
+
sessionId: result.sessionId,
|
|
383
|
+
type: "classifier_completed",
|
|
384
|
+
});
|
|
385
|
+
return { reviewer: reviewer.key, output: result.value };
|
|
386
|
+
}
|
|
387
|
+
catch (error) {
|
|
388
|
+
run.error = errorMessage(error);
|
|
389
|
+
run.status = "failed";
|
|
390
|
+
await input.onClassifierProgress?.({
|
|
391
|
+
error: run.error,
|
|
392
|
+
reviewer: reviewer.key,
|
|
393
|
+
type: "classifier_failed",
|
|
394
|
+
});
|
|
395
|
+
return { reviewer: reviewer.key, output: undefined };
|
|
396
|
+
}
|
|
397
|
+
}, { signal: input.signal });
|
|
398
|
+
const threshold = majorityThreshold(reviewers.length);
|
|
399
|
+
return {
|
|
400
|
+
classified: input.checks.map((item) => {
|
|
401
|
+
const successfulVotes = votes.filter((vote) => vote.output);
|
|
402
|
+
const checkVotes = successfulVotes.map((vote) => {
|
|
403
|
+
const check = vote.output?.checks.find((output) => output.name === item.check.name);
|
|
404
|
+
return {
|
|
405
|
+
classification: check?.classification ?? "SCOPE_IN",
|
|
406
|
+
reason: check?.reason ?? "Missing classification; treated as scope-in.",
|
|
407
|
+
reviewer: vote.reviewer,
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
const failures = votes.filter((vote) => !vote.output);
|
|
411
|
+
const scopeIn = checkVotes.filter((vote) => vote.classification === "SCOPE_IN");
|
|
412
|
+
const scopeOut = checkVotes.filter((vote) => vote.classification === "SCOPE_OUT");
|
|
413
|
+
const classification = scopeOut.length >= threshold
|
|
414
|
+
? "SCOPE_OUT"
|
|
415
|
+
: scopeIn.length >= threshold
|
|
416
|
+
? "SCOPE_IN"
|
|
417
|
+
: undefined;
|
|
418
|
+
if (!classification) {
|
|
419
|
+
throw new Error(`CI classification did not reach majority for ${item.check.name}`);
|
|
420
|
+
}
|
|
421
|
+
const reasons = checkVotes
|
|
422
|
+
.filter((vote) => vote.classification === classification)
|
|
423
|
+
.map((vote) => `${vote.reviewer}: ${vote.reason}`);
|
|
424
|
+
for (const failure of failures) {
|
|
425
|
+
reasons.push(`${failure.reviewer}: classifier failed; vote ignored`);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
check: item.check,
|
|
429
|
+
classification,
|
|
430
|
+
reason: reasons.join("; ") || "No majority reason was provided.",
|
|
431
|
+
};
|
|
432
|
+
}),
|
|
433
|
+
classifierRuns,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
export async function waitForChecksWithClassification(input) {
|
|
437
|
+
const report = emptyReport();
|
|
438
|
+
let investigated = false;
|
|
439
|
+
const runHeadCache = new Map();
|
|
440
|
+
async function readTargetChecks() {
|
|
441
|
+
return checksForHead({
|
|
442
|
+
exec: input.exec,
|
|
443
|
+
headSha: input.headSha,
|
|
444
|
+
pr: input.pr,
|
|
445
|
+
repository: input.repository,
|
|
446
|
+
runHeadCache,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
async function assignBlockingChecks(checks) {
|
|
450
|
+
report.failed = applyCheckExclusions({
|
|
451
|
+
checks,
|
|
452
|
+
excluded: report.excluded,
|
|
453
|
+
patterns: input.repository.checks.exclude,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (input.wait) {
|
|
457
|
+
await input.onProgress?.("waiting for CI checks");
|
|
458
|
+
for (let attempt = 0;; attempt += 1) {
|
|
459
|
+
try {
|
|
460
|
+
await watchChecks(input.exec, input.repository, input.pr);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// gh exits non-zero for pending checks too; re-read check state below.
|
|
464
|
+
}
|
|
465
|
+
const target = await readTargetChecks();
|
|
466
|
+
const waitingForTargetHead = Boolean(input.headSha) &&
|
|
467
|
+
(!target.hasAnyCheck ||
|
|
468
|
+
(target.hasAnyActionCheck && !target.hasTargetActionCheck));
|
|
469
|
+
if (!waitingForTargetHead && !target.hasPending) {
|
|
470
|
+
await assignBlockingChecks(target.blocking);
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
if (attempt >= (input.waitPollLimit ?? 60)) {
|
|
474
|
+
await assignBlockingChecks(target.blocking);
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
await sleep(input.waitPollIntervalMs ?? 1_000);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
await assignBlockingChecks((await readTargetChecks()).blocking);
|
|
482
|
+
}
|
|
483
|
+
if (report.failed.length && !investigated) {
|
|
484
|
+
await input.onProgress?.("investigating failed CI checks");
|
|
485
|
+
}
|
|
486
|
+
if (!report.failed.length && input.wait && !investigated) {
|
|
487
|
+
await input.onProgress?.("CI checks passed");
|
|
488
|
+
}
|
|
489
|
+
for (;;) {
|
|
490
|
+
if (!report.failed.length)
|
|
491
|
+
return { ciFailureContext: "", report };
|
|
492
|
+
const cancelled = report.failed.filter(isCancelledCheck);
|
|
493
|
+
const failed = report.failed.filter((check) => !isCancelledCheck(check));
|
|
494
|
+
const scopeOut = cancelled.map(cancelledClassification);
|
|
495
|
+
let scopeIn = [];
|
|
496
|
+
let withLogs = [];
|
|
497
|
+
if (failed.length) {
|
|
498
|
+
await input.onProgress?.("fetching failed CI logs");
|
|
499
|
+
withLogs = await checksWithLogs(input.exec, input.repository, failed);
|
|
500
|
+
await input.onProgress?.("classifying CI failures");
|
|
501
|
+
const classifiedResult = await classifyChecks({
|
|
502
|
+
afterEdit: input.afterEdit,
|
|
503
|
+
checks: withLogs,
|
|
504
|
+
client: input.client,
|
|
505
|
+
directory: input.directory,
|
|
506
|
+
onClassifierProgress: input.onClassifierProgress,
|
|
507
|
+
outputDir: input.outputDir,
|
|
508
|
+
pr: input.pr,
|
|
509
|
+
repairAttempts: input.repairAttempts,
|
|
510
|
+
repository: input.repository,
|
|
511
|
+
signal: input.signal,
|
|
512
|
+
});
|
|
513
|
+
const classified = classifiedResult.classified;
|
|
514
|
+
report.classifierRuns?.push(...classifiedResult.classifierRuns);
|
|
515
|
+
scopeIn = classified.filter((item) => item.classification === "SCOPE_IN");
|
|
516
|
+
scopeOut.push(...classified.filter((item) => item.classification === "SCOPE_OUT"));
|
|
517
|
+
}
|
|
518
|
+
if (scopeIn.length) {
|
|
519
|
+
await input.onProgress?.("CI failures classified as scope-in");
|
|
520
|
+
report.scopeInside.push(...scopeIn);
|
|
521
|
+
report.scopeOutsideUnresolved.push(...scopeOut);
|
|
522
|
+
return {
|
|
523
|
+
ciFailureContext: ciFailureContextForClassified(withLogs, scopeIn),
|
|
524
|
+
report,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const rerunnable = scopeOut.filter((item) => checkJobId(item.check));
|
|
528
|
+
const notRerunnable = scopeOut.filter((item) => !checkJobId(item.check));
|
|
529
|
+
if (notRerunnable.length ||
|
|
530
|
+
report.attempts >= input.repository.checks.retryFailedJobs) {
|
|
531
|
+
await input.onProgress?.("scope-out CI failures remain unresolved");
|
|
532
|
+
report.scopeOutsideUnresolved.push(...scopeOut);
|
|
533
|
+
return { ciFailureContext: "", report };
|
|
534
|
+
}
|
|
535
|
+
if (input.dryRun) {
|
|
536
|
+
report.dryRunRerun?.push(...rerunnable);
|
|
537
|
+
report.scopeOutsideUnresolved.push(...scopeOut);
|
|
538
|
+
await input.onProgress?.("dry-run skipping scope-out CI reruns");
|
|
539
|
+
return { ciFailureContext: "", report };
|
|
540
|
+
}
|
|
541
|
+
report.attempts += 1;
|
|
542
|
+
report.rerun.push(...rerunnable);
|
|
543
|
+
await input.onProgress?.("rerunning scope-out CI jobs");
|
|
544
|
+
await Promise.all(rerunnable.map((item) => rerunCheckJob(input.exec, input.repository, checkJobId(item.check) ?? "")));
|
|
545
|
+
try {
|
|
546
|
+
await input.onProgress?.("waiting for rerun CI checks");
|
|
547
|
+
await watchRerunRuns(input.exec, input.repository, rerunnable);
|
|
548
|
+
if (input.wait)
|
|
549
|
+
await watchChecks(input.exec, input.repository, input.pr);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// Re-read the PR checks below so stale failed checks are not trusted.
|
|
553
|
+
}
|
|
554
|
+
report.failed = applyCheckExclusions({
|
|
555
|
+
checks: (await readTargetChecks()).blocking,
|
|
556
|
+
excluded: report.excluded,
|
|
557
|
+
patterns: input.repository.checks.exclude,
|
|
558
|
+
});
|
|
559
|
+
if (!report.failed.length) {
|
|
560
|
+
await input.onProgress?.("rerun CI checks passed");
|
|
561
|
+
report.scopeOutsideRecovered.push(...rerunnable);
|
|
562
|
+
return { ciFailureContext: "", report };
|
|
563
|
+
}
|
|
564
|
+
if (report.failed.length) {
|
|
565
|
+
await input.onProgress?.("investigating failed CI checks");
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { majorityThreshold } from "./majority";
|
|
2
|
+
export function reviewFindingTargets(outputs) {
|
|
3
|
+
return Object.entries(outputs).flatMap(([reviewer, output]) => {
|
|
4
|
+
if (output.verdict !== "CHANGES_REQUESTED")
|
|
5
|
+
return [];
|
|
6
|
+
return output.findings.map((finding, findingIndex) => ({
|
|
7
|
+
finding,
|
|
8
|
+
findingIndex,
|
|
9
|
+
reviewer,
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export function validateFindingVotes(input) {
|
|
14
|
+
const expected = input.targets.filter((target) => target.reviewer !== input.validator);
|
|
15
|
+
const expectedKeys = new Set(expected.map((target) => `${target.reviewer}:${target.findingIndex}`));
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (const vote of input.output.votes) {
|
|
18
|
+
if (vote.reviewer === input.validator) {
|
|
19
|
+
throw new Error(`${input.validator} must not vote on its own findings`);
|
|
20
|
+
}
|
|
21
|
+
const key = `${vote.reviewer}:${vote.findingIndex}`;
|
|
22
|
+
if (!expectedKeys.has(key))
|
|
23
|
+
throw new Error(`unexpected finding vote: ${key}`);
|
|
24
|
+
if (seen.has(key))
|
|
25
|
+
throw new Error(`duplicate finding vote: ${key}`);
|
|
26
|
+
seen.add(key);
|
|
27
|
+
}
|
|
28
|
+
for (const target of expected) {
|
|
29
|
+
const key = `${target.reviewer}:${target.findingIndex}`;
|
|
30
|
+
if (!seen.has(key))
|
|
31
|
+
throw new Error(`missing finding vote: ${key}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function applyFindingValidation(input) {
|
|
35
|
+
const threshold = majorityThreshold(input.reviewerKeys.length);
|
|
36
|
+
const kept = [];
|
|
37
|
+
const discarded = [];
|
|
38
|
+
const next = {};
|
|
39
|
+
for (const [reviewer, output] of Object.entries(input.outputs)) {
|
|
40
|
+
if (output.verdict !== "CHANGES_REQUESTED") {
|
|
41
|
+
next[reviewer] = output;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const findings = output.findings.filter((finding, findingIndex) => {
|
|
45
|
+
let agrees = 1;
|
|
46
|
+
for (const validator of input.reviewerKeys) {
|
|
47
|
+
if (validator === reviewer)
|
|
48
|
+
continue;
|
|
49
|
+
const vote = input.validations[validator]?.votes.find((item) => item.reviewer === reviewer && item.findingIndex === findingIndex);
|
|
50
|
+
if (vote?.vote === "AGREE")
|
|
51
|
+
agrees += 1;
|
|
52
|
+
}
|
|
53
|
+
const target = { finding, findingIndex, reviewer };
|
|
54
|
+
if (agrees >= threshold) {
|
|
55
|
+
kept.push(target);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
discarded.push(target);
|
|
59
|
+
return false;
|
|
60
|
+
});
|
|
61
|
+
next[reviewer] = findings.length
|
|
62
|
+
? { ...output, findings }
|
|
63
|
+
: { findings: [], verdict: "MERGE" };
|
|
64
|
+
}
|
|
65
|
+
return { outputs: next, summary: { discarded, kept } };
|
|
66
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const VERDICTS = ["MERGE", "CHANGES_REQUESTED", "CLOSE"];
|
|
2
|
+
export function majorityThreshold(total) {
|
|
3
|
+
return Math.floor(total / 2) + 1;
|
|
4
|
+
}
|
|
5
|
+
export function aggregateMajority(results) {
|
|
6
|
+
if (results.length < 3 || results.length % 2 === 0) {
|
|
7
|
+
throw new Error("majority requires an odd number of at least 3 reviewer results");
|
|
8
|
+
}
|
|
9
|
+
const counts = {
|
|
10
|
+
CHANGES_REQUESTED: 0,
|
|
11
|
+
CLOSE: 0,
|
|
12
|
+
MERGE: 0,
|
|
13
|
+
};
|
|
14
|
+
const reviewers = {
|
|
15
|
+
CHANGES_REQUESTED: [],
|
|
16
|
+
CLOSE: [],
|
|
17
|
+
MERGE: [],
|
|
18
|
+
};
|
|
19
|
+
for (const result of results) {
|
|
20
|
+
counts[result.verdict] += 1;
|
|
21
|
+
reviewers[result.verdict].push(result.reviewer);
|
|
22
|
+
}
|
|
23
|
+
const threshold = majorityThreshold(results.length);
|
|
24
|
+
const verdict = VERDICTS.find((item) => counts[item] >= threshold);
|
|
25
|
+
if (!verdict)
|
|
26
|
+
throw new Error("no majority verdict");
|
|
27
|
+
return { counts, reviewers, threshold, verdict };
|
|
28
|
+
}
|
|
29
|
+
export function reviewOutputsToVerdicts(outputs) {
|
|
30
|
+
return Object.entries(outputs).map(([reviewer, output]) => ({
|
|
31
|
+
reviewer,
|
|
32
|
+
verdict: output.verdict,
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
export function mergeVerdictForPolicy(results, policy) {
|
|
36
|
+
const majority = aggregateMajority(results);
|
|
37
|
+
if (majority.verdict === "CLOSE")
|
|
38
|
+
return "CLOSE";
|
|
39
|
+
if (policy === "majority")
|
|
40
|
+
return majority.verdict;
|
|
41
|
+
return majority.counts.MERGE === results.length
|
|
42
|
+
? "MERGE"
|
|
43
|
+
: "CHANGES_REQUESTED";
|
|
44
|
+
}
|
|
45
|
+
export function closeMinorityReviewers(results) {
|
|
46
|
+
const majority = aggregateMajority(results);
|
|
47
|
+
return majority.verdict === "CLOSE" ? [] : majority.reviewers.CLOSE;
|
|
48
|
+
}
|