opencode-magi 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +254 -0
- package/README.md +31 -26
- package/dist/commands.js +11 -9
- package/dist/config/index.js +2 -0
- package/dist/config/resolve.js +120 -246
- package/dist/config/validate.js +98 -1110
- package/dist/constant.js +131 -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 +116 -0
- package/dist/tools/merge/editor.js +256 -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 +459 -0
- package/dist/tools/review/check.js +335 -0
- package/dist/tools/review/context.js +254 -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 +146 -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 +46 -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 +36 -23
- package/schema.json +116 -20
- 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 -1480
- 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
|
@@ -1,1480 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { createWorktree, fetchPullRequest, fetchPullRequestCommits, fetchPullRequestReviews, fetchUnresolvedThreads, closePullRequest, mergePullRequest, ensurePullRequestCommits, postApproval, postChangesRequested, postCloseComment, postReply, removeWorktree, resolveThread, shellQuote, } from "../github/commands";
|
|
4
|
-
import { composeFindingValidationPrompt, composeCloseReconsiderationPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, composeReviewPrompt, } from "../prompts/compose";
|
|
5
|
-
import { prRunOutputDir } from "../config/output";
|
|
6
|
-
import { prRunWorktreeDir } from "../config/worktree";
|
|
7
|
-
import { parseCloseReconsiderationOutput, parseFindingValidationOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, parseReviewOutput, } from "../prompts/output";
|
|
8
|
-
import { throwIfAborted, withAbortSignal } from "./abort";
|
|
9
|
-
import { waitForChecksWithClassification } from "./ci";
|
|
10
|
-
import { parseRightSideDiffTargets, validateInlineCommentTargets, } from "./inline-comments";
|
|
11
|
-
import { applyFindingValidation, reviewFindingTargets, validateFindingVotes, } from "./findings";
|
|
12
|
-
import { closeMinorityReviewers, mergeVerdictForPolicy, } from "./majority";
|
|
13
|
-
import { runModelWithRepair } from "./model";
|
|
14
|
-
import { mapPool } from "./pool";
|
|
15
|
-
import { formatReviewReport } from "./report";
|
|
16
|
-
import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
|
|
17
|
-
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
18
|
-
function resolvedReviewMode(repository) {
|
|
19
|
-
return repository.mode === "multi" ? "multi" : "single";
|
|
20
|
-
}
|
|
21
|
-
export function reviewPostingAccount(repository, reviewer) {
|
|
22
|
-
if (resolvedReviewMode(repository) !== "single")
|
|
23
|
-
return reviewer.account;
|
|
24
|
-
if (repository.account)
|
|
25
|
-
return repository.account;
|
|
26
|
-
throw new Error("account is required for single review mode");
|
|
27
|
-
}
|
|
28
|
-
function reviewAssignmentKey(repository, reviewer) {
|
|
29
|
-
return resolvedReviewMode(repository) === "single"
|
|
30
|
-
? reviewer.key
|
|
31
|
-
: reviewer.account;
|
|
32
|
-
}
|
|
33
|
-
function parseMarkerFields(text) {
|
|
34
|
-
const fields = Object.fromEntries(text
|
|
35
|
-
.trim()
|
|
36
|
-
.split(/\s+/)
|
|
37
|
-
.flatMap((part) => {
|
|
38
|
-
const index = part.indexOf("=");
|
|
39
|
-
return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
|
|
40
|
-
}));
|
|
41
|
-
return fields.v === "1" && fields.mode === "single" ? fields : undefined;
|
|
42
|
-
}
|
|
43
|
-
function isMarkerVerdict(value) {
|
|
44
|
-
return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
|
|
45
|
-
}
|
|
46
|
-
export function formatReviewMarker(marker) {
|
|
47
|
-
return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
|
|
48
|
-
}
|
|
49
|
-
export function parseReviewMarkers(body) {
|
|
50
|
-
const markers = [];
|
|
51
|
-
const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
|
|
52
|
-
for (const match of body?.matchAll(regex) ?? []) {
|
|
53
|
-
const fields = parseMarkerFields(match[1] ?? "");
|
|
54
|
-
const pr = Number(fields?.pr);
|
|
55
|
-
if (!fields ||
|
|
56
|
-
!Number.isInteger(pr) ||
|
|
57
|
-
!fields.reviewer ||
|
|
58
|
-
!fields.head ||
|
|
59
|
-
!isMarkerVerdict(fields.verdict)) {
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
markers.push({
|
|
63
|
-
head: fields.head,
|
|
64
|
-
pr,
|
|
65
|
-
reviewer: fields.reviewer,
|
|
66
|
-
verdict: fields.verdict,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return markers;
|
|
70
|
-
}
|
|
71
|
-
export function formatReviewFindingMarker(marker) {
|
|
72
|
-
return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
|
|
73
|
-
}
|
|
74
|
-
export function parseReviewFindingMarkers(body) {
|
|
75
|
-
const markers = [];
|
|
76
|
-
const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
|
|
77
|
-
for (const match of body?.matchAll(regex) ?? []) {
|
|
78
|
-
const fields = parseMarkerFields(match[1] ?? "");
|
|
79
|
-
const pr = Number(fields?.pr);
|
|
80
|
-
const finding = Number(fields?.finding);
|
|
81
|
-
if (!fields ||
|
|
82
|
-
!Number.isInteger(pr) ||
|
|
83
|
-
!Number.isInteger(finding) ||
|
|
84
|
-
!fields.reviewer ||
|
|
85
|
-
!fields.head) {
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
|
|
89
|
-
}
|
|
90
|
-
return markers;
|
|
91
|
-
}
|
|
92
|
-
function markerReviewState(verdict) {
|
|
93
|
-
if (verdict === "MERGE")
|
|
94
|
-
return "APPROVED";
|
|
95
|
-
if (verdict === "CHANGES_REQUESTED")
|
|
96
|
-
return "CHANGES_REQUESTED";
|
|
97
|
-
return "CLOSE";
|
|
98
|
-
}
|
|
99
|
-
function errorMessage(error) {
|
|
100
|
-
return error instanceof Error ? error.message : String(error);
|
|
101
|
-
}
|
|
102
|
-
async function withReviewerFailureProgress(input) {
|
|
103
|
-
try {
|
|
104
|
-
return await input.run();
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
await input.onProgress?.({
|
|
108
|
-
error: errorMessage(error),
|
|
109
|
-
reviewer: input.reviewer,
|
|
110
|
-
type: "reviewer_failed",
|
|
111
|
-
});
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
async function postReviewOutput(input, reviewerKey, output) {
|
|
116
|
-
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
117
|
-
if (!reviewer)
|
|
118
|
-
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
119
|
-
const account = reviewPostingAccount(input.repository, reviewer);
|
|
120
|
-
if (output.verdict === "MERGE")
|
|
121
|
-
return postApproval(input.exec, input.repository, input.pr, account);
|
|
122
|
-
if (output.verdict === "CLOSE")
|
|
123
|
-
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
124
|
-
return postChangesRequested(input.exec, input.repository, input.pr, account, output.findings);
|
|
125
|
-
}
|
|
126
|
-
function dryRunReviewPost(key, output) {
|
|
127
|
-
if (output.verdict === "MERGE")
|
|
128
|
-
return `dry-run:would-approve:${key}`;
|
|
129
|
-
if (output.verdict === "CLOSE")
|
|
130
|
-
return `dry-run:would-comment-close:${key}`;
|
|
131
|
-
return `dry-run:would-request-changes:${key}`;
|
|
132
|
-
}
|
|
133
|
-
function latestReviewsByAccount(reviews, accounts) {
|
|
134
|
-
const accountSet = new Set(accounts);
|
|
135
|
-
const latest = new Map();
|
|
136
|
-
for (const review of reviews) {
|
|
137
|
-
if (!accountSet.has(review.author.login))
|
|
138
|
-
continue;
|
|
139
|
-
if (review.state === "DISMISSED")
|
|
140
|
-
continue;
|
|
141
|
-
const current = latest.get(review.author.login);
|
|
142
|
-
if (!current || current.submittedAt.localeCompare(review.submittedAt) < 0)
|
|
143
|
-
latest.set(review.author.login, review);
|
|
144
|
-
}
|
|
145
|
-
return latest;
|
|
146
|
-
}
|
|
147
|
-
export function resolveReviewMode(reviews, accounts, current, accountsWithPendingThreadReplies = new Set()) {
|
|
148
|
-
const latest = latestReviewsByAccount(reviews, accounts);
|
|
149
|
-
const reviewedHead = accounts.every((account) => {
|
|
150
|
-
return (isReviewCurrent(latest.get(account), current) &&
|
|
151
|
-
!accountsWithPendingThreadReplies.has(account));
|
|
152
|
-
});
|
|
153
|
-
const assignments = new Map();
|
|
154
|
-
for (const account of accounts) {
|
|
155
|
-
const review = latest.get(account);
|
|
156
|
-
if (!review) {
|
|
157
|
-
assignments.set(account, { type: "initial" });
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
if (isReviewCurrent(review, current) &&
|
|
161
|
-
!accountsWithPendingThreadReplies.has(account)) {
|
|
162
|
-
assignments.set(account, { review, type: "skip" });
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
assignments.set(account, { review, type: "rereview" });
|
|
166
|
-
}
|
|
167
|
-
if (latest.size && reviewedHead)
|
|
168
|
-
return { assignments, type: "already_reviewed" };
|
|
169
|
-
return { assignments, type: "active" };
|
|
170
|
-
}
|
|
171
|
-
export function resolveSingleAccountReviewMode(input) {
|
|
172
|
-
const reviewerKeySet = new Set(input.reviewerKeys);
|
|
173
|
-
const pendingReviewers = input.pendingReviewers ?? new Set();
|
|
174
|
-
const latest = new Map();
|
|
175
|
-
for (const review of input.reviews) {
|
|
176
|
-
if (review.author.login !== input.account)
|
|
177
|
-
continue;
|
|
178
|
-
if (review.state === "DISMISSED")
|
|
179
|
-
continue;
|
|
180
|
-
for (const marker of parseReviewMarkers(review.body)) {
|
|
181
|
-
if (marker.pr !== input.pr || !reviewerKeySet.has(marker.reviewer)) {
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
const synthetic = {
|
|
185
|
-
...review,
|
|
186
|
-
commit: { oid: marker.head },
|
|
187
|
-
comments: (review.comments ?? []).filter((comment) => parseReviewFindingMarkers(comment.body).some((findingMarker) => findingMarker.pr === input.pr &&
|
|
188
|
-
findingMarker.reviewer === marker.reviewer &&
|
|
189
|
-
findingMarker.head === marker.head)),
|
|
190
|
-
state: markerReviewState(marker.verdict),
|
|
191
|
-
};
|
|
192
|
-
const current = latest.get(marker.reviewer);
|
|
193
|
-
if (!current ||
|
|
194
|
-
current.submittedAt.localeCompare(review.submittedAt) < 0) {
|
|
195
|
-
latest.set(marker.reviewer, synthetic);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
const reviewedHead = input.reviewerKeys.every((reviewer) => {
|
|
200
|
-
return (isReviewCurrent(latest.get(reviewer), input.current) &&
|
|
201
|
-
!pendingReviewers.has(reviewer));
|
|
202
|
-
});
|
|
203
|
-
const assignments = new Map();
|
|
204
|
-
for (const reviewer of input.reviewerKeys) {
|
|
205
|
-
const review = latest.get(reviewer);
|
|
206
|
-
if (!review) {
|
|
207
|
-
assignments.set(reviewer, { type: "initial" });
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
if (isReviewCurrent(review, input.current) &&
|
|
211
|
-
!pendingReviewers.has(reviewer)) {
|
|
212
|
-
assignments.set(reviewer, { review, type: "skip" });
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
assignments.set(reviewer, { review, type: "rereview" });
|
|
216
|
-
}
|
|
217
|
-
if (latest.size && reviewedHead)
|
|
218
|
-
return { assignments, type: "already_reviewed" };
|
|
219
|
-
return { assignments, type: "active" };
|
|
220
|
-
}
|
|
221
|
-
export function reviewFreshnessTarget(commits, headSha) {
|
|
222
|
-
const latestNonMerge = [...commits]
|
|
223
|
-
.reverse()
|
|
224
|
-
.find((commit) => commit.parentCount < 2);
|
|
225
|
-
return latestNonMerge
|
|
226
|
-
? {
|
|
227
|
-
committedAt: latestNonMerge.committedDate,
|
|
228
|
-
fallbackHeadSha: headSha,
|
|
229
|
-
type: "timestamp",
|
|
230
|
-
}
|
|
231
|
-
: { headSha, type: "head" };
|
|
232
|
-
}
|
|
233
|
-
function isReviewCurrent(review, current) {
|
|
234
|
-
if (!review)
|
|
235
|
-
return false;
|
|
236
|
-
if (current.type === "head")
|
|
237
|
-
return review.commit?.oid === current.headSha;
|
|
238
|
-
if (review.submittedAt.localeCompare(current.committedAt) >= 0)
|
|
239
|
-
return true;
|
|
240
|
-
return review.commit?.oid === current.fallbackHeadSha;
|
|
241
|
-
}
|
|
242
|
-
function reviewStateToVerdict(state) {
|
|
243
|
-
if (state === "APPROVED")
|
|
244
|
-
return "MERGE";
|
|
245
|
-
if (state === "CHANGES_REQUESTED")
|
|
246
|
-
return "CHANGES_REQUESTED";
|
|
247
|
-
if (state === "CLOSE")
|
|
248
|
-
return "CLOSE";
|
|
249
|
-
throw new Error(`Unsupported GitHub review state: ${state}`);
|
|
250
|
-
}
|
|
251
|
-
function hasBlockingCiReports(reports) {
|
|
252
|
-
return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
|
|
253
|
-
}
|
|
254
|
-
function previousReviewText(review) {
|
|
255
|
-
return JSON.stringify({
|
|
256
|
-
body: review.body ?? "",
|
|
257
|
-
commit: review.commit?.oid,
|
|
258
|
-
state: review.state,
|
|
259
|
-
submittedAt: review.submittedAt,
|
|
260
|
-
}, null, 2);
|
|
261
|
-
}
|
|
262
|
-
function parseReviewOutputWithInlineTargets(text, targets) {
|
|
263
|
-
const output = parseReviewOutput(text);
|
|
264
|
-
validateInlineCommentTargets(output.findings, targets);
|
|
265
|
-
return output;
|
|
266
|
-
}
|
|
267
|
-
function parseRereviewOutputWithInlineTargets(text, targets) {
|
|
268
|
-
const output = parseRereviewOutput(text);
|
|
269
|
-
validateInlineCommentTargets(output.newFindings, targets, "newFindings");
|
|
270
|
-
return output;
|
|
271
|
-
}
|
|
272
|
-
export async function inlineCommentTargetsForDiff(input) {
|
|
273
|
-
if (input.ensure) {
|
|
274
|
-
await ensurePullRequestCommits({
|
|
275
|
-
commits: [
|
|
276
|
-
{
|
|
277
|
-
label: "base",
|
|
278
|
-
sha: input.fromSha,
|
|
279
|
-
source: input.ensure.fromSource,
|
|
280
|
-
},
|
|
281
|
-
{
|
|
282
|
-
label: "head",
|
|
283
|
-
sha: input.toSha,
|
|
284
|
-
source: input.ensure.toSource,
|
|
285
|
-
},
|
|
286
|
-
],
|
|
287
|
-
exec: input.exec,
|
|
288
|
-
meta: input.ensure.meta,
|
|
289
|
-
repository: input.ensure.repository,
|
|
290
|
-
worktreePath: input.worktreePath,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
const diffRange = input.range === "direct"
|
|
294
|
-
? `${shellQuote(input.fromSha)} ${shellQuote(input.toSha)}`
|
|
295
|
-
: `${shellQuote(input.fromSha)}...${shellQuote(input.toSha)}`;
|
|
296
|
-
return parseRightSideDiffTargets(await input.exec(`git diff --no-ext-diff --unified=3 ${diffRange}`, {
|
|
297
|
-
cwd: input.worktreePath,
|
|
298
|
-
}));
|
|
299
|
-
}
|
|
300
|
-
function firstTargetLine(targets, path) {
|
|
301
|
-
const lines = targets.get(path);
|
|
302
|
-
if (!lines?.size)
|
|
303
|
-
return undefined;
|
|
304
|
-
return [...lines].sort((a, b) => a - b)[0];
|
|
305
|
-
}
|
|
306
|
-
function mergeInlineCommentTargets(left, right) {
|
|
307
|
-
const merged = new Map();
|
|
308
|
-
for (const [path, lines] of [...left, ...right]) {
|
|
309
|
-
const targetLines = merged.get(path) ?? new Set();
|
|
310
|
-
for (const line of lines)
|
|
311
|
-
targetLines.add(line);
|
|
312
|
-
merged.set(path, targetLines);
|
|
313
|
-
}
|
|
314
|
-
return merged;
|
|
315
|
-
}
|
|
316
|
-
function targetLineSummary(targets, path) {
|
|
317
|
-
const lines = targets.get(path);
|
|
318
|
-
if (!lines?.size)
|
|
319
|
-
return "(none)";
|
|
320
|
-
const sorted = [...lines].sort((a, b) => a - b);
|
|
321
|
-
const shown = sorted.slice(0, 12).join(", ");
|
|
322
|
-
return sorted.length > 12 ? `${shown}, ...` : shown;
|
|
323
|
-
}
|
|
324
|
-
function indentedExcerpt(lines) {
|
|
325
|
-
return lines
|
|
326
|
-
.slice(0, 24)
|
|
327
|
-
.map((line) => ` ${line}`)
|
|
328
|
-
.join("\n");
|
|
329
|
-
}
|
|
330
|
-
function parseMergeConflictSections(output) {
|
|
331
|
-
const conflictHeaders = new Set([
|
|
332
|
-
"added in both",
|
|
333
|
-
"changed in both",
|
|
334
|
-
"removed in local",
|
|
335
|
-
"removed in remote",
|
|
336
|
-
]);
|
|
337
|
-
const sections = [];
|
|
338
|
-
let current;
|
|
339
|
-
for (const line of output.split("\n")) {
|
|
340
|
-
if (!line.trim())
|
|
341
|
-
continue;
|
|
342
|
-
if (!line.startsWith(" ") &&
|
|
343
|
-
!line.startsWith("+") &&
|
|
344
|
-
!line.startsWith("-") &&
|
|
345
|
-
!line.startsWith("@")) {
|
|
346
|
-
current = conflictHeaders.has(line)
|
|
347
|
-
? { lines: [line], paths: new Set() }
|
|
348
|
-
: undefined;
|
|
349
|
-
if (current)
|
|
350
|
-
sections.push(current);
|
|
351
|
-
continue;
|
|
352
|
-
}
|
|
353
|
-
if (!current)
|
|
354
|
-
continue;
|
|
355
|
-
current.lines.push(line);
|
|
356
|
-
const path = /^ (?:base|our|their)\s+\d+\s+[0-9a-f]+\s+(.+)$/.exec(line)?.[1];
|
|
357
|
-
if (path)
|
|
358
|
-
current.paths.add(path);
|
|
359
|
-
}
|
|
360
|
-
return sections.flatMap((section) => [...section.paths].map((path) => ({
|
|
361
|
-
excerpt: indentedExcerpt(section.lines),
|
|
362
|
-
path,
|
|
363
|
-
})));
|
|
364
|
-
}
|
|
365
|
-
export async function mergeConflictContextForDiff(input) {
|
|
366
|
-
const mergeBase = (await input.exec(`git merge-base ${shellQuote(input.baseSha)} ${shellQuote(input.headSha)}`, { cwd: input.worktreePath })).trim();
|
|
367
|
-
const output = await input.exec(`git merge-tree ${shellQuote(mergeBase)} ${shellQuote(input.headSha)} ${shellQuote(input.baseSha)}`, { cwd: input.worktreePath });
|
|
368
|
-
const conflicts = parseMergeConflictSections(output);
|
|
369
|
-
if (!conflicts.length)
|
|
370
|
-
return "";
|
|
371
|
-
return [
|
|
372
|
-
"The PR currently has unresolved merge conflicts with the base branch.",
|
|
373
|
-
"Treat unresolved conflicts as review findings and request changes when they make the PR unsafe or impossible to merge.",
|
|
374
|
-
"Use suggestedLine when it is present; it is a valid right-side PR diff line for an inline finding.",
|
|
375
|
-
...conflicts.map((conflict) => {
|
|
376
|
-
const suggestedLine = firstTargetLine(input.inlineCommentTargets, conflict.path);
|
|
377
|
-
const suggestedLineText = suggestedLine
|
|
378
|
-
? `suggestedLine: ${suggestedLine}`
|
|
379
|
-
: "suggestedLine: (no right-side PR diff line found)";
|
|
380
|
-
return `<conflict_file>\npath: ${conflict.path}\n${suggestedLineText}\nrightSideDiffLines: ${targetLineSummary(input.inlineCommentTargets, conflict.path)}\nmergeTreeExcerpt:\n${conflict.excerpt}\n</conflict_file>`;
|
|
381
|
-
}),
|
|
382
|
-
].join("\n");
|
|
383
|
-
}
|
|
384
|
-
function parsePostedFindingLocation(location) {
|
|
385
|
-
const range = /^(.*):(\d+)-(\d+)$/.exec(location);
|
|
386
|
-
if (range) {
|
|
387
|
-
return {
|
|
388
|
-
line: Number(range[3]),
|
|
389
|
-
path: range[1] ?? location,
|
|
390
|
-
startLine: Number(range[2]),
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
const line = /^(.*):(\d+)$/.exec(location);
|
|
394
|
-
if (line)
|
|
395
|
-
return { line: Number(line[2]), path: line[1] ?? location };
|
|
396
|
-
return undefined;
|
|
397
|
-
}
|
|
398
|
-
function reviewFindingsFromBody(body) {
|
|
399
|
-
const findings = [];
|
|
400
|
-
const lines = (body ?? "").split(/\r?\n/);
|
|
401
|
-
let section;
|
|
402
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
403
|
-
const line = lines[index];
|
|
404
|
-
if (line === "Inline findings:" || line === "File-level findings:") {
|
|
405
|
-
section = "finding";
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
if (line === "Requirement findings:") {
|
|
409
|
-
section = undefined;
|
|
410
|
-
continue;
|
|
411
|
-
}
|
|
412
|
-
if (section === "finding") {
|
|
413
|
-
const match = /^- (.*): (.+)$/.exec(line ?? "");
|
|
414
|
-
const fix = /^\s+Fix: (.+)$/.exec(lines[index + 1] ?? "");
|
|
415
|
-
if (!match || !fix)
|
|
416
|
-
continue;
|
|
417
|
-
const location = parsePostedFindingLocation(match[1] ?? "");
|
|
418
|
-
if (!location)
|
|
419
|
-
continue;
|
|
420
|
-
findings.push({
|
|
421
|
-
...location,
|
|
422
|
-
fix: fix[1] ?? "Please address this before merging.",
|
|
423
|
-
issue: match[2] ?? "Review finding.",
|
|
424
|
-
});
|
|
425
|
-
index += 1;
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return { findings };
|
|
430
|
-
}
|
|
431
|
-
function parsePostedFindingComment(body) {
|
|
432
|
-
const visibleBody = body
|
|
433
|
-
.replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
|
|
434
|
-
.trim();
|
|
435
|
-
const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]*?)(?:\s*\r?\n\r?\n\*\*Reviewer:\*\*[\s\S]*)?\s*$/.exec(visibleBody);
|
|
436
|
-
if (!match)
|
|
437
|
-
return undefined;
|
|
438
|
-
return {
|
|
439
|
-
fix: match[2]?.trim() || "Please address this before merging.",
|
|
440
|
-
issue: match[1]?.trim() || "Review finding.",
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
function reviewFindingsFromComments(comments) {
|
|
444
|
-
return {
|
|
445
|
-
findings: (comments ?? []).flatMap((comment) => {
|
|
446
|
-
if (comment.line == null)
|
|
447
|
-
return [];
|
|
448
|
-
const parsed = parsePostedFindingComment(comment.body);
|
|
449
|
-
if (!parsed)
|
|
450
|
-
return [];
|
|
451
|
-
return [
|
|
452
|
-
{
|
|
453
|
-
...parsed,
|
|
454
|
-
line: comment.line,
|
|
455
|
-
path: comment.path,
|
|
456
|
-
startLine: comment.startLine ?? undefined,
|
|
457
|
-
},
|
|
458
|
-
];
|
|
459
|
-
}),
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
export function reviewOutputFromState(review) {
|
|
463
|
-
const verdict = reviewStateToVerdict(review.state);
|
|
464
|
-
if (verdict === "CHANGES_REQUESTED") {
|
|
465
|
-
const fromComments = reviewFindingsFromComments(review.comments);
|
|
466
|
-
if (fromComments.findings.length)
|
|
467
|
-
return { ...fromComments, verdict };
|
|
468
|
-
return { ...reviewFindingsFromBody(review.body), verdict };
|
|
469
|
-
}
|
|
470
|
-
return verdict === "CLOSE"
|
|
471
|
-
? {
|
|
472
|
-
findings: [],
|
|
473
|
-
reason: review.body || "Close requested.",
|
|
474
|
-
verdict,
|
|
475
|
-
}
|
|
476
|
-
: { findings: [], verdict };
|
|
477
|
-
}
|
|
478
|
-
export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
479
|
-
return threads.some((thread) => {
|
|
480
|
-
const comments = [...thread.comments].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
481
|
-
const latestReviewerComment = comments
|
|
482
|
-
.filter((comment) => comment.author === reviewerAccount)
|
|
483
|
-
.at(-1);
|
|
484
|
-
if (!latestReviewerComment)
|
|
485
|
-
return false;
|
|
486
|
-
return comments.some((comment) => comment.author !== reviewerAccount &&
|
|
487
|
-
comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
export function assignThreadsByReviewFindingMarker(input) {
|
|
491
|
-
const reviewerKeys = new Set(input.reviewerKeys);
|
|
492
|
-
const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
|
|
493
|
-
for (const thread of input.threads) {
|
|
494
|
-
const markers = [
|
|
495
|
-
thread.body,
|
|
496
|
-
thread.latestBody,
|
|
497
|
-
...thread.comments.map((comment) => comment.body),
|
|
498
|
-
]
|
|
499
|
-
.flatMap(parseReviewFindingMarkers)
|
|
500
|
-
.filter((marker) => {
|
|
501
|
-
return (marker.pr === input.pr &&
|
|
502
|
-
reviewerKeys.has(marker.reviewer) &&
|
|
503
|
-
(!input.headSha || marker.head === input.headSha));
|
|
504
|
-
});
|
|
505
|
-
const reviewers = markers.length
|
|
506
|
-
? [...new Set(markers.map((marker) => marker.reviewer))]
|
|
507
|
-
: input.fallbackReviewerKeys;
|
|
508
|
-
for (const reviewer of reviewers)
|
|
509
|
-
assigned[reviewer]?.push(thread);
|
|
510
|
-
}
|
|
511
|
-
return assigned;
|
|
512
|
-
}
|
|
513
|
-
function outputFindings(reviewer, output) {
|
|
514
|
-
if (output.verdict !== "CHANGES_REQUESTED")
|
|
515
|
-
return [];
|
|
516
|
-
if ("findings" in output) {
|
|
517
|
-
return output.findings.map((finding, index) => ({
|
|
518
|
-
finding,
|
|
519
|
-
index,
|
|
520
|
-
reviewer,
|
|
521
|
-
}));
|
|
522
|
-
}
|
|
523
|
-
return output.newFindings.map((finding, index) => ({
|
|
524
|
-
finding: {
|
|
525
|
-
fix: "Please address this before merging.",
|
|
526
|
-
issue: finding.body,
|
|
527
|
-
line: finding.line,
|
|
528
|
-
path: finding.path,
|
|
529
|
-
startLine: finding.startLine,
|
|
530
|
-
},
|
|
531
|
-
index,
|
|
532
|
-
reviewer,
|
|
533
|
-
}));
|
|
534
|
-
}
|
|
535
|
-
function singleReviewBody(input) {
|
|
536
|
-
const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
|
|
537
|
-
const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
|
|
538
|
-
? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
|
|
539
|
-
: []);
|
|
540
|
-
const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
|
|
541
|
-
const line = finding.startLine == null || finding.startLine === finding.line
|
|
542
|
-
? String(finding.line)
|
|
543
|
-
: `${finding.startLine}-${finding.line}`;
|
|
544
|
-
return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
|
|
545
|
-
}));
|
|
546
|
-
const lines = [
|
|
547
|
-
`Magi single-account review result: ${input.verdict}.`,
|
|
548
|
-
"",
|
|
549
|
-
"Logical reviewer verdicts:",
|
|
550
|
-
...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
|
|
551
|
-
...(input.verdict === "CLOSE" && closeReasons.length
|
|
552
|
-
? ["", "Close reasons:", ...closeReasons]
|
|
553
|
-
: []),
|
|
554
|
-
...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
|
|
555
|
-
? ["", "Accepted change requests:", ...acceptedFindings]
|
|
556
|
-
: []),
|
|
557
|
-
"",
|
|
558
|
-
...outputs.map(([reviewer, output]) => formatReviewMarker({
|
|
559
|
-
head: input.headSha,
|
|
560
|
-
pr: input.pr,
|
|
561
|
-
reviewer,
|
|
562
|
-
verdict: output.verdict,
|
|
563
|
-
})),
|
|
564
|
-
];
|
|
565
|
-
return lines.join("\n");
|
|
566
|
-
}
|
|
567
|
-
function singleFindingBody(input) {
|
|
568
|
-
return [
|
|
569
|
-
`**Issue:** ${input.finding.issue}`,
|
|
570
|
-
"",
|
|
571
|
-
`**Fix:** ${input.finding.fix}`,
|
|
572
|
-
"",
|
|
573
|
-
`**Reviewer:** ${input.reviewer}`,
|
|
574
|
-
"",
|
|
575
|
-
formatReviewFindingMarker({
|
|
576
|
-
finding: input.index,
|
|
577
|
-
head: input.headSha,
|
|
578
|
-
pr: input.pr,
|
|
579
|
-
reviewer: input.reviewer,
|
|
580
|
-
}),
|
|
581
|
-
].join("\n");
|
|
582
|
-
}
|
|
583
|
-
export async function postSingleConsensusReview(input) {
|
|
584
|
-
const account = input.repository.account;
|
|
585
|
-
if (!account)
|
|
586
|
-
throw new Error("account is required for single review mode");
|
|
587
|
-
const body = singleReviewBody(input);
|
|
588
|
-
if (input.verdict === "MERGE") {
|
|
589
|
-
return postApproval(input.exec, input.repository, input.pr, account, body);
|
|
590
|
-
}
|
|
591
|
-
if (input.verdict === "CLOSE") {
|
|
592
|
-
return postCloseComment(input.exec, input.repository, input.pr, account, body);
|
|
593
|
-
}
|
|
594
|
-
const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
|
|
595
|
-
return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
|
|
596
|
-
body,
|
|
597
|
-
commentBodies: findings.map((item) => singleFindingBody({
|
|
598
|
-
finding: item.finding,
|
|
599
|
-
headSha: input.headSha,
|
|
600
|
-
index: item.index,
|
|
601
|
-
pr: input.pr,
|
|
602
|
-
reviewer: item.reviewer,
|
|
603
|
-
})),
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
async function postRereviewOutput(input, reviewerKey, output) {
|
|
607
|
-
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
608
|
-
if (!reviewer)
|
|
609
|
-
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
610
|
-
const account = reviewPostingAccount(input.repository, reviewer);
|
|
611
|
-
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
|
|
612
|
-
await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
|
|
613
|
-
if (output.verdict === "MERGE")
|
|
614
|
-
return postApproval(input.exec, input.repository, input.pr, account);
|
|
615
|
-
if (output.verdict === "CLOSE")
|
|
616
|
-
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
617
|
-
if (!output.newFindings.length)
|
|
618
|
-
return "";
|
|
619
|
-
return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
|
|
620
|
-
fix: "Please address this before merging.",
|
|
621
|
-
issue: finding.body,
|
|
622
|
-
path: finding.path,
|
|
623
|
-
line: finding.line,
|
|
624
|
-
startLine: finding.startLine,
|
|
625
|
-
})));
|
|
626
|
-
}
|
|
627
|
-
function isReviewOutput(output) {
|
|
628
|
-
return "findings" in output;
|
|
629
|
-
}
|
|
630
|
-
async function runFindingValidation(input) {
|
|
631
|
-
const outputs = Object.fromEntries(input.entries.map((entry) => [entry.key, entry.value]));
|
|
632
|
-
const targets = reviewFindingTargets(outputs);
|
|
633
|
-
if (!targets.length) {
|
|
634
|
-
return {
|
|
635
|
-
outputs: Object.fromEntries(input.entries.map((entry) => [entry.key, entry.value])),
|
|
636
|
-
summary: { discarded: [], kept: [] },
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
await input.reviewInput.onProgress?.({
|
|
640
|
-
phase: "validating review findings",
|
|
641
|
-
type: "phase",
|
|
642
|
-
});
|
|
643
|
-
const validations = Object.fromEntries(await mapPool(input.reviewInput.repository.agents.reviewers, input.reviewInput.repository.concurrency.reviewers, async (reviewer) => {
|
|
644
|
-
const reviewerTargets = targets.filter((target) => target.reviewer !== reviewer.key);
|
|
645
|
-
if (!reviewerTargets.length)
|
|
646
|
-
return [reviewer.key, { votes: [] }];
|
|
647
|
-
const hasReviewerSession = Boolean(input.sessionIds[reviewer.key]);
|
|
648
|
-
const prompt = await composeFindingValidationPrompt({
|
|
649
|
-
baseSha: input.meta.baseRefOid,
|
|
650
|
-
directory: input.reviewInput.directory,
|
|
651
|
-
findings: JSON.stringify(reviewerTargets, null, 2),
|
|
652
|
-
headSha: input.meta.headRefOid,
|
|
653
|
-
includeReviewGuidelines: !hasReviewerSession,
|
|
654
|
-
includeSessionContext: !hasReviewerSession,
|
|
655
|
-
pr: input.reviewInput.pr,
|
|
656
|
-
repository: input.reviewInput.repository,
|
|
657
|
-
reviewContext: input.reviewContext,
|
|
658
|
-
reviewer,
|
|
659
|
-
worktreePath: input.worktreePath,
|
|
660
|
-
});
|
|
661
|
-
const result = await withReviewerFailureProgress({
|
|
662
|
-
onProgress: input.reviewInput.onProgress,
|
|
663
|
-
reviewer: reviewer.key,
|
|
664
|
-
run: () => runModelWithRepair({
|
|
665
|
-
client: input.reviewInput.client,
|
|
666
|
-
model: reviewer.model,
|
|
667
|
-
onProgress: async (progress) => {
|
|
668
|
-
if (progress.type === "session_created") {
|
|
669
|
-
await input.reviewInput.onProgress?.({
|
|
670
|
-
reviewer: reviewer.key,
|
|
671
|
-
options: progress.options,
|
|
672
|
-
sessionId: progress.sessionId,
|
|
673
|
-
type: "reviewer_session",
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
if (progress.type === "repair") {
|
|
677
|
-
await input.reviewInput.onProgress?.({
|
|
678
|
-
reviewer: reviewer.key,
|
|
679
|
-
type: "reviewer_repair",
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
if (progress.type === "response") {
|
|
683
|
-
await input.reviewInput.onProgress?.({
|
|
684
|
-
reviewer: reviewer.key,
|
|
685
|
-
sessionId: progress.sessionId,
|
|
686
|
-
type: "reviewer_response",
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
},
|
|
690
|
-
options: reviewer.options,
|
|
691
|
-
parentSessionId: input.reviewInput.parentSessionId,
|
|
692
|
-
parse: (text) => {
|
|
693
|
-
const output = parseFindingValidationOutput(text);
|
|
694
|
-
validateFindingVotes({
|
|
695
|
-
output,
|
|
696
|
-
targets,
|
|
697
|
-
validator: reviewer.key,
|
|
698
|
-
});
|
|
699
|
-
return output;
|
|
700
|
-
},
|
|
701
|
-
permission: reviewer.permission,
|
|
702
|
-
prompt,
|
|
703
|
-
repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
|
|
704
|
-
schemaName: "finding validation",
|
|
705
|
-
sessionId: input.sessionIds[reviewer.key],
|
|
706
|
-
signal: input.reviewInput.signal,
|
|
707
|
-
title: `magi validate findings ${input.reviewInput.repository.alias}#${input.reviewInput.pr} ${reviewer.key}`,
|
|
708
|
-
}),
|
|
709
|
-
});
|
|
710
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.prompt.txt`), prompt);
|
|
711
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.raw.txt`), result.raw);
|
|
712
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.finding-validation.json`), JSON.stringify(result.value, null, 2));
|
|
713
|
-
input.sessionIds[reviewer.key] = result.sessionId;
|
|
714
|
-
return [reviewer.key, result.value];
|
|
715
|
-
}, { signal: input.reviewInput.signal }));
|
|
716
|
-
const filtered = applyFindingValidation({
|
|
717
|
-
outputs,
|
|
718
|
-
reviewerKeys: input.reviewInput.repository.agents.reviewers.map((reviewer) => reviewer.key),
|
|
719
|
-
validations,
|
|
720
|
-
});
|
|
721
|
-
await writeFile(join(input.outputDir, "finding-validation.json"), JSON.stringify({ validations, ...filtered.summary }, null, 2));
|
|
722
|
-
await input.reviewInput.onProgress?.({
|
|
723
|
-
discarded: filtered.summary.discarded.length,
|
|
724
|
-
kept: filtered.summary.kept.length,
|
|
725
|
-
reviewersChangedToMerge: Object.entries(outputs)
|
|
726
|
-
.filter(([reviewer, output]) => {
|
|
727
|
-
return (output.verdict === "CHANGES_REQUESTED" &&
|
|
728
|
-
filtered.outputs[reviewer]?.verdict === "MERGE");
|
|
729
|
-
})
|
|
730
|
-
.map(([reviewer]) => reviewer),
|
|
731
|
-
type: "findings_validated",
|
|
732
|
-
});
|
|
733
|
-
return {
|
|
734
|
-
outputs: Object.fromEntries(input.entries.map((entry) => [
|
|
735
|
-
entry.key,
|
|
736
|
-
filtered.outputs[entry.key] ?? entry.value,
|
|
737
|
-
])),
|
|
738
|
-
summary: filtered.summary,
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
async function runCloseReconsideration(input) {
|
|
742
|
-
const targets = input.targets ??
|
|
743
|
-
closeMinorityReviewers(input.entries.map((entry) => ({
|
|
744
|
-
reviewer: entry.key,
|
|
745
|
-
verdict: entry.value.verdict,
|
|
746
|
-
})));
|
|
747
|
-
if (!targets.length)
|
|
748
|
-
return input.entries;
|
|
749
|
-
await input.reviewInput.onProgress?.({
|
|
750
|
-
phase: "reconsidering close verdicts",
|
|
751
|
-
type: "phase",
|
|
752
|
-
});
|
|
753
|
-
return Promise.all(input.entries.map(async (entry) => {
|
|
754
|
-
if (!targets.includes(entry.key)) {
|
|
755
|
-
return entry;
|
|
756
|
-
}
|
|
757
|
-
const reviewer = input.reviewInput.repository.agents.reviewers.find((item) => item.key === entry.key);
|
|
758
|
-
if (!reviewer)
|
|
759
|
-
return entry;
|
|
760
|
-
const hasReviewerSession = Boolean(input.sessionIds[reviewer.key]);
|
|
761
|
-
const isReviewEntry = isReviewOutput(entry.value);
|
|
762
|
-
let prompt;
|
|
763
|
-
if (isReviewEntry) {
|
|
764
|
-
prompt = await composeCloseReconsiderationPrompt({
|
|
765
|
-
baseSha: input.meta.baseRefOid,
|
|
766
|
-
ciFailureContext: undefined,
|
|
767
|
-
closeReason: entry.value.reason,
|
|
768
|
-
directory: input.reviewInput.directory,
|
|
769
|
-
headSha: input.meta.headRefOid,
|
|
770
|
-
includeReviewGuidelines: !hasReviewerSession,
|
|
771
|
-
includeSessionContext: !hasReviewerSession,
|
|
772
|
-
pr: input.reviewInput.pr,
|
|
773
|
-
repository: input.reviewInput.repository,
|
|
774
|
-
reviewContext: input.reviewContext,
|
|
775
|
-
reviewer,
|
|
776
|
-
worktreePath: input.worktreePath,
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
else {
|
|
780
|
-
if (!entry.previousHeadSha) {
|
|
781
|
-
throw new Error(`Missing previous review commit for ${reviewer.account}`);
|
|
782
|
-
}
|
|
783
|
-
prompt = await composeRereviewCloseReconsiderationPrompt({
|
|
784
|
-
baseSha: input.meta.baseRefOid,
|
|
785
|
-
closeReason: entry.value.reason,
|
|
786
|
-
directory: input.reviewInput.directory,
|
|
787
|
-
headSha: input.meta.headRefOid,
|
|
788
|
-
includeReviewGuidelines: !hasReviewerSession,
|
|
789
|
-
includeSessionContext: !hasReviewerSession,
|
|
790
|
-
pr: input.reviewInput.pr,
|
|
791
|
-
previousHeadSha: entry.previousHeadSha,
|
|
792
|
-
repository: input.reviewInput.repository,
|
|
793
|
-
reviewer,
|
|
794
|
-
worktreePath: input.worktreePath,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
const result = await withReviewerFailureProgress({
|
|
798
|
-
onProgress: input.reviewInput.onProgress,
|
|
799
|
-
reviewer: reviewer.key,
|
|
800
|
-
run: () => runModelWithRepair({
|
|
801
|
-
client: input.reviewInput.client,
|
|
802
|
-
model: reviewer.model,
|
|
803
|
-
onProgress: async (progress) => {
|
|
804
|
-
if (progress.type === "session_created") {
|
|
805
|
-
await input.reviewInput.onProgress?.({
|
|
806
|
-
reviewer: reviewer.key,
|
|
807
|
-
options: progress.options,
|
|
808
|
-
sessionId: progress.sessionId,
|
|
809
|
-
type: "reviewer_session",
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
if (progress.type === "repair") {
|
|
813
|
-
await input.reviewInput.onProgress?.({
|
|
814
|
-
reviewer: reviewer.key,
|
|
815
|
-
type: "reviewer_repair",
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
if (progress.type === "response") {
|
|
819
|
-
await input.reviewInput.onProgress?.({
|
|
820
|
-
reviewer: reviewer.key,
|
|
821
|
-
sessionId: progress.sessionId,
|
|
822
|
-
type: "reviewer_response",
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
},
|
|
826
|
-
options: reviewer.options,
|
|
827
|
-
parentSessionId: input.reviewInput.parentSessionId,
|
|
828
|
-
parse: (text) => {
|
|
829
|
-
const output = isReviewEntry
|
|
830
|
-
? parseCloseReconsiderationOutput(text)
|
|
831
|
-
: parseRereviewCloseReconsiderationOutput(text);
|
|
832
|
-
const findings = "newFindings" in output ? output.newFindings : output.findings;
|
|
833
|
-
validateInlineCommentTargets(findings, entry.inlineCommentTargets, "newFindings" in output ? "newFindings" : "findings");
|
|
834
|
-
return output;
|
|
835
|
-
},
|
|
836
|
-
permission: reviewer.permission,
|
|
837
|
-
prompt,
|
|
838
|
-
repairAttempts: input.reviewInput.config.output?.repairAttempts ?? 3,
|
|
839
|
-
schemaName: isReviewEntry
|
|
840
|
-
? "close reconsideration"
|
|
841
|
-
: "rereview close reconsideration",
|
|
842
|
-
sessionId: input.sessionIds[reviewer.key],
|
|
843
|
-
signal: input.reviewInput.signal,
|
|
844
|
-
title: `magi reconsider close ${input.reviewInput.repository.alias}#${input.reviewInput.pr} ${reviewer.key}`,
|
|
845
|
-
}),
|
|
846
|
-
});
|
|
847
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.prompt.txt`), prompt);
|
|
848
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.raw.txt`), result.raw);
|
|
849
|
-
await writeFile(join(input.outputDir, `${reviewer.key}.close-reconsideration.json`), JSON.stringify(result.value, null, 2));
|
|
850
|
-
await input.reviewInput.onProgress?.({
|
|
851
|
-
from: "CLOSE",
|
|
852
|
-
reviewer: reviewer.key,
|
|
853
|
-
to: result.value.verdict,
|
|
854
|
-
type: "reviewer_reconsidered",
|
|
855
|
-
});
|
|
856
|
-
await input.reviewInput.onProgress?.({
|
|
857
|
-
reviewer: reviewer.key,
|
|
858
|
-
sessionId: result.sessionId,
|
|
859
|
-
type: "reviewer_completed",
|
|
860
|
-
verdict: result.value.verdict,
|
|
861
|
-
});
|
|
862
|
-
input.sessionIds[reviewer.key] = result.sessionId;
|
|
863
|
-
return {
|
|
864
|
-
inlineCommentTargets: entry.inlineCommentTargets,
|
|
865
|
-
key: entry.key,
|
|
866
|
-
previousHeadSha: entry.previousHeadSha,
|
|
867
|
-
raw: result.raw,
|
|
868
|
-
sessionId: result.sessionId,
|
|
869
|
-
value: result.value,
|
|
870
|
-
};
|
|
871
|
-
}));
|
|
872
|
-
}
|
|
873
|
-
export async function runReview(input) {
|
|
874
|
-
const exec = withAbortSignal(input.exec, input.signal);
|
|
875
|
-
throwIfAborted(input.signal);
|
|
876
|
-
await input.onProgress?.({ phase: "fetching PR metadata", type: "phase" });
|
|
877
|
-
const meta = await fetchPullRequest(exec, input.repository, input.pr);
|
|
878
|
-
if (meta.isDraft)
|
|
879
|
-
throw new Error(`PR #${input.pr} is a draft`);
|
|
880
|
-
if (!input.skipSafety && hasSafetyGate(input.repository)) {
|
|
881
|
-
await input.onProgress?.({ phase: "checking safety", type: "phase" });
|
|
882
|
-
const safety = await checkSafetyGate({
|
|
883
|
-
exec,
|
|
884
|
-
pr: input.pr,
|
|
885
|
-
repository: input.repository,
|
|
886
|
-
});
|
|
887
|
-
if (!safety.ok) {
|
|
888
|
-
const outputDir = prRunOutputDir({
|
|
889
|
-
config: input.config,
|
|
890
|
-
directory: input.directory,
|
|
891
|
-
pr: input.pr,
|
|
892
|
-
runId: input.runId,
|
|
893
|
-
});
|
|
894
|
-
await mkdir(outputDir, { recursive: true });
|
|
895
|
-
const report = formatReviewReport({
|
|
896
|
-
ciReports: [],
|
|
897
|
-
dryRun: input.dryRun,
|
|
898
|
-
outputs: {},
|
|
899
|
-
posted: {},
|
|
900
|
-
pr: input.pr,
|
|
901
|
-
repository: input.repository,
|
|
902
|
-
safety,
|
|
903
|
-
});
|
|
904
|
-
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
905
|
-
await input.onProgress?.({ type: "completed", verdict: "SAFETY_BLOCKED" });
|
|
906
|
-
return {
|
|
907
|
-
baseSha: meta.baseRefOid,
|
|
908
|
-
ciReports: [],
|
|
909
|
-
discardedFindings: [],
|
|
910
|
-
headSha: meta.headRefOid,
|
|
911
|
-
outputs: {},
|
|
912
|
-
posted: {},
|
|
913
|
-
pr: input.pr,
|
|
914
|
-
report,
|
|
915
|
-
sessionIds: {},
|
|
916
|
-
verdict: "SAFETY_BLOCKED",
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
await input.onProgress?.({
|
|
921
|
-
phase: "fetching existing reviews",
|
|
922
|
-
type: "phase",
|
|
923
|
-
});
|
|
924
|
-
const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
|
|
925
|
-
const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
|
|
926
|
-
const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
|
|
927
|
-
const singleReviewMode = resolvedReviewMode(input.repository) === "single";
|
|
928
|
-
const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
|
|
929
|
-
const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
|
|
930
|
-
const preliminaryMode = singleReviewMode
|
|
931
|
-
? resolveSingleAccountReviewMode({
|
|
932
|
-
account: input.repository.account ?? "",
|
|
933
|
-
current: freshnessTarget,
|
|
934
|
-
pr: input.pr,
|
|
935
|
-
reviewerKeys,
|
|
936
|
-
reviews,
|
|
937
|
-
})
|
|
938
|
-
: resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
|
|
939
|
-
const unresolvedThreadsByAccount = new Map();
|
|
940
|
-
const unresolvedThreadsByReviewer = new Map();
|
|
941
|
-
const pendingThreadReplyAccounts = new Set();
|
|
942
|
-
const pendingThreadReplyReviewers = new Set();
|
|
943
|
-
const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
|
|
944
|
-
return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
|
|
945
|
-
});
|
|
946
|
-
if (singleReviewMode) {
|
|
947
|
-
const account = input.repository.account ?? "";
|
|
948
|
-
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
|
|
949
|
-
const assigned = assignThreadsByReviewFindingMarker({
|
|
950
|
-
fallbackReviewerKeys: reviewerKeys,
|
|
951
|
-
pr: input.pr,
|
|
952
|
-
reviewerKeys,
|
|
953
|
-
threads,
|
|
954
|
-
});
|
|
955
|
-
for (const reviewer of input.repository.agents.reviewers) {
|
|
956
|
-
const reviewerThreads = assigned[reviewer.key] ?? [];
|
|
957
|
-
unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
|
|
958
|
-
if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
|
|
959
|
-
continue;
|
|
960
|
-
}
|
|
961
|
-
if (hasPendingThreadReply(reviewerThreads, account)) {
|
|
962
|
-
pendingThreadReplyReviewers.add(reviewer.key);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
else {
|
|
967
|
-
await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
968
|
-
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
|
|
969
|
-
unresolvedThreadsByAccount.set(reviewer.account, threads);
|
|
970
|
-
if (hasPendingThreadReply(threads, reviewer.account)) {
|
|
971
|
-
pendingThreadReplyAccounts.add(reviewer.account);
|
|
972
|
-
}
|
|
973
|
-
}, { signal: input.signal });
|
|
974
|
-
}
|
|
975
|
-
const mode = singleReviewMode
|
|
976
|
-
? pendingThreadReplyReviewers.size
|
|
977
|
-
? resolveSingleAccountReviewMode({
|
|
978
|
-
account: input.repository.account ?? "",
|
|
979
|
-
current: freshnessTarget,
|
|
980
|
-
pendingReviewers: pendingThreadReplyReviewers,
|
|
981
|
-
pr: input.pr,
|
|
982
|
-
reviewerKeys,
|
|
983
|
-
reviews,
|
|
984
|
-
})
|
|
985
|
-
: preliminaryMode
|
|
986
|
-
: pendingThreadReplyAccounts.size
|
|
987
|
-
? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
|
|
988
|
-
: preliminaryMode;
|
|
989
|
-
if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
|
|
990
|
-
throw new Error("PR has already been reviewed by all configured accounts");
|
|
991
|
-
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
992
|
-
const outputDir = prRunOutputDir({
|
|
993
|
-
config: input.config,
|
|
994
|
-
directory: input.directory,
|
|
995
|
-
pr: input.pr,
|
|
996
|
-
runId,
|
|
997
|
-
});
|
|
998
|
-
await mkdir(outputDir, { recursive: true });
|
|
999
|
-
await input.onProgress?.({ phase: "fetching review context", type: "phase" });
|
|
1000
|
-
const reviewContextSnapshot = await buildReviewContextSnapshot({
|
|
1001
|
-
exec,
|
|
1002
|
-
pr: meta,
|
|
1003
|
-
repository: input.repository,
|
|
1004
|
-
});
|
|
1005
|
-
const reviewContext = renderReviewContext(reviewContextSnapshot);
|
|
1006
|
-
await writeFile(join(outputDir, "review-context.json"), JSON.stringify(reviewContextSnapshot, null, 2));
|
|
1007
|
-
await writeFile(join(outputDir, "review-context.md"), `${reviewContext}\n`);
|
|
1008
|
-
await input.onProgress?.({ phase: "waiting for checks", type: "phase" });
|
|
1009
|
-
const checkResult = await waitForChecksWithClassification({
|
|
1010
|
-
client: input.client,
|
|
1011
|
-
directory: input.directory,
|
|
1012
|
-
exec,
|
|
1013
|
-
headSha: meta.headRefOid,
|
|
1014
|
-
onClassifierProgress: (progress) => {
|
|
1015
|
-
if (progress.type === "classifier_started") {
|
|
1016
|
-
return input.onProgress?.({
|
|
1017
|
-
...progress,
|
|
1018
|
-
type: "ci_classifier_started",
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
if (progress.type === "classifier_session") {
|
|
1022
|
-
return input.onProgress?.({
|
|
1023
|
-
...progress,
|
|
1024
|
-
type: "ci_classifier_session",
|
|
1025
|
-
});
|
|
1026
|
-
}
|
|
1027
|
-
if (progress.type === "classifier_repair") {
|
|
1028
|
-
return input.onProgress?.({
|
|
1029
|
-
...progress,
|
|
1030
|
-
type: "ci_classifier_repair",
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
if (progress.type === "classifier_completed") {
|
|
1034
|
-
return input.onProgress?.({
|
|
1035
|
-
...progress,
|
|
1036
|
-
type: "ci_classifier_completed",
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
return input.onProgress?.({
|
|
1040
|
-
...progress,
|
|
1041
|
-
type: "ci_classifier_failed",
|
|
1042
|
-
});
|
|
1043
|
-
},
|
|
1044
|
-
onProgress: (phase) => input.onProgress?.({ phase, type: "phase" }),
|
|
1045
|
-
outputDir,
|
|
1046
|
-
parentSessionId: input.parentSessionId,
|
|
1047
|
-
pr: input.pr,
|
|
1048
|
-
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
1049
|
-
repository: input.repository,
|
|
1050
|
-
dryRun: input.dryRun,
|
|
1051
|
-
signal: input.signal,
|
|
1052
|
-
wait: input.repository.checks.waitBeforeReview,
|
|
1053
|
-
});
|
|
1054
|
-
const ciFailureContext = checkResult?.ciFailureContext ?? "";
|
|
1055
|
-
const ciReports = checkResult ? [checkResult.report] : [];
|
|
1056
|
-
if (checkResult &&
|
|
1057
|
-
(checkResult.report.scopeOutsideRecovered.length ||
|
|
1058
|
-
checkResult.report.scopeOutsideUnresolved.length ||
|
|
1059
|
-
checkResult.report.scopeInside.length)) {
|
|
1060
|
-
await input.onProgress?.({ report: checkResult.report, type: "ci_report" });
|
|
1061
|
-
}
|
|
1062
|
-
const worktreePath = prRunWorktreeDir({
|
|
1063
|
-
config: input.config,
|
|
1064
|
-
directory: input.directory,
|
|
1065
|
-
pr: input.pr,
|
|
1066
|
-
runId,
|
|
1067
|
-
});
|
|
1068
|
-
await input.onProgress?.({ phase: "creating worktree", type: "phase" });
|
|
1069
|
-
const worktree = await createWorktree(exec, input.repository, input.pr, worktreePath);
|
|
1070
|
-
await input.onProgress?.({
|
|
1071
|
-
branch: worktree.branch,
|
|
1072
|
-
type: "worktree_created",
|
|
1073
|
-
worktreePath,
|
|
1074
|
-
});
|
|
1075
|
-
try {
|
|
1076
|
-
throwIfAborted(input.signal);
|
|
1077
|
-
const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1078
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1079
|
-
if (!assignment || assignment.type === "skip")
|
|
1080
|
-
return [];
|
|
1081
|
-
return [{ assignment, reviewer }];
|
|
1082
|
-
});
|
|
1083
|
-
const initialInlineCommentTargets = await inlineCommentTargetsForDiff({
|
|
1084
|
-
ensure: {
|
|
1085
|
-
fromSource: "base",
|
|
1086
|
-
meta,
|
|
1087
|
-
repository: input.repository,
|
|
1088
|
-
toSource: "head",
|
|
1089
|
-
},
|
|
1090
|
-
exec,
|
|
1091
|
-
fromSha: meta.baseRefOid,
|
|
1092
|
-
toSha: meta.headRefOid,
|
|
1093
|
-
worktreePath,
|
|
1094
|
-
});
|
|
1095
|
-
const mergeConflictContext = await mergeConflictContextForDiff({
|
|
1096
|
-
baseSha: meta.baseRefOid,
|
|
1097
|
-
exec,
|
|
1098
|
-
headSha: meta.headRefOid,
|
|
1099
|
-
inlineCommentTargets: initialInlineCommentTargets,
|
|
1100
|
-
worktreePath,
|
|
1101
|
-
});
|
|
1102
|
-
for (const reviewer of input.repository.agents.reviewers) {
|
|
1103
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1104
|
-
if (assignment?.type !== "skip")
|
|
1105
|
-
continue;
|
|
1106
|
-
await input.onProgress?.({
|
|
1107
|
-
reviewer: reviewer.key,
|
|
1108
|
-
type: "reviewer_skipped",
|
|
1109
|
-
verdict: reviewStateToVerdict(assignment.review.state),
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
let entries = await mapPool(activeReviewers, input.repository.concurrency.reviewers, async ({ assignment, reviewer }) => {
|
|
1113
|
-
await input.onProgress?.({
|
|
1114
|
-
reviewer: reviewer.key,
|
|
1115
|
-
type: "reviewer_started",
|
|
1116
|
-
});
|
|
1117
|
-
if (assignment.type === "rereview") {
|
|
1118
|
-
const previous = assignment.review;
|
|
1119
|
-
if (!previous.commit?.oid)
|
|
1120
|
-
throw new Error(`Missing previous review commit for ${reviewer.account}`);
|
|
1121
|
-
const inlineCommentTargets = await inlineCommentTargetsForDiff({
|
|
1122
|
-
ensure: {
|
|
1123
|
-
fromSource: "head",
|
|
1124
|
-
meta,
|
|
1125
|
-
repository: input.repository,
|
|
1126
|
-
toSource: "head",
|
|
1127
|
-
},
|
|
1128
|
-
exec,
|
|
1129
|
-
fromSha: previous.commit.oid,
|
|
1130
|
-
toSha: meta.headRefOid,
|
|
1131
|
-
worktreePath,
|
|
1132
|
-
});
|
|
1133
|
-
const rereviewInlineCommentTargets = mergeConflictContext
|
|
1134
|
-
? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
|
|
1135
|
-
: inlineCommentTargets;
|
|
1136
|
-
const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
|
|
1137
|
-
unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
1138
|
-
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
|
|
1139
|
-
const prompt = await composeRereviewPrompt({
|
|
1140
|
-
baseSha: meta.baseRefOid,
|
|
1141
|
-
ciFailureContext,
|
|
1142
|
-
directory: input.directory,
|
|
1143
|
-
headSha: meta.headRefOid,
|
|
1144
|
-
mergeConflictContext,
|
|
1145
|
-
pr: input.pr,
|
|
1146
|
-
previousReview: previousReviewText(previous),
|
|
1147
|
-
previousHeadSha: previous.commit.oid,
|
|
1148
|
-
repository: input.repository,
|
|
1149
|
-
reviewContext,
|
|
1150
|
-
reviewer,
|
|
1151
|
-
unresolvedThreads: JSON.stringify(unresolved, null, 2),
|
|
1152
|
-
worktreePath,
|
|
1153
|
-
});
|
|
1154
|
-
const result = await withReviewerFailureProgress({
|
|
1155
|
-
onProgress: input.onProgress,
|
|
1156
|
-
reviewer: reviewer.key,
|
|
1157
|
-
run: () => runModelWithRepair({
|
|
1158
|
-
client: input.client,
|
|
1159
|
-
model: reviewer.model,
|
|
1160
|
-
onProgress: async (progress) => {
|
|
1161
|
-
if (progress.type === "session_created") {
|
|
1162
|
-
await input.onProgress?.({
|
|
1163
|
-
reviewer: reviewer.key,
|
|
1164
|
-
options: progress.options,
|
|
1165
|
-
sessionId: progress.sessionId,
|
|
1166
|
-
type: "reviewer_session",
|
|
1167
|
-
});
|
|
1168
|
-
}
|
|
1169
|
-
if (progress.type === "repair") {
|
|
1170
|
-
await input.onProgress?.({
|
|
1171
|
-
reviewer: reviewer.key,
|
|
1172
|
-
type: "reviewer_repair",
|
|
1173
|
-
});
|
|
1174
|
-
}
|
|
1175
|
-
if (progress.type === "response") {
|
|
1176
|
-
await input.onProgress?.({
|
|
1177
|
-
reviewer: reviewer.key,
|
|
1178
|
-
sessionId: progress.sessionId,
|
|
1179
|
-
type: "reviewer_response",
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
},
|
|
1183
|
-
options: reviewer.options,
|
|
1184
|
-
parentSessionId: input.parentSessionId,
|
|
1185
|
-
parse: (text) => parseRereviewOutputWithInlineTargets(text, rereviewInlineCommentTargets),
|
|
1186
|
-
permission: reviewer.permission,
|
|
1187
|
-
prompt,
|
|
1188
|
-
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
1189
|
-
schemaName: "rereview",
|
|
1190
|
-
signal: input.signal,
|
|
1191
|
-
title: `magi rereview ${input.repository.alias}#${input.pr} ${reviewer.key}`,
|
|
1192
|
-
}),
|
|
1193
|
-
});
|
|
1194
|
-
await writeFile(join(outputDir, `${reviewer.key}.rereview.prompt.txt`), prompt);
|
|
1195
|
-
await writeFile(join(outputDir, `${reviewer.key}.rereview.raw.txt`), result.raw);
|
|
1196
|
-
await writeFile(join(outputDir, `${reviewer.key}.rereview.json`), JSON.stringify(result.value, null, 2));
|
|
1197
|
-
await input.onProgress?.({
|
|
1198
|
-
reviewer: reviewer.key,
|
|
1199
|
-
sessionId: result.sessionId,
|
|
1200
|
-
type: "reviewer_completed",
|
|
1201
|
-
verdict: result.value.verdict,
|
|
1202
|
-
});
|
|
1203
|
-
return {
|
|
1204
|
-
inlineCommentTargets,
|
|
1205
|
-
key: reviewer.key,
|
|
1206
|
-
previousHeadSha: previous.commit.oid,
|
|
1207
|
-
raw: result.raw,
|
|
1208
|
-
sessionId: result.sessionId,
|
|
1209
|
-
value: result.value,
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
const prompt = await composeReviewPrompt({
|
|
1213
|
-
baseSha: meta.baseRefOid,
|
|
1214
|
-
ciFailureContext,
|
|
1215
|
-
directory: input.directory,
|
|
1216
|
-
headSha: meta.headRefOid,
|
|
1217
|
-
mergeConflictContext,
|
|
1218
|
-
pr: input.pr,
|
|
1219
|
-
repository: input.repository,
|
|
1220
|
-
reviewContext,
|
|
1221
|
-
reviewer,
|
|
1222
|
-
worktreePath,
|
|
1223
|
-
});
|
|
1224
|
-
const result = await withReviewerFailureProgress({
|
|
1225
|
-
onProgress: input.onProgress,
|
|
1226
|
-
reviewer: reviewer.key,
|
|
1227
|
-
run: () => runModelWithRepair({
|
|
1228
|
-
client: input.client,
|
|
1229
|
-
model: reviewer.model,
|
|
1230
|
-
onProgress: async (progress) => {
|
|
1231
|
-
if (progress.type === "session_created") {
|
|
1232
|
-
await input.onProgress?.({
|
|
1233
|
-
reviewer: reviewer.key,
|
|
1234
|
-
options: progress.options,
|
|
1235
|
-
sessionId: progress.sessionId,
|
|
1236
|
-
type: "reviewer_session",
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1239
|
-
if (progress.type === "repair") {
|
|
1240
|
-
await input.onProgress?.({
|
|
1241
|
-
reviewer: reviewer.key,
|
|
1242
|
-
type: "reviewer_repair",
|
|
1243
|
-
});
|
|
1244
|
-
}
|
|
1245
|
-
if (progress.type === "response") {
|
|
1246
|
-
await input.onProgress?.({
|
|
1247
|
-
reviewer: reviewer.key,
|
|
1248
|
-
sessionId: progress.sessionId,
|
|
1249
|
-
type: "reviewer_response",
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
},
|
|
1253
|
-
options: reviewer.options,
|
|
1254
|
-
parentSessionId: input.parentSessionId,
|
|
1255
|
-
parse: (text) => parseReviewOutputWithInlineTargets(text, initialInlineCommentTargets),
|
|
1256
|
-
permission: reviewer.permission,
|
|
1257
|
-
prompt,
|
|
1258
|
-
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
1259
|
-
schemaName: "review",
|
|
1260
|
-
signal: input.signal,
|
|
1261
|
-
title: `magi review ${input.repository.alias}#${input.pr} ${reviewer.key}`,
|
|
1262
|
-
}),
|
|
1263
|
-
});
|
|
1264
|
-
await writeFile(join(outputDir, `${reviewer.key}.review.prompt.txt`), prompt);
|
|
1265
|
-
await writeFile(join(outputDir, `${reviewer.key}.review.raw.txt`), result.raw);
|
|
1266
|
-
await writeFile(join(outputDir, `${reviewer.key}.review.json`), JSON.stringify(result.value, null, 2));
|
|
1267
|
-
await input.onProgress?.({
|
|
1268
|
-
reviewer: reviewer.key,
|
|
1269
|
-
sessionId: result.sessionId,
|
|
1270
|
-
type: "reviewer_completed",
|
|
1271
|
-
verdict: result.value.verdict,
|
|
1272
|
-
});
|
|
1273
|
-
return {
|
|
1274
|
-
inlineCommentTargets: initialInlineCommentTargets,
|
|
1275
|
-
key: reviewer.key,
|
|
1276
|
-
raw: result.raw,
|
|
1277
|
-
sessionId: result.sessionId,
|
|
1278
|
-
value: result.value,
|
|
1279
|
-
};
|
|
1280
|
-
}, { signal: input.signal });
|
|
1281
|
-
throwIfAborted(input.signal);
|
|
1282
|
-
const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
|
|
1283
|
-
const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1284
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1285
|
-
if (assignment?.type !== "skip")
|
|
1286
|
-
return [];
|
|
1287
|
-
return [
|
|
1288
|
-
{
|
|
1289
|
-
reviewer: reviewer.key,
|
|
1290
|
-
verdict: reviewStateToVerdict(assignment.review.state),
|
|
1291
|
-
},
|
|
1292
|
-
];
|
|
1293
|
-
});
|
|
1294
|
-
const closeTargets = closeMinorityReviewers([
|
|
1295
|
-
...skippedVerdicts,
|
|
1296
|
-
...entries.map((entry) => ({
|
|
1297
|
-
reviewer: entry.key,
|
|
1298
|
-
verdict: entry.value.verdict,
|
|
1299
|
-
})),
|
|
1300
|
-
]);
|
|
1301
|
-
const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1302
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1303
|
-
if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
|
|
1304
|
-
return [];
|
|
1305
|
-
return [
|
|
1306
|
-
{
|
|
1307
|
-
key: reviewer.key,
|
|
1308
|
-
inlineCommentTargets: initialInlineCommentTargets,
|
|
1309
|
-
raw: assignment.review.body ?? "",
|
|
1310
|
-
sessionId: "",
|
|
1311
|
-
value: reviewOutputFromState(assignment.review),
|
|
1312
|
-
},
|
|
1313
|
-
];
|
|
1314
|
-
});
|
|
1315
|
-
entries = await runCloseReconsideration({
|
|
1316
|
-
entries: [...entries, ...skippedCloseEntries],
|
|
1317
|
-
meta,
|
|
1318
|
-
outputDir,
|
|
1319
|
-
reviewContext,
|
|
1320
|
-
reviewInput: { ...input, exec },
|
|
1321
|
-
sessionIds,
|
|
1322
|
-
targets: closeTargets,
|
|
1323
|
-
worktreePath,
|
|
1324
|
-
});
|
|
1325
|
-
const validation = await runFindingValidation({
|
|
1326
|
-
entries,
|
|
1327
|
-
meta,
|
|
1328
|
-
outputDir,
|
|
1329
|
-
reviewContext,
|
|
1330
|
-
reviewInput: { ...input, exec },
|
|
1331
|
-
sessionIds,
|
|
1332
|
-
worktreePath,
|
|
1333
|
-
});
|
|
1334
|
-
const activeOutputs = validation.outputs;
|
|
1335
|
-
const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1336
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1337
|
-
return assignment?.type === "skip"
|
|
1338
|
-
? [[reviewer.key, reviewOutputFromState(assignment.review)]]
|
|
1339
|
-
: [];
|
|
1340
|
-
}));
|
|
1341
|
-
const outputs = { ...skippedOutputs, ...activeOutputs };
|
|
1342
|
-
const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1343
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1344
|
-
if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
|
|
1345
|
-
return [];
|
|
1346
|
-
return [
|
|
1347
|
-
{
|
|
1348
|
-
reviewer: reviewer.key,
|
|
1349
|
-
verdict: reviewStateToVerdict(assignment.review.state),
|
|
1350
|
-
},
|
|
1351
|
-
];
|
|
1352
|
-
});
|
|
1353
|
-
const activeVerdicts = Object.entries(activeOutputs).map(([reviewer, output]) => ({
|
|
1354
|
-
reviewer,
|
|
1355
|
-
verdict: output.verdict,
|
|
1356
|
-
}));
|
|
1357
|
-
const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
|
|
1358
|
-
await input.onProgress?.({ phase: "posting reviews", type: "phase" });
|
|
1359
|
-
const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1360
|
-
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1361
|
-
return assignment?.type === "skip"
|
|
1362
|
-
? [[reviewer.key, "skipped: already reviewed current head"]]
|
|
1363
|
-
: [];
|
|
1364
|
-
}));
|
|
1365
|
-
const posted = singleReviewMode
|
|
1366
|
-
? {
|
|
1367
|
-
...skippedPosted,
|
|
1368
|
-
...(Object.keys(activeOutputs).length
|
|
1369
|
-
? {
|
|
1370
|
-
consensus: input.dryRun
|
|
1371
|
-
? `dry-run:would-post-single-review:${verdict}`
|
|
1372
|
-
: await (async () => {
|
|
1373
|
-
const account = input.repository.account ?? "";
|
|
1374
|
-
await Promise.all(Object.values(activeOutputs).flatMap((output) => {
|
|
1375
|
-
if (!("resolve" in output))
|
|
1376
|
-
return [];
|
|
1377
|
-
return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
|
|
1378
|
-
}));
|
|
1379
|
-
await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
|
|
1380
|
-
if (!("followUps" in output))
|
|
1381
|
-
return [];
|
|
1382
|
-
return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
|
|
1383
|
-
`**Reviewer:** ${key}`,
|
|
1384
|
-
"",
|
|
1385
|
-
item.body,
|
|
1386
|
-
"",
|
|
1387
|
-
formatReviewMarker({
|
|
1388
|
-
head: meta.headRefOid,
|
|
1389
|
-
pr: input.pr,
|
|
1390
|
-
reviewer: key,
|
|
1391
|
-
verdict: output.verdict,
|
|
1392
|
-
}),
|
|
1393
|
-
].join("\n")));
|
|
1394
|
-
}));
|
|
1395
|
-
return postSingleConsensusReview({
|
|
1396
|
-
exec,
|
|
1397
|
-
headSha: meta.headRefOid,
|
|
1398
|
-
outputs,
|
|
1399
|
-
pr: input.pr,
|
|
1400
|
-
repository: input.repository,
|
|
1401
|
-
verdict,
|
|
1402
|
-
});
|
|
1403
|
-
})(),
|
|
1404
|
-
}
|
|
1405
|
-
: {}),
|
|
1406
|
-
}
|
|
1407
|
-
: {
|
|
1408
|
-
...skippedPosted,
|
|
1409
|
-
...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
|
|
1410
|
-
key,
|
|
1411
|
-
input.dryRun
|
|
1412
|
-
? dryRunReviewPost(key, output)
|
|
1413
|
-
: "resolve" in output
|
|
1414
|
-
? await postRereviewOutput({ ...input, exec }, key, output)
|
|
1415
|
-
: await postReviewOutput({ ...input, exec }, key, output),
|
|
1416
|
-
]))),
|
|
1417
|
-
};
|
|
1418
|
-
const automationAccount = singleReviewMode
|
|
1419
|
-
? input.repository.account
|
|
1420
|
-
: input.repository.agents.reviewers[0]?.account;
|
|
1421
|
-
const enableReviewAutomation = input.enableReviewAutomation ?? true;
|
|
1422
|
-
if (enableReviewAutomation &&
|
|
1423
|
-
verdict === "MERGE" &&
|
|
1424
|
-
input.repository.reviewAutomation?.merge) {
|
|
1425
|
-
await input.onProgress?.({ phase: "merging PR", type: "phase" });
|
|
1426
|
-
posted.automation = hasBlockingCiReports(ciReports)
|
|
1427
|
-
? "skipped: unresolved CI"
|
|
1428
|
-
: input.dryRun
|
|
1429
|
-
? "dry-run:would-merge"
|
|
1430
|
-
: automationAccount
|
|
1431
|
-
? await mergePullRequest(input.exec, input.repository, input.pr, automationAccount)
|
|
1432
|
-
: "skipped: no review automation account";
|
|
1433
|
-
}
|
|
1434
|
-
if (enableReviewAutomation &&
|
|
1435
|
-
verdict === "CLOSE" &&
|
|
1436
|
-
input.repository.reviewAutomation?.close) {
|
|
1437
|
-
await input.onProgress?.({ phase: "closing PR", type: "phase" });
|
|
1438
|
-
posted.automation = input.dryRun
|
|
1439
|
-
? "dry-run:would-close"
|
|
1440
|
-
: automationAccount
|
|
1441
|
-
? await closePullRequest(input.exec, input.repository, input.pr, automationAccount)
|
|
1442
|
-
: "skipped: no review automation account";
|
|
1443
|
-
}
|
|
1444
|
-
await writeFile(join(outputDir, "majority.json"), JSON.stringify({
|
|
1445
|
-
approvalPolicy: input.approvalPolicy ?? "majority",
|
|
1446
|
-
verdict,
|
|
1447
|
-
verdicts: [...remainingSkippedVerdicts, ...activeVerdicts],
|
|
1448
|
-
}, null, 2));
|
|
1449
|
-
await writeFile(join(outputDir, "sessions.json"), JSON.stringify(sessionIds, null, 2));
|
|
1450
|
-
await writeFile(join(outputDir, "posted.json"), JSON.stringify(posted, null, 2));
|
|
1451
|
-
const report = formatReviewReport({
|
|
1452
|
-
ciReports,
|
|
1453
|
-
discardedFindings: validation.summary.discarded,
|
|
1454
|
-
dryRun: input.dryRun,
|
|
1455
|
-
outputs,
|
|
1456
|
-
posted,
|
|
1457
|
-
pr: input.pr,
|
|
1458
|
-
repository: input.repository,
|
|
1459
|
-
});
|
|
1460
|
-
await writeFile(join(outputDir, "report.md"), `${report}\n`);
|
|
1461
|
-
await input.onProgress?.({ type: "completed", verdict });
|
|
1462
|
-
return {
|
|
1463
|
-
baseSha: meta.baseRefOid,
|
|
1464
|
-
ciReports,
|
|
1465
|
-
discardedFindings: validation.summary.discarded,
|
|
1466
|
-
headSha: meta.headRefOid,
|
|
1467
|
-
outputs,
|
|
1468
|
-
posted,
|
|
1469
|
-
pr: input.pr,
|
|
1470
|
-
report,
|
|
1471
|
-
sessionIds,
|
|
1472
|
-
verdict,
|
|
1473
|
-
worktreePath,
|
|
1474
|
-
};
|
|
1475
|
-
}
|
|
1476
|
-
catch (error) {
|
|
1477
|
-
await removeWorktree(input.exec, worktreePath).catch(() => undefined);
|
|
1478
|
-
throw error;
|
|
1479
|
-
}
|
|
1480
|
-
}
|