opencode-magi 0.7.0 → 0.9.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.md +26 -9
- package/dist/config/resolve.js +36 -1
- package/dist/config/validate.js +137 -15
- package/dist/github/commands.js +48 -6
- package/dist/orchestrator/merge.js +367 -23
- package/dist/orchestrator/report.js +1 -1
- package/dist/orchestrator/review.js +485 -46
- package/dist/orchestrator/triage.js +249 -64
- package/dist/prompts/compose.js +59 -2
- package/dist/prompts/contracts.js +20 -1
- package/dist/prompts/output.js +19 -1
- package/dist/prompts/templates/merge/conflict.md +10 -0
- package/dist/prompts/templates/review/rereview.md +2 -0
- package/dist/prompts/templates/review/review.md +2 -0
- package/dist/prompts/templates/triage/acceptance.md +1 -1
- package/dist/prompts/templates/triage/signal.md +10 -0
- package/package.json +8 -8
- package/schema.json +60 -5
|
@@ -15,6 +15,85 @@ import { mapPool } from "./pool";
|
|
|
15
15
|
import { formatReviewReport } from "./report";
|
|
16
16
|
import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
|
|
17
17
|
import { checkSafetyGate, hasSafetyGate } from "./safety";
|
|
18
|
+
function resolvedReviewMode(repository) {
|
|
19
|
+
return repository.review?.mode === "multi" ? "multi" : "single";
|
|
20
|
+
}
|
|
21
|
+
export function reviewPostingAccount(repository, reviewer) {
|
|
22
|
+
return resolvedReviewMode(repository) === "single"
|
|
23
|
+
? (repository.review?.account ?? reviewer.account)
|
|
24
|
+
: reviewer.account;
|
|
25
|
+
}
|
|
26
|
+
function reviewAssignmentKey(repository, reviewer) {
|
|
27
|
+
return resolvedReviewMode(repository) === "single"
|
|
28
|
+
? reviewer.key
|
|
29
|
+
: reviewer.account;
|
|
30
|
+
}
|
|
31
|
+
function parseMarkerFields(text) {
|
|
32
|
+
const fields = Object.fromEntries(text
|
|
33
|
+
.trim()
|
|
34
|
+
.split(/\s+/)
|
|
35
|
+
.flatMap((part) => {
|
|
36
|
+
const index = part.indexOf("=");
|
|
37
|
+
return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
|
|
38
|
+
}));
|
|
39
|
+
return fields.v === "1" && fields.mode === "single" ? fields : undefined;
|
|
40
|
+
}
|
|
41
|
+
function isMarkerVerdict(value) {
|
|
42
|
+
return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
|
|
43
|
+
}
|
|
44
|
+
export function formatReviewMarker(marker) {
|
|
45
|
+
return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
|
|
46
|
+
}
|
|
47
|
+
export function parseReviewMarkers(body) {
|
|
48
|
+
const markers = [];
|
|
49
|
+
const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
|
|
50
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
51
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
52
|
+
const pr = Number(fields?.pr);
|
|
53
|
+
if (!fields ||
|
|
54
|
+
!Number.isInteger(pr) ||
|
|
55
|
+
!fields.reviewer ||
|
|
56
|
+
!fields.head ||
|
|
57
|
+
!isMarkerVerdict(fields.verdict)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
markers.push({
|
|
61
|
+
head: fields.head,
|
|
62
|
+
pr,
|
|
63
|
+
reviewer: fields.reviewer,
|
|
64
|
+
verdict: fields.verdict,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return markers;
|
|
68
|
+
}
|
|
69
|
+
export function formatReviewFindingMarker(marker) {
|
|
70
|
+
return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
|
|
71
|
+
}
|
|
72
|
+
export function parseReviewFindingMarkers(body) {
|
|
73
|
+
const markers = [];
|
|
74
|
+
const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
|
|
75
|
+
for (const match of body?.matchAll(regex) ?? []) {
|
|
76
|
+
const fields = parseMarkerFields(match[1] ?? "");
|
|
77
|
+
const pr = Number(fields?.pr);
|
|
78
|
+
const finding = Number(fields?.finding);
|
|
79
|
+
if (!fields ||
|
|
80
|
+
!Number.isInteger(pr) ||
|
|
81
|
+
!Number.isInteger(finding) ||
|
|
82
|
+
!fields.reviewer ||
|
|
83
|
+
!fields.head) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
|
|
87
|
+
}
|
|
88
|
+
return markers;
|
|
89
|
+
}
|
|
90
|
+
function markerReviewState(verdict) {
|
|
91
|
+
if (verdict === "MERGE")
|
|
92
|
+
return "APPROVED";
|
|
93
|
+
if (verdict === "CHANGES_REQUESTED")
|
|
94
|
+
return "CHANGES_REQUESTED";
|
|
95
|
+
return "CLOSE";
|
|
96
|
+
}
|
|
18
97
|
function errorMessage(error) {
|
|
19
98
|
return error instanceof Error ? error.message : String(error);
|
|
20
99
|
}
|
|
@@ -35,11 +114,12 @@ async function postReviewOutput(input, reviewerKey, output) {
|
|
|
35
114
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
36
115
|
if (!reviewer)
|
|
37
116
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
117
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
38
118
|
if (output.verdict === "MERGE")
|
|
39
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
119
|
+
return postApproval(input.exec, input.repository, input.pr, account);
|
|
40
120
|
if (output.verdict === "CLOSE")
|
|
41
|
-
return postCloseComment(input.exec, input.repository, input.pr,
|
|
42
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
121
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
122
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, output.findings);
|
|
43
123
|
}
|
|
44
124
|
function dryRunReviewPost(key, output) {
|
|
45
125
|
if (output.verdict === "MERGE")
|
|
@@ -86,6 +166,56 @@ export function resolveReviewMode(reviews, accounts, current, accountsWithPendin
|
|
|
86
166
|
return { assignments, type: "already_reviewed" };
|
|
87
167
|
return { assignments, type: "active" };
|
|
88
168
|
}
|
|
169
|
+
export function resolveSingleAccountReviewMode(input) {
|
|
170
|
+
const reviewerKeySet = new Set(input.reviewerKeys);
|
|
171
|
+
const pendingReviewers = input.pendingReviewers ?? new Set();
|
|
172
|
+
const latest = new Map();
|
|
173
|
+
for (const review of input.reviews) {
|
|
174
|
+
if (review.author.login !== input.account)
|
|
175
|
+
continue;
|
|
176
|
+
if (review.state === "DISMISSED")
|
|
177
|
+
continue;
|
|
178
|
+
for (const marker of parseReviewMarkers(review.body)) {
|
|
179
|
+
if (marker.pr !== input.pr || !reviewerKeySet.has(marker.reviewer)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const synthetic = {
|
|
183
|
+
...review,
|
|
184
|
+
commit: { oid: marker.head },
|
|
185
|
+
comments: (review.comments ?? []).filter((comment) => parseReviewFindingMarkers(comment.body).some((findingMarker) => findingMarker.pr === input.pr &&
|
|
186
|
+
findingMarker.reviewer === marker.reviewer &&
|
|
187
|
+
findingMarker.head === marker.head)),
|
|
188
|
+
state: markerReviewState(marker.verdict),
|
|
189
|
+
};
|
|
190
|
+
const current = latest.get(marker.reviewer);
|
|
191
|
+
if (!current ||
|
|
192
|
+
current.submittedAt.localeCompare(review.submittedAt) < 0) {
|
|
193
|
+
latest.set(marker.reviewer, synthetic);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const reviewedHead = input.reviewerKeys.every((reviewer) => {
|
|
198
|
+
return (isReviewCurrent(latest.get(reviewer), input.current) &&
|
|
199
|
+
!pendingReviewers.has(reviewer));
|
|
200
|
+
});
|
|
201
|
+
const assignments = new Map();
|
|
202
|
+
for (const reviewer of input.reviewerKeys) {
|
|
203
|
+
const review = latest.get(reviewer);
|
|
204
|
+
if (!review) {
|
|
205
|
+
assignments.set(reviewer, { type: "initial" });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (isReviewCurrent(review, input.current) &&
|
|
209
|
+
!pendingReviewers.has(reviewer)) {
|
|
210
|
+
assignments.set(reviewer, { review, type: "skip" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
assignments.set(reviewer, { review, type: "rereview" });
|
|
214
|
+
}
|
|
215
|
+
if (latest.size && reviewedHead)
|
|
216
|
+
return { assignments, type: "already_reviewed" };
|
|
217
|
+
return { assignments, type: "active" };
|
|
218
|
+
}
|
|
89
219
|
export function reviewFreshnessTarget(commits, headSha) {
|
|
90
220
|
const latestNonMerge = [...commits]
|
|
91
221
|
.reverse()
|
|
@@ -112,6 +242,8 @@ function reviewStateToVerdict(state) {
|
|
|
112
242
|
return "MERGE";
|
|
113
243
|
if (state === "CHANGES_REQUESTED")
|
|
114
244
|
return "CHANGES_REQUESTED";
|
|
245
|
+
if (state === "CLOSE")
|
|
246
|
+
return "CLOSE";
|
|
115
247
|
throw new Error(`Unsupported GitHub review state: ${state}`);
|
|
116
248
|
}
|
|
117
249
|
function hasBlockingCiReports(reports) {
|
|
@@ -163,6 +295,90 @@ export async function inlineCommentTargetsForDiff(input) {
|
|
|
163
295
|
cwd: input.worktreePath,
|
|
164
296
|
}));
|
|
165
297
|
}
|
|
298
|
+
function firstTargetLine(targets, path) {
|
|
299
|
+
const lines = targets.get(path);
|
|
300
|
+
if (!lines?.size)
|
|
301
|
+
return undefined;
|
|
302
|
+
return [...lines].sort((a, b) => a - b)[0];
|
|
303
|
+
}
|
|
304
|
+
function mergeInlineCommentTargets(left, right) {
|
|
305
|
+
const merged = new Map();
|
|
306
|
+
for (const [path, lines] of [...left, ...right]) {
|
|
307
|
+
const targetLines = merged.get(path) ?? new Set();
|
|
308
|
+
for (const line of lines)
|
|
309
|
+
targetLines.add(line);
|
|
310
|
+
merged.set(path, targetLines);
|
|
311
|
+
}
|
|
312
|
+
return merged;
|
|
313
|
+
}
|
|
314
|
+
function targetLineSummary(targets, path) {
|
|
315
|
+
const lines = targets.get(path);
|
|
316
|
+
if (!lines?.size)
|
|
317
|
+
return "(none)";
|
|
318
|
+
const sorted = [...lines].sort((a, b) => a - b);
|
|
319
|
+
const shown = sorted.slice(0, 12).join(", ");
|
|
320
|
+
return sorted.length > 12 ? `${shown}, ...` : shown;
|
|
321
|
+
}
|
|
322
|
+
function indentedExcerpt(lines) {
|
|
323
|
+
return lines
|
|
324
|
+
.slice(0, 24)
|
|
325
|
+
.map((line) => ` ${line}`)
|
|
326
|
+
.join("\n");
|
|
327
|
+
}
|
|
328
|
+
function parseMergeConflictSections(output) {
|
|
329
|
+
const conflictHeaders = new Set([
|
|
330
|
+
"added in both",
|
|
331
|
+
"changed in both",
|
|
332
|
+
"removed in local",
|
|
333
|
+
"removed in remote",
|
|
334
|
+
]);
|
|
335
|
+
const sections = [];
|
|
336
|
+
let current;
|
|
337
|
+
for (const line of output.split("\n")) {
|
|
338
|
+
if (!line.trim())
|
|
339
|
+
continue;
|
|
340
|
+
if (!line.startsWith(" ") &&
|
|
341
|
+
!line.startsWith("+") &&
|
|
342
|
+
!line.startsWith("-") &&
|
|
343
|
+
!line.startsWith("@")) {
|
|
344
|
+
current = conflictHeaders.has(line)
|
|
345
|
+
? { lines: [line], paths: new Set() }
|
|
346
|
+
: undefined;
|
|
347
|
+
if (current)
|
|
348
|
+
sections.push(current);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!current)
|
|
352
|
+
continue;
|
|
353
|
+
current.lines.push(line);
|
|
354
|
+
const path = /^ (?:base|our|their)\s+\d+\s+[0-9a-f]+\s+(.+)$/.exec(line)?.[1];
|
|
355
|
+
if (path)
|
|
356
|
+
current.paths.add(path);
|
|
357
|
+
}
|
|
358
|
+
return sections.flatMap((section) => [...section.paths].map((path) => ({
|
|
359
|
+
excerpt: indentedExcerpt(section.lines),
|
|
360
|
+
path,
|
|
361
|
+
})));
|
|
362
|
+
}
|
|
363
|
+
export async function mergeConflictContextForDiff(input) {
|
|
364
|
+
const mergeBase = (await input.exec(`git merge-base ${shellQuote(input.baseSha)} ${shellQuote(input.headSha)}`, { cwd: input.worktreePath })).trim();
|
|
365
|
+
const output = await input.exec(`git merge-tree ${shellQuote(mergeBase)} ${shellQuote(input.headSha)} ${shellQuote(input.baseSha)}`, { cwd: input.worktreePath });
|
|
366
|
+
const conflicts = parseMergeConflictSections(output);
|
|
367
|
+
if (!conflicts.length)
|
|
368
|
+
return "";
|
|
369
|
+
return [
|
|
370
|
+
"The PR currently has unresolved merge conflicts with the base branch.",
|
|
371
|
+
"Treat unresolved conflicts as review findings and request changes when they make the PR unsafe or impossible to merge.",
|
|
372
|
+
"Use suggestedLine when it is present; it is a valid right-side PR diff line for an inline finding.",
|
|
373
|
+
...conflicts.map((conflict) => {
|
|
374
|
+
const suggestedLine = firstTargetLine(input.inlineCommentTargets, conflict.path);
|
|
375
|
+
const suggestedLineText = suggestedLine
|
|
376
|
+
? `suggestedLine: ${suggestedLine}`
|
|
377
|
+
: "suggestedLine: (no right-side PR diff line found)";
|
|
378
|
+
return `<conflict_file>\npath: ${conflict.path}\n${suggestedLineText}\nrightSideDiffLines: ${targetLineSummary(input.inlineCommentTargets, conflict.path)}\nmergeTreeExcerpt:\n${conflict.excerpt}\n</conflict_file>`;
|
|
379
|
+
}),
|
|
380
|
+
].join("\n");
|
|
381
|
+
}
|
|
166
382
|
function parsePostedFindingLocation(location) {
|
|
167
383
|
const range = /^(.*):(\d+)-(\d+)$/.exec(location);
|
|
168
384
|
if (range) {
|
|
@@ -211,7 +427,10 @@ function reviewFindingsFromBody(body) {
|
|
|
211
427
|
return { findings };
|
|
212
428
|
}
|
|
213
429
|
function parsePostedFindingComment(body) {
|
|
214
|
-
const
|
|
430
|
+
const visibleBody = body
|
|
431
|
+
.replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
|
|
432
|
+
.trim();
|
|
433
|
+
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);
|
|
215
434
|
if (!match)
|
|
216
435
|
return undefined;
|
|
217
436
|
return {
|
|
@@ -266,19 +485,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
|
|
|
266
485
|
comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
|
|
267
486
|
});
|
|
268
487
|
}
|
|
488
|
+
export function assignThreadsByReviewFindingMarker(input) {
|
|
489
|
+
const reviewerKeys = new Set(input.reviewerKeys);
|
|
490
|
+
const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
|
|
491
|
+
for (const thread of input.threads) {
|
|
492
|
+
const markers = [
|
|
493
|
+
thread.body,
|
|
494
|
+
thread.latestBody,
|
|
495
|
+
...thread.comments.map((comment) => comment.body),
|
|
496
|
+
]
|
|
497
|
+
.flatMap(parseReviewFindingMarkers)
|
|
498
|
+
.filter((marker) => {
|
|
499
|
+
return (marker.pr === input.pr &&
|
|
500
|
+
reviewerKeys.has(marker.reviewer) &&
|
|
501
|
+
(!input.headSha || marker.head === input.headSha));
|
|
502
|
+
});
|
|
503
|
+
const reviewers = markers.length
|
|
504
|
+
? [...new Set(markers.map((marker) => marker.reviewer))]
|
|
505
|
+
: input.fallbackReviewerKeys;
|
|
506
|
+
for (const reviewer of reviewers)
|
|
507
|
+
assigned[reviewer]?.push(thread);
|
|
508
|
+
}
|
|
509
|
+
return assigned;
|
|
510
|
+
}
|
|
511
|
+
function outputFindings(reviewer, output) {
|
|
512
|
+
if (output.verdict !== "CHANGES_REQUESTED")
|
|
513
|
+
return [];
|
|
514
|
+
if ("findings" in output) {
|
|
515
|
+
return output.findings.map((finding, index) => ({
|
|
516
|
+
finding,
|
|
517
|
+
index,
|
|
518
|
+
reviewer,
|
|
519
|
+
}));
|
|
520
|
+
}
|
|
521
|
+
return output.newFindings.map((finding, index) => ({
|
|
522
|
+
finding: {
|
|
523
|
+
fix: "Please address this before merging.",
|
|
524
|
+
issue: finding.body,
|
|
525
|
+
line: finding.line,
|
|
526
|
+
path: finding.path,
|
|
527
|
+
startLine: finding.startLine,
|
|
528
|
+
},
|
|
529
|
+
index,
|
|
530
|
+
reviewer,
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
function singleReviewBody(input) {
|
|
534
|
+
const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
|
|
535
|
+
const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
|
|
536
|
+
? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
|
|
537
|
+
: []);
|
|
538
|
+
const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
|
|
539
|
+
const line = finding.startLine == null || finding.startLine === finding.line
|
|
540
|
+
? String(finding.line)
|
|
541
|
+
: `${finding.startLine}-${finding.line}`;
|
|
542
|
+
return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
|
|
543
|
+
}));
|
|
544
|
+
const lines = [
|
|
545
|
+
`Magi single-account review result: ${input.verdict}.`,
|
|
546
|
+
"",
|
|
547
|
+
"Logical reviewer verdicts:",
|
|
548
|
+
...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
|
|
549
|
+
...(input.verdict === "CLOSE" && closeReasons.length
|
|
550
|
+
? ["", "Close reasons:", ...closeReasons]
|
|
551
|
+
: []),
|
|
552
|
+
...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
|
|
553
|
+
? ["", "Accepted change requests:", ...acceptedFindings]
|
|
554
|
+
: []),
|
|
555
|
+
"",
|
|
556
|
+
...outputs.map(([reviewer, output]) => formatReviewMarker({
|
|
557
|
+
head: input.headSha,
|
|
558
|
+
pr: input.pr,
|
|
559
|
+
reviewer,
|
|
560
|
+
verdict: output.verdict,
|
|
561
|
+
})),
|
|
562
|
+
];
|
|
563
|
+
return lines.join("\n");
|
|
564
|
+
}
|
|
565
|
+
function singleFindingBody(input) {
|
|
566
|
+
return [
|
|
567
|
+
`**Issue:** ${input.finding.issue}`,
|
|
568
|
+
"",
|
|
569
|
+
`**Fix:** ${input.finding.fix}`,
|
|
570
|
+
"",
|
|
571
|
+
`**Reviewer:** ${input.reviewer}`,
|
|
572
|
+
"",
|
|
573
|
+
formatReviewFindingMarker({
|
|
574
|
+
finding: input.index,
|
|
575
|
+
head: input.headSha,
|
|
576
|
+
pr: input.pr,
|
|
577
|
+
reviewer: input.reviewer,
|
|
578
|
+
}),
|
|
579
|
+
].join("\n");
|
|
580
|
+
}
|
|
581
|
+
export async function postSingleConsensusReview(input) {
|
|
582
|
+
const account = input.repository.review?.account;
|
|
583
|
+
if (!account)
|
|
584
|
+
throw new Error("review.account is required for single review mode");
|
|
585
|
+
const body = singleReviewBody(input);
|
|
586
|
+
if (input.verdict === "MERGE") {
|
|
587
|
+
return postApproval(input.exec, input.repository, input.pr, account, body);
|
|
588
|
+
}
|
|
589
|
+
if (input.verdict === "CLOSE") {
|
|
590
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, body);
|
|
591
|
+
}
|
|
592
|
+
const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
|
|
593
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
|
|
594
|
+
body,
|
|
595
|
+
commentBodies: findings.map((item) => singleFindingBody({
|
|
596
|
+
finding: item.finding,
|
|
597
|
+
headSha: input.headSha,
|
|
598
|
+
index: item.index,
|
|
599
|
+
pr: input.pr,
|
|
600
|
+
reviewer: item.reviewer,
|
|
601
|
+
})),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
269
604
|
async function postRereviewOutput(input, reviewerKey, output) {
|
|
270
605
|
const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
|
|
271
606
|
if (!reviewer)
|
|
272
607
|
throw new Error(`Unknown reviewer: ${reviewerKey}`);
|
|
273
|
-
|
|
274
|
-
await Promise.all(output.
|
|
608
|
+
const account = reviewPostingAccount(input.repository, reviewer);
|
|
609
|
+
await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
|
|
610
|
+
await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
|
|
275
611
|
if (output.verdict === "MERGE")
|
|
276
|
-
return postApproval(input.exec, input.repository, input.pr,
|
|
612
|
+
return postApproval(input.exec, input.repository, input.pr, account);
|
|
277
613
|
if (output.verdict === "CLOSE")
|
|
278
|
-
return postCloseComment(input.exec, input.repository, input.pr,
|
|
614
|
+
return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
|
|
279
615
|
if (!output.newFindings.length)
|
|
280
616
|
return "";
|
|
281
|
-
return postChangesRequested(input.exec, input.repository, input.pr,
|
|
617
|
+
return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
|
|
282
618
|
fix: "Please address this before merging.",
|
|
283
619
|
issue: finding.body,
|
|
284
620
|
path: finding.path,
|
|
@@ -586,23 +922,68 @@ export async function runReview(input) {
|
|
|
586
922
|
const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
|
|
587
923
|
const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
|
|
588
924
|
const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
|
|
925
|
+
const singleReviewMode = resolvedReviewMode(input.repository) === "single";
|
|
926
|
+
const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
|
|
589
927
|
const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
|
|
590
|
-
const preliminaryMode =
|
|
928
|
+
const preliminaryMode = singleReviewMode
|
|
929
|
+
? resolveSingleAccountReviewMode({
|
|
930
|
+
account: input.repository.review?.account ?? "",
|
|
931
|
+
current: freshnessTarget,
|
|
932
|
+
pr: input.pr,
|
|
933
|
+
reviewerKeys,
|
|
934
|
+
reviews,
|
|
935
|
+
})
|
|
936
|
+
: resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
|
|
591
937
|
const unresolvedThreadsByAccount = new Map();
|
|
938
|
+
const unresolvedThreadsByReviewer = new Map();
|
|
592
939
|
const pendingThreadReplyAccounts = new Set();
|
|
940
|
+
const pendingThreadReplyReviewers = new Set();
|
|
593
941
|
const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
|
|
594
|
-
return preliminaryMode.assignments.get(reviewer
|
|
942
|
+
return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
|
|
595
943
|
});
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
944
|
+
if (singleReviewMode) {
|
|
945
|
+
const account = input.repository.review?.account ?? "";
|
|
946
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
|
|
947
|
+
const assigned = assignThreadsByReviewFindingMarker({
|
|
948
|
+
fallbackReviewerKeys: reviewerKeys,
|
|
949
|
+
pr: input.pr,
|
|
950
|
+
reviewerKeys,
|
|
951
|
+
threads,
|
|
952
|
+
});
|
|
953
|
+
for (const reviewer of input.repository.agents.reviewers) {
|
|
954
|
+
const reviewerThreads = assigned[reviewer.key] ?? [];
|
|
955
|
+
unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
|
|
956
|
+
if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (hasPendingThreadReply(reviewerThreads, account)) {
|
|
960
|
+
pendingThreadReplyReviewers.add(reviewer.key);
|
|
961
|
+
}
|
|
601
962
|
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
|
|
966
|
+
const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
|
|
967
|
+
unresolvedThreadsByAccount.set(reviewer.account, threads);
|
|
968
|
+
if (hasPendingThreadReply(threads, reviewer.account)) {
|
|
969
|
+
pendingThreadReplyAccounts.add(reviewer.account);
|
|
970
|
+
}
|
|
971
|
+
}, { signal: input.signal });
|
|
972
|
+
}
|
|
973
|
+
const mode = singleReviewMode
|
|
974
|
+
? pendingThreadReplyReviewers.size
|
|
975
|
+
? resolveSingleAccountReviewMode({
|
|
976
|
+
account: input.repository.review?.account ?? "",
|
|
977
|
+
current: freshnessTarget,
|
|
978
|
+
pendingReviewers: pendingThreadReplyReviewers,
|
|
979
|
+
pr: input.pr,
|
|
980
|
+
reviewerKeys,
|
|
981
|
+
reviews,
|
|
982
|
+
})
|
|
983
|
+
: preliminaryMode
|
|
984
|
+
: pendingThreadReplyAccounts.size
|
|
985
|
+
? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
|
|
986
|
+
: preliminaryMode;
|
|
606
987
|
if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
|
|
607
988
|
throw new Error("PR has already been reviewed by all configured accounts");
|
|
608
989
|
const runId = input.runId ?? `run-${Date.now().toString(36)}`;
|
|
@@ -692,7 +1073,7 @@ export async function runReview(input) {
|
|
|
692
1073
|
try {
|
|
693
1074
|
throwIfAborted(input.signal);
|
|
694
1075
|
const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
695
|
-
const assignment = mode.assignments.get(reviewer
|
|
1076
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
696
1077
|
if (!assignment || assignment.type === "skip")
|
|
697
1078
|
return [];
|
|
698
1079
|
return [{ assignment, reviewer }];
|
|
@@ -709,8 +1090,15 @@ export async function runReview(input) {
|
|
|
709
1090
|
toSha: meta.headRefOid,
|
|
710
1091
|
worktreePath,
|
|
711
1092
|
});
|
|
1093
|
+
const mergeConflictContext = await mergeConflictContextForDiff({
|
|
1094
|
+
baseSha: meta.baseRefOid,
|
|
1095
|
+
exec,
|
|
1096
|
+
headSha: meta.headRefOid,
|
|
1097
|
+
inlineCommentTargets: initialInlineCommentTargets,
|
|
1098
|
+
worktreePath,
|
|
1099
|
+
});
|
|
712
1100
|
for (const reviewer of input.repository.agents.reviewers) {
|
|
713
|
-
const assignment = mode.assignments.get(reviewer
|
|
1101
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
714
1102
|
if (assignment?.type !== "skip")
|
|
715
1103
|
continue;
|
|
716
1104
|
await input.onProgress?.({
|
|
@@ -740,13 +1128,18 @@ export async function runReview(input) {
|
|
|
740
1128
|
toSha: meta.headRefOid,
|
|
741
1129
|
worktreePath,
|
|
742
1130
|
});
|
|
743
|
-
const
|
|
744
|
-
|
|
1131
|
+
const rereviewInlineCommentTargets = mergeConflictContext
|
|
1132
|
+
? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
|
|
1133
|
+
: inlineCommentTargets;
|
|
1134
|
+
const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
|
|
1135
|
+
unresolvedThreadsByAccount.get(reviewer.account) ??
|
|
1136
|
+
(await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
|
|
745
1137
|
const prompt = await composeRereviewPrompt({
|
|
746
1138
|
baseSha: meta.baseRefOid,
|
|
747
1139
|
ciFailureContext,
|
|
748
1140
|
directory: input.directory,
|
|
749
1141
|
headSha: meta.headRefOid,
|
|
1142
|
+
mergeConflictContext,
|
|
750
1143
|
pr: input.pr,
|
|
751
1144
|
previousReview: previousReviewText(previous),
|
|
752
1145
|
previousHeadSha: previous.commit.oid,
|
|
@@ -787,7 +1180,7 @@ export async function runReview(input) {
|
|
|
787
1180
|
},
|
|
788
1181
|
options: reviewer.options,
|
|
789
1182
|
parentSessionId: input.parentSessionId,
|
|
790
|
-
parse: (text) => parseRereviewOutputWithInlineTargets(text,
|
|
1183
|
+
parse: (text) => parseRereviewOutputWithInlineTargets(text, rereviewInlineCommentTargets),
|
|
791
1184
|
permission: reviewer.permission,
|
|
792
1185
|
prompt,
|
|
793
1186
|
repairAttempts: input.config.output?.repairAttempts ?? 3,
|
|
@@ -819,6 +1212,7 @@ export async function runReview(input) {
|
|
|
819
1212
|
ciFailureContext,
|
|
820
1213
|
directory: input.directory,
|
|
821
1214
|
headSha: meta.headRefOid,
|
|
1215
|
+
mergeConflictContext,
|
|
822
1216
|
pr: input.pr,
|
|
823
1217
|
repository: input.repository,
|
|
824
1218
|
reviewContext,
|
|
@@ -885,7 +1279,7 @@ export async function runReview(input) {
|
|
|
885
1279
|
throwIfAborted(input.signal);
|
|
886
1280
|
const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
|
|
887
1281
|
const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
888
|
-
const assignment = mode.assignments.get(reviewer
|
|
1282
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
889
1283
|
if (assignment?.type !== "skip")
|
|
890
1284
|
return [];
|
|
891
1285
|
return [
|
|
@@ -903,7 +1297,7 @@ export async function runReview(input) {
|
|
|
903
1297
|
})),
|
|
904
1298
|
]);
|
|
905
1299
|
const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
906
|
-
const assignment = mode.assignments.get(reviewer
|
|
1300
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
907
1301
|
if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
|
|
908
1302
|
return [];
|
|
909
1303
|
return [
|
|
@@ -937,14 +1331,14 @@ export async function runReview(input) {
|
|
|
937
1331
|
});
|
|
938
1332
|
const activeOutputs = validation.outputs;
|
|
939
1333
|
const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
940
|
-
const assignment = mode.assignments.get(reviewer
|
|
1334
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
941
1335
|
return assignment?.type === "skip"
|
|
942
1336
|
? [[reviewer.key, reviewOutputFromState(assignment.review)]]
|
|
943
1337
|
: [];
|
|
944
1338
|
}));
|
|
945
1339
|
const outputs = { ...skippedOutputs, ...activeOutputs };
|
|
946
1340
|
const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
947
|
-
const assignment = mode.assignments.get(reviewer
|
|
1341
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
948
1342
|
if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
|
|
949
1343
|
return [];
|
|
950
1344
|
return [
|
|
@@ -960,23 +1354,68 @@ export async function runReview(input) {
|
|
|
960
1354
|
}));
|
|
961
1355
|
const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
|
|
962
1356
|
await input.onProgress?.({ phase: "posting reviews", type: "phase" });
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
?
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1357
|
+
const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
|
|
1358
|
+
const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
|
|
1359
|
+
return assignment?.type === "skip"
|
|
1360
|
+
? [[reviewer.key, "skipped: already reviewed current head"]]
|
|
1361
|
+
: [];
|
|
1362
|
+
}));
|
|
1363
|
+
const posted = singleReviewMode
|
|
1364
|
+
? {
|
|
1365
|
+
...skippedPosted,
|
|
1366
|
+
...(Object.keys(activeOutputs).length
|
|
1367
|
+
? {
|
|
1368
|
+
consensus: input.dryRun
|
|
1369
|
+
? `dry-run:would-post-single-review:${verdict}`
|
|
1370
|
+
: await (async () => {
|
|
1371
|
+
const account = input.repository.review?.account ?? "";
|
|
1372
|
+
await Promise.all(Object.values(activeOutputs).flatMap((output) => {
|
|
1373
|
+
if (!("resolve" in output))
|
|
1374
|
+
return [];
|
|
1375
|
+
return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
|
|
1376
|
+
}));
|
|
1377
|
+
await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
|
|
1378
|
+
if (!("followUps" in output))
|
|
1379
|
+
return [];
|
|
1380
|
+
return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
|
|
1381
|
+
`**Reviewer:** ${key}`,
|
|
1382
|
+
"",
|
|
1383
|
+
item.body,
|
|
1384
|
+
"",
|
|
1385
|
+
formatReviewMarker({
|
|
1386
|
+
head: meta.headRefOid,
|
|
1387
|
+
pr: input.pr,
|
|
1388
|
+
reviewer: key,
|
|
1389
|
+
verdict: output.verdict,
|
|
1390
|
+
}),
|
|
1391
|
+
].join("\n")));
|
|
1392
|
+
}));
|
|
1393
|
+
return postSingleConsensusReview({
|
|
1394
|
+
exec,
|
|
1395
|
+
headSha: meta.headRefOid,
|
|
1396
|
+
outputs,
|
|
1397
|
+
pr: input.pr,
|
|
1398
|
+
repository: input.repository,
|
|
1399
|
+
verdict,
|
|
1400
|
+
});
|
|
1401
|
+
})(),
|
|
1402
|
+
}
|
|
1403
|
+
: {}),
|
|
1404
|
+
}
|
|
1405
|
+
: {
|
|
1406
|
+
...skippedPosted,
|
|
1407
|
+
...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
|
|
1408
|
+
key,
|
|
1409
|
+
input.dryRun
|
|
1410
|
+
? dryRunReviewPost(key, output)
|
|
1411
|
+
: "resolve" in output
|
|
1412
|
+
? await postRereviewOutput({ ...input, exec }, key, output)
|
|
1413
|
+
: await postReviewOutput({ ...input, exec }, key, output),
|
|
1414
|
+
]))),
|
|
1415
|
+
};
|
|
1416
|
+
const automationAccount = singleReviewMode
|
|
1417
|
+
? input.repository.review?.account
|
|
1418
|
+
: input.repository.agents.reviewers[0]?.account;
|
|
980
1419
|
const enableReviewAutomation = input.enableReviewAutomation ?? true;
|
|
981
1420
|
if (enableReviewAutomation &&
|
|
982
1421
|
verdict === "MERGE" &&
|