opencode-magi 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +254 -0
- package/README.md +37 -34
- package/dist/commands.js +11 -9
- package/dist/config/index.js +2 -0
- package/dist/config/resolve.js +120 -248
- package/dist/config/validate.js +103 -1100
- package/dist/constant.js +135 -0
- package/dist/graphql/index.generated.js +125 -0
- package/dist/graphql/index.js +49 -0
- package/dist/index.js +12 -800
- package/dist/magi.js +357 -0
- package/dist/permissions/common.json +22 -22
- package/dist/permissions/editor.json +12 -12
- package/dist/prompts/index.js +98 -0
- package/dist/prompts/merge/ci-classification/output-contract.md +22 -0
- package/dist/prompts/merge/ci-classification/task.md +10 -0
- package/dist/prompts/merge/ci-classification/validate.json +20 -0
- package/dist/prompts/merge/conflict/output-contract.md +12 -0
- package/dist/prompts/merge/conflict/task.md +9 -0
- package/dist/prompts/merge/conflict/validate.json +4 -0
- package/dist/prompts/merge/edit/output-contract.md +26 -0
- package/dist/prompts/merge/edit/task.md +10 -0
- package/dist/prompts/merge/edit/validate.json +60 -0
- package/dist/prompts/review/ci-classification/output-contract.md +21 -0
- package/dist/prompts/review/ci-classification/task.md +7 -0
- package/dist/prompts/review/ci-classification/validate.json +20 -0
- package/dist/prompts/review/close-reconsideration/output-contract.md +21 -0
- package/dist/prompts/review/close-reconsideration/task.md +6 -0
- package/dist/prompts/review/close-reconsideration/validate.json +44 -0
- package/dist/prompts/review/comment/output-contract.md +11 -0
- package/dist/prompts/review/comment/task.md +3 -0
- package/dist/prompts/review/comment/validate.json +8 -0
- package/dist/prompts/review/finding-validation/output-contract.md +25 -0
- package/dist/prompts/{templates/review/finding-validation.md → review/finding-validation/task.md} +2 -3
- package/dist/prompts/review/finding-validation/validate.json +21 -0
- package/dist/prompts/review/rereview/output-contract.md +30 -0
- package/dist/prompts/{templates/review/rereview.md → review/rereview/task.md} +8 -11
- package/dist/prompts/review/rereview/validate.json +87 -0
- package/dist/prompts/review/review/output-contract.md +25 -0
- package/dist/prompts/{templates/review/review.md → review/review/task.md} +2 -5
- package/dist/prompts/review/review/validate.json +54 -0
- package/dist/prompts/triage/acceptance/output-contract.md +16 -0
- package/dist/prompts/triage/acceptance/validate.json +23 -0
- package/dist/prompts/triage/category/output-contract.md +16 -0
- package/dist/prompts/triage/category/validate.json +23 -0
- package/dist/prompts/triage/comment-classification/output-contract.md +15 -0
- package/dist/prompts/triage/comment-classification/validate.json +28 -0
- package/dist/prompts/triage/create/output-contract.md +28 -0
- package/dist/prompts/triage/create/validate.json +73 -0
- package/dist/prompts/triage/deplicate/output-contract.md +16 -0
- package/dist/prompts/triage/deplicate/validate.json +20 -0
- package/dist/prompts/triage/existing/output-contract.md +16 -0
- package/dist/prompts/triage/existing/validate.json +13 -0
- package/dist/prompts/triage/reconsider/output-contract.md +16 -0
- package/dist/prompts/triage/reconsider/validate.json +23 -0
- package/dist/prompts/triage/signal/output-contract.md +19 -0
- package/dist/prompts/triage/signal/validate.json +18 -0
- package/dist/tools/clear/index.js +21 -0
- package/dist/tools/index.js +12 -0
- package/dist/tools/merge/action.js +47 -0
- package/dist/tools/merge/context.js +118 -0
- package/dist/tools/merge/editor.js +264 -0
- package/dist/tools/merge/index.js +151 -0
- package/dist/tools/merge/index.type.js +1 -0
- package/dist/tools/merge/merge.js +42 -0
- package/dist/tools/merge/report.js +73 -0
- package/dist/tools/review/action.js +429 -0
- package/dist/tools/review/check.js +295 -0
- package/dist/tools/review/context.js +258 -0
- package/dist/tools/review/index.js +104 -0
- package/dist/tools/review/index.type.js +1 -0
- package/dist/tools/review/report.js +143 -0
- package/dist/tools/review/review.js +145 -0
- package/dist/tools/review/reviewer.js +409 -0
- package/dist/tools/triage/index.js +16 -0
- package/dist/tools/validate/index.js +28 -0
- package/dist/utils/array.js +6 -0
- package/dist/utils/assertion.js +38 -0
- package/dist/utils/exec.js +14 -0
- package/dist/utils/fs.js +24 -0
- package/dist/utils/function.js +37 -0
- package/dist/utils/github.js +63 -0
- package/dist/utils/index.js +10 -0
- package/dist/utils/index.type.js +1 -0
- package/dist/utils/object.js +21 -0
- package/dist/utils/opencode.js +25 -0
- package/dist/utils/string.js +38 -0
- package/dist/utils/worker.js +30 -0
- package/package.json +20 -8
- package/schema.json +9 -7
- package/dist/config/load.js +0 -62
- package/dist/config/output.js +0 -25
- package/dist/config/worktree.js +0 -25
- package/dist/github/commands.js +0 -905
- package/dist/github/retry.js +0 -44
- package/dist/orchestrator/abort.js +0 -9
- package/dist/orchestrator/ci.js +0 -579
- package/dist/orchestrator/findings.js +0 -88
- package/dist/orchestrator/inline-comments.js +0 -73
- package/dist/orchestrator/majority.js +0 -62
- package/dist/orchestrator/merge.js +0 -1260
- package/dist/orchestrator/model.js +0 -216
- package/dist/orchestrator/pool.js +0 -15
- package/dist/orchestrator/report.js +0 -175
- package/dist/orchestrator/review-context.js +0 -342
- package/dist/orchestrator/review.js +0 -1478
- package/dist/orchestrator/run-manager.js +0 -2132
- package/dist/orchestrator/safety.js +0 -44
- package/dist/orchestrator/triage.js +0 -1370
- package/dist/prompts/compose.js +0 -473
- package/dist/prompts/contracts.js +0 -300
- package/dist/prompts/output.js +0 -401
- package/dist/prompts/templates/merge/ci-classification.md +0 -16
- package/dist/prompts/templates/merge/conflict.md +0 -10
- package/dist/prompts/templates/merge/edit.md +0 -15
- package/dist/prompts/templates/review/ci-classification.md +0 -9
- package/dist/prompts/templates/review/close-reconsideration.md +0 -6
- /package/dist/{types.js → config/index.type.js} +0 -0
- /package/dist/prompts/{templates/triage/acceptance.md → triage/acceptance/task.md} +0 -0
- /package/dist/prompts/{templates/triage/category.md → triage/category/task.md} +0 -0
- /package/dist/prompts/{templates/triage/comment-classification.md → triage/comment-classification/task.md} +0 -0
- /package/dist/prompts/{templates/triage/create.md → triage/create/task.md} +0 -0
- /package/dist/prompts/{templates/triage/duplicate.md → triage/deplicate/task.md} +0 -0
- /package/dist/prompts/{templates/triage/existing-pr.md → triage/existing/task.md} +0 -0
- /package/dist/prompts/{templates/triage/reconsider.md → triage/reconsider/task.md} +0 -0
- /package/dist/prompts/{templates/triage/signal.md → triage/signal/task.md} +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
import { MagiError } from "@/magi";
|
|
3
|
+
import { Prompt } from "@/prompts";
|
|
4
|
+
import { command, filterEmpty, isNumber, omitNullish, quote, retry, Worker, } from "@/utils";
|
|
5
|
+
const ci = {
|
|
6
|
+
isExcluded(exclude, { name }) {
|
|
7
|
+
return exclude.some((pattern) => {
|
|
8
|
+
if (pattern.startsWith("/") &&
|
|
9
|
+
pattern.endsWith("/") &&
|
|
10
|
+
pattern.length > 1)
|
|
11
|
+
return new RegExp(pattern.slice(1, -1)).test(name);
|
|
12
|
+
return pattern === name;
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
isFailed(check) {
|
|
16
|
+
return check.bucket === "fail" || check.state === "FAILURE";
|
|
17
|
+
},
|
|
18
|
+
isPassed(check) {
|
|
19
|
+
return check.bucket === "pass" || check.state === "SUCCESS";
|
|
20
|
+
},
|
|
21
|
+
isPending(check) {
|
|
22
|
+
return !this.isFailed(check) && !this.isPassed(check);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export async function checkPr() {
|
|
26
|
+
this.context.abort.throwIfAborted();
|
|
27
|
+
await this.updateState({ status: "running" });
|
|
28
|
+
await this.updateEvent(`Checking PR.`);
|
|
29
|
+
const { files, metadata } = await getMetadata.call(this);
|
|
30
|
+
if (metadata.state !== "open")
|
|
31
|
+
throw new MagiError("blocked", `PR is not open.`);
|
|
32
|
+
if (metadata.draft)
|
|
33
|
+
throw new MagiError("blocked", `PR is a draft.`);
|
|
34
|
+
const errors = [];
|
|
35
|
+
if (this.config.review.safety.allowAuthors.length)
|
|
36
|
+
if (!this.config.review.safety.allowAuthors.includes(metadata.user.login))
|
|
37
|
+
errors.push(`Author is not allowed: ${metadata.user.login}.`);
|
|
38
|
+
if (this.config.review.safety.requiredLabels.length) {
|
|
39
|
+
const missingLabels = this.config.review.safety.requiredLabels.filter((label) => !metadata.labels.some(({ name }) => name === label));
|
|
40
|
+
if (missingLabels.length)
|
|
41
|
+
errors.push(`Required labels missing: ${missingLabels.join(", ")}.`);
|
|
42
|
+
}
|
|
43
|
+
if (isNumber(this.config.review.safety.maxChangedFiles) &&
|
|
44
|
+
metadata.changed_files > this.config.review.safety.maxChangedFiles)
|
|
45
|
+
errors.push(`Changed files exceed limit: ${metadata.changed_files} > ${this.config.review.safety.maxChangedFiles}.`);
|
|
46
|
+
if (this.config.review.safety.blockedPaths.length) {
|
|
47
|
+
const isBlocked = picomatch(this.config.review.safety.blockedPaths, {
|
|
48
|
+
dot: true,
|
|
49
|
+
});
|
|
50
|
+
const blocked = files.filter((file) => isBlocked(file));
|
|
51
|
+
if (blocked.length)
|
|
52
|
+
errors.push(`Blocked paths changed: ${blocked.join(", ")}.`);
|
|
53
|
+
}
|
|
54
|
+
if (errors.length)
|
|
55
|
+
throw new MagiError("blocked", `PR is safety blocked. ${errors.join(" ")}`);
|
|
56
|
+
if (this.config.mode === "single") {
|
|
57
|
+
if (this.config.account === metadata.user.login)
|
|
58
|
+
throw new MagiError("blocked", `Single mode account ${this.config.account} cannot review because it opened the pull request. Configure account to a different account.`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const accounts = Object.values(this.state.reviewers ?? {})
|
|
62
|
+
.filter(({ account }) => account === metadata.user.login)
|
|
63
|
+
.map(({ account }) => account);
|
|
64
|
+
if (accounts.length)
|
|
65
|
+
throw new MagiError("blocked", `Multi mode accounts ${accounts.join(", ")} cannot review because they opened the pull request. Configure accounts to different accounts.`);
|
|
66
|
+
}
|
|
67
|
+
await this.updateState({ pr: { files, metadata } });
|
|
68
|
+
await this.updateEvent(`Finished checking PR.`);
|
|
69
|
+
}
|
|
70
|
+
export async function checkCi(wait = this.config.review.checks.wait) {
|
|
71
|
+
this.context.abort.throwIfAborted();
|
|
72
|
+
await this.updateEvent(`Checking CI.`);
|
|
73
|
+
if (wait)
|
|
74
|
+
await watchChecks.call(this);
|
|
75
|
+
const checks = await getChecks.call(this);
|
|
76
|
+
await this.updateState({ pr: { checks } });
|
|
77
|
+
await this.updateEvent(`Finished checking CI.`);
|
|
78
|
+
}
|
|
79
|
+
export async function classifyChecks() {
|
|
80
|
+
this.context.abort.throwIfAborted();
|
|
81
|
+
if (!this.state.pr?.checks?.failed.length)
|
|
82
|
+
return;
|
|
83
|
+
await this.updateEvent(`Classifying CI checks.`);
|
|
84
|
+
if (!this.state.pr.metadata)
|
|
85
|
+
throw new MagiError("blocked", "PR metadata not found.");
|
|
86
|
+
if (!this.state.worktree)
|
|
87
|
+
throw new MagiError("blocked", "PR worktree not found.");
|
|
88
|
+
const worker = new Worker(this.config.review.concurrency.reviewers);
|
|
89
|
+
const classifiedChecks = Object.fromEntries(this.state.pr.checks.failed.map(({ id }) => [id, {}]));
|
|
90
|
+
const command = this.state.command === "merge" ? "merge" : "review";
|
|
91
|
+
const prompt = await Prompt.init(this.magi, this.config, `${command}/ci-classification`);
|
|
92
|
+
const taskMessage = await prompt.create(this.config[command].prompts?.ciClassification, [
|
|
93
|
+
"output_contract",
|
|
94
|
+
["failed_checks", JSON.stringify(this.state.pr.checks.failed, null, 2)],
|
|
95
|
+
], {
|
|
96
|
+
baseSha: this.state.pr.metadata[this.state.command === "merge" ? "head" : "base"]
|
|
97
|
+
.sha,
|
|
98
|
+
headSha: this.state.pr.metadata.head.sha,
|
|
99
|
+
owner: this.config.github.owner,
|
|
100
|
+
pr: this.number.toString(),
|
|
101
|
+
repo: this.config.github.repo,
|
|
102
|
+
worktreePath: this.state.worktree.path,
|
|
103
|
+
});
|
|
104
|
+
await Promise.all(Object.entries(this.state.reviewers ?? {}).map(([id, { sessionId }]) => worker.run(async () => {
|
|
105
|
+
if (!sessionId)
|
|
106
|
+
throw new Error(`No session ID found for reviewer ${id}.`);
|
|
107
|
+
await this.updateEvent(`Classifying CI checks with reviewer ${id}.`);
|
|
108
|
+
const output = await retry(async (count, e) => {
|
|
109
|
+
const raw = await this.magi.promptSession(sessionId, count === 1 ? taskMessage : await prompt.repair(e), this.context.abort);
|
|
110
|
+
await this.createAgentFile("ci-classification", id, raw, count);
|
|
111
|
+
const parsed = prompt.parse(raw);
|
|
112
|
+
if (!prompt.validate(parsed))
|
|
113
|
+
throw new Error(`Invalid output for reviewer ${id}.`);
|
|
114
|
+
return parsed;
|
|
115
|
+
}, {
|
|
116
|
+
error: async (e, count) => {
|
|
117
|
+
if (e instanceof MagiError)
|
|
118
|
+
throw e;
|
|
119
|
+
await this.updateEvent(`Attempt ${count} failed to classify CI checks with reviewer ${id}. Retrying...`);
|
|
120
|
+
},
|
|
121
|
+
retries: this.config.output.repairAttempts,
|
|
122
|
+
signal: this.context.abort,
|
|
123
|
+
});
|
|
124
|
+
if (!output)
|
|
125
|
+
throw new MagiError("blocked", `Invalid output for reviewer ${id}.`);
|
|
126
|
+
output.checks.forEach((data) => {
|
|
127
|
+
classifiedChecks[data.id][id] = {
|
|
128
|
+
comment: data.comment,
|
|
129
|
+
scope: data.classification === "SCOPE_IN",
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
})));
|
|
133
|
+
const failed = this.state.pr.checks.failed.map((check) => {
|
|
134
|
+
const classifieds = classifiedChecks[check.id];
|
|
135
|
+
const values = Object.values(classifieds);
|
|
136
|
+
const scope = values.reduce((acc, { scope }) => acc + (scope ? 1 : 0), 0) >
|
|
137
|
+
values.length / 2;
|
|
138
|
+
return { ...check, classifieds, scope };
|
|
139
|
+
});
|
|
140
|
+
await Promise.all(failed.map(async ({ classifieds, name, scope }) => {
|
|
141
|
+
const reasons = {
|
|
142
|
+
in: Object.entries(classifieds)
|
|
143
|
+
.filter(([, { scope }]) => scope)
|
|
144
|
+
.map(([id, { comment }]) => `- ${id}: ${comment}`),
|
|
145
|
+
out: Object.entries(classifieds)
|
|
146
|
+
.filter(([, { scope }]) => !scope)
|
|
147
|
+
.map(([id, { comment }]) => `- ${id}: ${comment}`),
|
|
148
|
+
};
|
|
149
|
+
await this.updateEvent(filterEmpty([
|
|
150
|
+
scope
|
|
151
|
+
? `Check ${name} was classified as in scope by majority vote.`
|
|
152
|
+
: `Check ${name} was classified as out of scope by majority vote. Rerunning it.`,
|
|
153
|
+
reasons.in.length
|
|
154
|
+
? `In scope reasons:\n${reasons.in.join("\n")}`
|
|
155
|
+
: undefined,
|
|
156
|
+
reasons.out.length
|
|
157
|
+
? `Out of scope reasons:\n${reasons.out.join("\n")}`
|
|
158
|
+
: undefined,
|
|
159
|
+
]).join("\n\n"));
|
|
160
|
+
}));
|
|
161
|
+
await this.updateState({ pr: { checks: { failed } } });
|
|
162
|
+
await this.updateEvent(`Finished classifying CI checks.`);
|
|
163
|
+
}
|
|
164
|
+
export async function rerunChecks() {
|
|
165
|
+
this.context.abort.throwIfAborted();
|
|
166
|
+
if (!this.state.pr?.checks)
|
|
167
|
+
throw new MagiError("blocked", "PR checks not found.");
|
|
168
|
+
const checks = {
|
|
169
|
+
failed: this.state.pr.checks.failed,
|
|
170
|
+
passed: this.state.pr.checks.passed,
|
|
171
|
+
};
|
|
172
|
+
const failedChecks = checks.failed.filter(({ scope }) => !scope);
|
|
173
|
+
if (!failedChecks.length)
|
|
174
|
+
return;
|
|
175
|
+
await this.updateEvent(`Rerunning CI checks.`);
|
|
176
|
+
let label = failedChecks.map(({ name }) => name).join(", ");
|
|
177
|
+
if (this.state.dryRun) {
|
|
178
|
+
checks.passed = [
|
|
179
|
+
...checks.passed,
|
|
180
|
+
...checks.failed.filter(({ scope }) => !scope),
|
|
181
|
+
];
|
|
182
|
+
checks.failed = checks.failed.filter(({ scope }) => scope);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
await retry(async () => {
|
|
186
|
+
await this.updateEvent(`Rerunning checks ${label}.`);
|
|
187
|
+
await Promise.all(checks.failed
|
|
188
|
+
.filter(({ scope }) => !scope)
|
|
189
|
+
.map(async ({ id }) => rerunCheck.call(this, id)));
|
|
190
|
+
await watchChecks.call(this);
|
|
191
|
+
const { failed, passed } = await getChecks.call(this);
|
|
192
|
+
checks.passed = passed.map((check) => {
|
|
193
|
+
const { classifieds, scope } = checks.failed.find(({ name, workflow }) => check.name === name && check.workflow === workflow) ?? {};
|
|
194
|
+
return omitNullish({ ...check, classifieds, scope });
|
|
195
|
+
});
|
|
196
|
+
checks.failed = failed.map((check) => {
|
|
197
|
+
const { classifieds, scope } = checks.failed.find(({ name, workflow }) => check.name === name && check.workflow === workflow) ?? {};
|
|
198
|
+
return omitNullish({ ...check, classifieds, scope });
|
|
199
|
+
});
|
|
200
|
+
const failedChecks = checks.failed.filter(({ scope }) => !scope);
|
|
201
|
+
if (!failedChecks.length)
|
|
202
|
+
return;
|
|
203
|
+
label = failedChecks.map(({ name }) => name).join(", ");
|
|
204
|
+
throw new Error(`Checks ${label} still failed.`);
|
|
205
|
+
}, {
|
|
206
|
+
error: (_, count) => this.updateEvent(`Attempt ${count} failed to rerun checks ${label}. Retrying...`),
|
|
207
|
+
retries: this.config.review.checks.retryFailedJobs,
|
|
208
|
+
signal: this.context.abort,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
const passedChecksAfterRerun = checks.passed.filter((check) => "scope" in check && !check.scope);
|
|
212
|
+
const failedChecksAfterRerun = checks.failed.filter(({ scope }) => !scope);
|
|
213
|
+
const message = filterEmpty([
|
|
214
|
+
passedChecksAfterRerun.length
|
|
215
|
+
? `Reran checks ${passedChecksAfterRerun.map(({ name }) => name).join(", ")} passed.`
|
|
216
|
+
: undefined,
|
|
217
|
+
failedChecksAfterRerun.length
|
|
218
|
+
? `Reran checks ${failedChecksAfterRerun.map(({ name }) => name).join(", ")} failed.`
|
|
219
|
+
: undefined,
|
|
220
|
+
]).join("\n");
|
|
221
|
+
if (message)
|
|
222
|
+
await this.updateEvent(message);
|
|
223
|
+
await this.updateState({ pr: { checks } });
|
|
224
|
+
await this.updateEvent(`Finished rerunning checks.`);
|
|
225
|
+
}
|
|
226
|
+
export async function getMetadata() {
|
|
227
|
+
const [{ data }, files] = await Promise.all([
|
|
228
|
+
this.octokit.rest.pulls.get({
|
|
229
|
+
owner: this.config.github.owner,
|
|
230
|
+
pull_number: this.number,
|
|
231
|
+
repo: this.config.github.repo,
|
|
232
|
+
}),
|
|
233
|
+
this.octokit.paginate(this.octokit.rest.pulls.listFiles, {
|
|
234
|
+
owner: this.config.github.owner,
|
|
235
|
+
pull_number: this.number,
|
|
236
|
+
repo: this.config.github.repo,
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
return {
|
|
240
|
+
files: files.map(({ filename }) => filename),
|
|
241
|
+
metadata: data,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
async function getChecks() {
|
|
245
|
+
const fields = "name,state,bucket,link,workflow";
|
|
246
|
+
const checks = {
|
|
247
|
+
excluded: [],
|
|
248
|
+
failed: [],
|
|
249
|
+
passed: [],
|
|
250
|
+
pending: [],
|
|
251
|
+
};
|
|
252
|
+
try {
|
|
253
|
+
const raw = await this.exec(command("gh", "pr", "checks", this.number, "--repo", this.state.repo, "--json", fields, "--required"), { signal: this.context.abort });
|
|
254
|
+
const data = JSON.parse(raw);
|
|
255
|
+
await Promise.all(data.map(async (check) => {
|
|
256
|
+
check.id = check.link.match(/\/actions\/runs\/\d+\/job\/(\d+)/)[1];
|
|
257
|
+
if (ci.isExcluded(this.config.review.checks.exclude, check)) {
|
|
258
|
+
checks.excluded.push(check);
|
|
259
|
+
}
|
|
260
|
+
else if (ci.isFailed(check)) {
|
|
261
|
+
const log = await getCheckLog.call(this, check.id);
|
|
262
|
+
checks.failed.push({ ...check, log });
|
|
263
|
+
}
|
|
264
|
+
else if (ci.isPassed(check)) {
|
|
265
|
+
checks.passed.push(check);
|
|
266
|
+
}
|
|
267
|
+
else if (ci.isPending(check)) {
|
|
268
|
+
checks.pending.push(check);
|
|
269
|
+
}
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
if (!/no (required )?checks reported on the '.+' branch/i.test(String(e)))
|
|
274
|
+
throw e;
|
|
275
|
+
}
|
|
276
|
+
return checks;
|
|
277
|
+
}
|
|
278
|
+
async function getCheckLog(id) {
|
|
279
|
+
const log = await this.exec(command("gh", "run", "view", "--repo", this.state.repo, "--job", quote(id), "--log-failed"), { signal: this.context.abort });
|
|
280
|
+
return (log
|
|
281
|
+
// oxlint-disable-next-line no-control-regex
|
|
282
|
+
.replaceAll(/\u001B\[[0-9;]*m/g, "")
|
|
283
|
+
.split("\n")
|
|
284
|
+
.filter((line) => line.trim())
|
|
285
|
+
.join("\n"));
|
|
286
|
+
}
|
|
287
|
+
async function rerunCheck(id) {
|
|
288
|
+
await this.exec(command("gh", "run", "rerun", "--repo", this.state.repo, "--job", quote(id)), { signal: this.context.abort });
|
|
289
|
+
}
|
|
290
|
+
async function watchChecks() {
|
|
291
|
+
try {
|
|
292
|
+
await this.exec(command("gh", "pr", "checks", this.number, "--repo", this.state.repo, "--watch", "--required"), { signal: this.context.abort });
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
295
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { MagiError } from "@/magi";
|
|
2
|
+
import { command, filterEmpty, marker, quote } from "@/utils";
|
|
3
|
+
export async function checkExistingReviews() {
|
|
4
|
+
this.context.abort.throwIfAborted();
|
|
5
|
+
await this.updateEvent(`Fetching existing reviews.`);
|
|
6
|
+
if (!this.state.pr?.metadata)
|
|
7
|
+
throw new MagiError("blocked", "PR metadata not found.");
|
|
8
|
+
if (!this.config.review.reviewers?.length)
|
|
9
|
+
throw new MagiError("blocked", "No reviewers configured.");
|
|
10
|
+
const [reviews, commits, threads] = await Promise.all([
|
|
11
|
+
getReviews.call(this),
|
|
12
|
+
getCommits.call(this),
|
|
13
|
+
getReviewThreads.call(this),
|
|
14
|
+
]);
|
|
15
|
+
await this.updateState({ pr: { commits, reviews, threads } });
|
|
16
|
+
const latestNonMergeCommit = commits
|
|
17
|
+
.toReversed()
|
|
18
|
+
.find(({ parents }) => parents.length < 2);
|
|
19
|
+
const reviewers = Object.fromEntries(this.config.review.reviewers.map(({ account, id }) => {
|
|
20
|
+
const targetReviews = filterEmpty(reviews.map((review) => {
|
|
21
|
+
if (this.config.mode === "single") {
|
|
22
|
+
if (review.user.login !== this.config.account)
|
|
23
|
+
return;
|
|
24
|
+
const markers = marker.parse(review.body);
|
|
25
|
+
const { body, reviewer, verdict } = markers.find(({ reviewer }) => reviewer === id) ?? {};
|
|
26
|
+
if (reviewer !== id || !verdict)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
return {
|
|
30
|
+
...review,
|
|
31
|
+
body: body ? decodeURIComponent(body) : "",
|
|
32
|
+
state: verdict,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { ...review, body: "", state: verdict };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
if (review.user.login !== account)
|
|
41
|
+
return;
|
|
42
|
+
if (review.state === "APPROVED")
|
|
43
|
+
return review;
|
|
44
|
+
if (review.state === "CHANGES_REQUESTED")
|
|
45
|
+
return review;
|
|
46
|
+
if (review.state !== "COMMENTED")
|
|
47
|
+
return;
|
|
48
|
+
const markers = marker.parse(review.body);
|
|
49
|
+
const { reviewer, verdict } = markers[0] ?? {};
|
|
50
|
+
if (reviewer !== id || verdict !== "CLOSED")
|
|
51
|
+
return;
|
|
52
|
+
return { ...review, state: "CLOSED" };
|
|
53
|
+
}
|
|
54
|
+
}));
|
|
55
|
+
if (!targetReviews.length) {
|
|
56
|
+
return [id, { account, status: "initial" }];
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const latestReviews = targetReviews.filter(({ submitted_at }) => latestNonMergeCommit?.commit.author?.date &&
|
|
60
|
+
submitted_at &&
|
|
61
|
+
submitted_at.localeCompare(latestNonMergeCommit.commit.author.date) >= 0);
|
|
62
|
+
const review = targetReviews.at(-1);
|
|
63
|
+
if (latestReviews.length)
|
|
64
|
+
return [id, { account, review, status: "skip" }];
|
|
65
|
+
else
|
|
66
|
+
return [id, { account, review, status: "rereview" }];
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
for (const [id, reviewer] of Object.entries(reviewers)) {
|
|
70
|
+
if (reviewer.status !== "skip")
|
|
71
|
+
continue;
|
|
72
|
+
if (!reviewer.review)
|
|
73
|
+
throw new MagiError("blocked", `No review found for reviewer ${id}.`);
|
|
74
|
+
const single = this.config.mode === "single";
|
|
75
|
+
const author = single ? this.config.account : reviewer.account;
|
|
76
|
+
const hasUserReply = threads.some(({ comments, isResolved }) => {
|
|
77
|
+
if (isResolved)
|
|
78
|
+
return false;
|
|
79
|
+
const belongsToReviewer = single
|
|
80
|
+
? comments.some(({ body }) => marker
|
|
81
|
+
.parse(body)
|
|
82
|
+
.some(({ reviewer }) => reviewer === id))
|
|
83
|
+
: comments.some(({ author }) => author?.login === reviewer.account);
|
|
84
|
+
if (!belongsToReviewer)
|
|
85
|
+
return false;
|
|
86
|
+
const last = comments.at(-1);
|
|
87
|
+
return (!!last &&
|
|
88
|
+
last.author?.login !== author &&
|
|
89
|
+
(!reviewer.review?.submitted_at ||
|
|
90
|
+
last.createdAt.localeCompare(reviewer.review.submitted_at) > 0));
|
|
91
|
+
});
|
|
92
|
+
if (hasUserReply) {
|
|
93
|
+
reviewer.status = "rereview";
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const verdict = reviewer.review.state;
|
|
97
|
+
reviewer.outputs = [...(reviewer.outputs ?? []), { verdict }];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const skip = Object.values(reviewers).every(({ status }) => status === "skip");
|
|
101
|
+
await this.updateState({ reviewers });
|
|
102
|
+
await this.updateEvent(`Finished fetching existing reviews.`);
|
|
103
|
+
return skip;
|
|
104
|
+
}
|
|
105
|
+
export async function fetchReviewContext() {
|
|
106
|
+
this.context.abort.throwIfAborted();
|
|
107
|
+
await this.updateEvent(`Fetching review context.`);
|
|
108
|
+
const [comments, issues, threads] = await Promise.all([
|
|
109
|
+
getComments.call(this),
|
|
110
|
+
getClosingIssues.call(this),
|
|
111
|
+
getReviewThreads.call(this),
|
|
112
|
+
]);
|
|
113
|
+
const inlineCommentTargets = await getInlineCommentTargets.call(this);
|
|
114
|
+
await this.updateState({
|
|
115
|
+
pr: { comments, inlineCommentTargets, issues, threads },
|
|
116
|
+
});
|
|
117
|
+
await this.updateEvent(`Finished fetching review context.`);
|
|
118
|
+
}
|
|
119
|
+
export async function getComments() {
|
|
120
|
+
return await this.octokit.paginate(this.octokit.rest.issues.listComments, {
|
|
121
|
+
issue_number: this.number,
|
|
122
|
+
owner: this.config.github.owner,
|
|
123
|
+
repo: this.config.github.repo,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function getReviews() {
|
|
127
|
+
return await this.octokit.paginate(this.octokit.rest.pulls.listReviews, {
|
|
128
|
+
owner: this.config.github.owner,
|
|
129
|
+
pull_number: this.number,
|
|
130
|
+
repo: this.config.github.repo,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function getCommits() {
|
|
134
|
+
return await this.octokit.paginate(this.octokit.rest.pulls.listCommits, {
|
|
135
|
+
owner: this.config.github.owner,
|
|
136
|
+
pull_number: this.number,
|
|
137
|
+
repo: this.config.github.repo,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
export async function getClosingIssues() {
|
|
141
|
+
const data = await this.graphql.paginate(this.graphql.closingIssues, {
|
|
142
|
+
owner: this.config.github.owner,
|
|
143
|
+
pr: this.number,
|
|
144
|
+
repo: this.config.github.repo,
|
|
145
|
+
});
|
|
146
|
+
return filterEmpty(data.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((node) => node && { ...node, comments: filterEmpty(node.comments.nodes ?? []) }) ?? []);
|
|
147
|
+
}
|
|
148
|
+
export async function getReviewThreads() {
|
|
149
|
+
const data = await this.graphql.paginate(this.graphql.reviewThreads, {
|
|
150
|
+
owner: this.config.github.owner,
|
|
151
|
+
pr: this.number,
|
|
152
|
+
repo: this.config.github.repo,
|
|
153
|
+
});
|
|
154
|
+
return filterEmpty(data.repository?.pullRequest?.reviewThreads.nodes?.map((node) => node && { ...node, comments: filterEmpty(node.comments.nodes ?? []) }) ?? []);
|
|
155
|
+
}
|
|
156
|
+
export async function getInlineCommentTargets() {
|
|
157
|
+
if (!this.state.reviewers)
|
|
158
|
+
throw new MagiError("blocked", "Reviewers not found.");
|
|
159
|
+
if (!this.state.pr?.metadata)
|
|
160
|
+
throw new MagiError("blocked", "PR metadata not found.");
|
|
161
|
+
if (!this.state.worktree)
|
|
162
|
+
throw new MagiError("blocked", "PR worktree not found.");
|
|
163
|
+
const get = async (from, to) => {
|
|
164
|
+
const inlineCommentTargets = {};
|
|
165
|
+
const [fromSource, fromSha] = from;
|
|
166
|
+
const [toSource, toSha] = to;
|
|
167
|
+
const commits = [
|
|
168
|
+
{ label: "from", sha: fromSha, source: fromSource },
|
|
169
|
+
{ label: "to", sha: toSha, source: toSource },
|
|
170
|
+
];
|
|
171
|
+
const missing = filterEmpty(await Promise.all(commits.map(async (commit) => {
|
|
172
|
+
if (!(await hasCommit.call(this, commit.sha)))
|
|
173
|
+
return commit;
|
|
174
|
+
})));
|
|
175
|
+
const sources = new Set(missing.map(({ source }) => source));
|
|
176
|
+
for (const source of sources) {
|
|
177
|
+
const ref = source === "base"
|
|
178
|
+
? this.state.pr.metadata.base.ref
|
|
179
|
+
: this.state.pr.metadata.head.ref;
|
|
180
|
+
const url = source === "base"
|
|
181
|
+
? this.state.pr.metadata.base.repo.clone_url
|
|
182
|
+
: this.state.pr.metadata.head.repo.clone_url;
|
|
183
|
+
await this.exec(command("git", "fetch", "--no-tags", quote(url), quote(`refs/heads/${ref}`)), { cwd: this.state.worktree.path, signal: this.context.abort });
|
|
184
|
+
}
|
|
185
|
+
for (const commit of missing) {
|
|
186
|
+
if (await hasCommit.call(this, commit.sha))
|
|
187
|
+
continue;
|
|
188
|
+
const ref = commit.source === "base"
|
|
189
|
+
? this.state.pr.metadata.base.ref
|
|
190
|
+
: this.state.pr.metadata.head.ref;
|
|
191
|
+
throw new MagiError("blocked", `${commit.label} commit ${commit.sha} is unavailable after fetching ${commit.source} ref ${ref}.`);
|
|
192
|
+
}
|
|
193
|
+
const diff = await this.exec(command("git", "diff", "--no-ext-diff", "--unified=3", quote(`${fromSha}...${toSha}`)), { cwd: this.state.worktree.path, signal: this.context.abort });
|
|
194
|
+
let path;
|
|
195
|
+
let line;
|
|
196
|
+
for (const entry of diff.split("\n")) {
|
|
197
|
+
if (entry.startsWith("+++ ")) {
|
|
198
|
+
let value = entry.slice(4);
|
|
199
|
+
if (value === "/dev/null")
|
|
200
|
+
continue;
|
|
201
|
+
if (value.startsWith('"') && value.endsWith('"'))
|
|
202
|
+
try {
|
|
203
|
+
value = JSON.parse(value);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
value = value.slice(1, -1);
|
|
207
|
+
}
|
|
208
|
+
path = value.startsWith("b/") ? value.slice(2) : value;
|
|
209
|
+
line = undefined;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (entry.startsWith("@@ ")) {
|
|
213
|
+
const match = entry.match(/\+(\d+)(?:,\d+)?/);
|
|
214
|
+
line = match ? Number(match[1]) : undefined;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!path || line == null)
|
|
218
|
+
continue;
|
|
219
|
+
if (entry.startsWith("+") || entry.startsWith(" ")) {
|
|
220
|
+
const lines = inlineCommentTargets[path] ?? [];
|
|
221
|
+
lines.push(line);
|
|
222
|
+
inlineCommentTargets[path] = lines;
|
|
223
|
+
line += 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return inlineCommentTargets;
|
|
227
|
+
};
|
|
228
|
+
const baseSha = this.state.pr.metadata.base.sha;
|
|
229
|
+
const headSha = this.state.pr.metadata.head.sha;
|
|
230
|
+
const inlineCommentTargets = {
|
|
231
|
+
[baseSha]: await get(["base", baseSha], ["head", headSha]),
|
|
232
|
+
};
|
|
233
|
+
const reviewers = Object.entries(this.state.reviewers);
|
|
234
|
+
for (const [id, { review, status }] of reviewers) {
|
|
235
|
+
if (status !== "rereview")
|
|
236
|
+
continue;
|
|
237
|
+
if (!review?.commit_id)
|
|
238
|
+
throw new MagiError("blocked", `Missing previous review commit for reviewer ${id}.`);
|
|
239
|
+
const previousHeadSha = review.commit_id;
|
|
240
|
+
if (!inlineCommentTargets[previousHeadSha])
|
|
241
|
+
inlineCommentTargets[previousHeadSha] = await get(["head", previousHeadSha], ["head", headSha]);
|
|
242
|
+
}
|
|
243
|
+
return inlineCommentTargets;
|
|
244
|
+
}
|
|
245
|
+
async function hasCommit(sha) {
|
|
246
|
+
if (!this.state.worktree)
|
|
247
|
+
throw new MagiError("blocked", "PR worktree not found.");
|
|
248
|
+
try {
|
|
249
|
+
await this.exec(command("git", "cat-file", "-e", quote(`${sha}^{commit}`)), {
|
|
250
|
+
cwd: this.state.worktree.path,
|
|
251
|
+
signal: this.context.abort,
|
|
252
|
+
});
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { parsePrs, split, Worker } from "@/utils";
|
|
3
|
+
import { Review } from "./review";
|
|
4
|
+
function overrideConfig(config, args, dryRun = false) {
|
|
5
|
+
for (let index = 0; index < args.length; index++) {
|
|
6
|
+
const arg = args[index];
|
|
7
|
+
const value = args[index + 1];
|
|
8
|
+
if (!arg.startsWith("--"))
|
|
9
|
+
continue;
|
|
10
|
+
switch (arg) {
|
|
11
|
+
case "--dry-run":
|
|
12
|
+
dryRun = true;
|
|
13
|
+
break;
|
|
14
|
+
case "--retry-api-attempts":
|
|
15
|
+
config.github.retryApiAttempts = parseInt(value);
|
|
16
|
+
break;
|
|
17
|
+
case "--language":
|
|
18
|
+
config.language = value;
|
|
19
|
+
break;
|
|
20
|
+
case "--merge":
|
|
21
|
+
config.review.automation.merge = true;
|
|
22
|
+
break;
|
|
23
|
+
case "--no-merge":
|
|
24
|
+
config.review.automation.merge = false;
|
|
25
|
+
break;
|
|
26
|
+
case "--close":
|
|
27
|
+
config.review.automation.close = true;
|
|
28
|
+
break;
|
|
29
|
+
case "--no-close":
|
|
30
|
+
config.review.automation.close = false;
|
|
31
|
+
break;
|
|
32
|
+
case "--retry-failed-jobs":
|
|
33
|
+
config.review.checks.retryFailedJobs = parseInt(value);
|
|
34
|
+
break;
|
|
35
|
+
case "--concurrency-reviewers":
|
|
36
|
+
config.review.concurrency.reviewers = parseInt(value);
|
|
37
|
+
break;
|
|
38
|
+
case "--concurrency-runs":
|
|
39
|
+
config.review.concurrency.runs = parseInt(value);
|
|
40
|
+
break;
|
|
41
|
+
case "--wait-checks":
|
|
42
|
+
config.review.checks.wait = true;
|
|
43
|
+
break;
|
|
44
|
+
case "--no-wait-checks":
|
|
45
|
+
config.review.checks.wait = false;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { config, dryRun };
|
|
50
|
+
}
|
|
51
|
+
export const review = function (magi) {
|
|
52
|
+
return {
|
|
53
|
+
magi_review: tool({
|
|
54
|
+
args: {
|
|
55
|
+
dryRun: tool.schema.boolean().optional(),
|
|
56
|
+
prs: tool.schema.string(),
|
|
57
|
+
},
|
|
58
|
+
description: "Review one or more pull requests and post the reviews.",
|
|
59
|
+
async execute(args, context) {
|
|
60
|
+
const prs = parsePrs(args.prs);
|
|
61
|
+
const { config, dryRun } = overrideConfig(await magi.getConfig({ reviewers: true }), split(args.prs), args.dryRun);
|
|
62
|
+
const worker = new Worker(config.review.concurrency.runs);
|
|
63
|
+
const reports = await Promise.all(prs.map(async (pr) => {
|
|
64
|
+
context.abort.throwIfAborted();
|
|
65
|
+
return worker.run(async () => {
|
|
66
|
+
const run = await Review.init(pr, magi, config, context, {
|
|
67
|
+
dryRun,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
await run.checkPr();
|
|
71
|
+
const skip = await run.checkExistingReviews();
|
|
72
|
+
await run.checkCi();
|
|
73
|
+
if (!skip) {
|
|
74
|
+
await run.createSessions();
|
|
75
|
+
await run.createWorktree();
|
|
76
|
+
await run.classifyChecks();
|
|
77
|
+
await run.rerunChecks();
|
|
78
|
+
await run.fetchReviewContext();
|
|
79
|
+
await run.review();
|
|
80
|
+
await run.validateFindings();
|
|
81
|
+
await run.reconsiderClose();
|
|
82
|
+
}
|
|
83
|
+
await run.resolveVerdict();
|
|
84
|
+
if (!skip)
|
|
85
|
+
await run.postReviews();
|
|
86
|
+
await run.automate();
|
|
87
|
+
return { error: false, report: await run.createReport() };
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return { error: true, report: await run.createReport(e) };
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await run.cleanup();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}));
|
|
97
|
+
const errors = reports.filter(({ error }) => error);
|
|
98
|
+
if (errors.length)
|
|
99
|
+
throw new Error(errors.map(({ report }) => report).join("\n\n"));
|
|
100
|
+
return reports.map(({ report }) => report).join("\n\n");
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|