diffcop 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -2
- package/package.json +66 -4
- package/src/agent/claude-code/claude-code-agent.ts +174 -0
- package/src/agent/explored.ts +93 -0
- package/src/agent/reviewer-agent.ts +51 -0
- package/src/cli.ts +21 -0
- package/src/commands/review.ts +220 -0
- package/src/context/git/git-repository.ts +74 -0
- package/src/context/github/pull-request-context.ts +81 -0
- package/src/context/github/pull-request-data.ts +80 -0
- package/src/context/github/pull-request-fetcher.ts +149 -0
- package/src/context/repository.ts +16 -0
- package/src/context/review-context-collector.ts +106 -0
- package/src/github/github-client.ts +23 -0
- package/src/github/unwrap-response.ts +15 -0
- package/src/prompt/github/pull-request-formatter.ts +96 -0
- package/src/prompt/github/sanitize-content.ts +108 -0
- package/src/prompt/review-prompt-builder.ts +206 -0
- package/src/reporter/console/console-reporter.ts +72 -0
- package/src/reporter/console/json-reporter.ts +37 -0
- package/src/reporter/github/github-reporter.ts +93 -0
- package/src/reporter/github/review-comment-formatter.ts +144 -0
- package/src/reporter/github/sticky-comment.ts +181 -0
- package/src/reporter/progress.ts +12 -0
- package/src/reporter/review-layout.ts +359 -0
- package/src/reporter/review-reporter.ts +19 -0
- package/src/reporter/stats.ts +41 -0
- package/src/review/change-size-limit.ts +92 -0
- package/src/review/code-reviewer.ts +120 -0
- package/src/review/findings.ts +125 -0
- package/src/util/errors.ts +7 -0
- package/src/util/json.ts +7 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { Explored, Search } from "@/agent/explored";
|
|
2
|
+
import { SEVERITIES } from "@/review/findings";
|
|
3
|
+
import type {
|
|
4
|
+
DroppedFinding,
|
|
5
|
+
Finding,
|
|
6
|
+
ReviewResult,
|
|
7
|
+
Severity,
|
|
8
|
+
} from "@/review/findings";
|
|
9
|
+
|
|
10
|
+
const SEVERITY_LABELS: Record<Severity, string> = {
|
|
11
|
+
high: "High",
|
|
12
|
+
medium: "Medium",
|
|
13
|
+
low: "Low",
|
|
14
|
+
nit: "Nit",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MAX_LISTED = 30;
|
|
18
|
+
|
|
19
|
+
export interface Headline {
|
|
20
|
+
icon: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReviewSection {
|
|
25
|
+
title: string;
|
|
26
|
+
count?: number;
|
|
27
|
+
// Markdown, rendered as is by both the console and the PR comment.
|
|
28
|
+
body: string;
|
|
29
|
+
open: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReviewLayout {
|
|
33
|
+
headline: Headline;
|
|
34
|
+
sections: ReviewSection[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lines({ file, startLine, endLine }: Finding) {
|
|
38
|
+
if (endLine <= startLine) {
|
|
39
|
+
return `${file}:${startLine}`;
|
|
40
|
+
}
|
|
41
|
+
return `${file}:${startLine}-${endLine}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LayoutOptions {
|
|
45
|
+
// GitHub renders <br> inside a table cell; the terminal renderer prints it
|
|
46
|
+
// literally and wraps long cells, so it gets a narrower table.
|
|
47
|
+
html?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cell(text: string, html: boolean) {
|
|
51
|
+
return text
|
|
52
|
+
.trim()
|
|
53
|
+
.replaceAll("|", "\\|")
|
|
54
|
+
.replaceAll(/\r?\n/g, html ? "<br>" : " ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function bySeverity(a: Finding, b: Finding) {
|
|
58
|
+
return SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function table(header: string[], rows: string[][]) {
|
|
62
|
+
return [
|
|
63
|
+
header,
|
|
64
|
+
header.map(() => {
|
|
65
|
+
return "---";
|
|
66
|
+
}),
|
|
67
|
+
...rows,
|
|
68
|
+
]
|
|
69
|
+
.map((row) => {
|
|
70
|
+
return `| ${row.join(" | ")} |`;
|
|
71
|
+
})
|
|
72
|
+
.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Row {
|
|
76
|
+
finding: Finding;
|
|
77
|
+
// The verifier's reason, for dropped findings.
|
|
78
|
+
reason?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function severityCell({ severity }: Finding) {
|
|
82
|
+
return `**${SEVERITY_LABELS[severity]}**`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function htmlTable(rows: Row[]) {
|
|
86
|
+
const withReason = rows.some(({ reason }) => {
|
|
87
|
+
return reason !== undefined;
|
|
88
|
+
});
|
|
89
|
+
const header = ["Severity", "Location", "Issue"];
|
|
90
|
+
if (withReason) {
|
|
91
|
+
header.push("Verifier");
|
|
92
|
+
}
|
|
93
|
+
return table(
|
|
94
|
+
header,
|
|
95
|
+
rows.map(({ finding, reason }) => {
|
|
96
|
+
const row = [
|
|
97
|
+
severityCell(finding),
|
|
98
|
+
cell(code(lines(finding)), true),
|
|
99
|
+
`**${cell(finding.title, true)}**<br>${cell(finding.explanation, true)}`,
|
|
100
|
+
];
|
|
101
|
+
if (reason !== undefined) {
|
|
102
|
+
row.push(cell(reason, true));
|
|
103
|
+
}
|
|
104
|
+
return row;
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A terminal table wraps long cells into tall columns, so it keeps the short
|
|
110
|
+
// fields and the explanations follow as a list keyed by row number.
|
|
111
|
+
function terminalTable(rows: Row[]) {
|
|
112
|
+
const summary = table(
|
|
113
|
+
["#", "Severity", "Location", "Title"],
|
|
114
|
+
rows.map(({ finding }, index) => {
|
|
115
|
+
return [
|
|
116
|
+
String(index + 1),
|
|
117
|
+
severityCell(finding),
|
|
118
|
+
cell(code(lines(finding)), false),
|
|
119
|
+
cell(finding.title, false),
|
|
120
|
+
];
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const details = rows.map(({ finding, reason }, index) => {
|
|
124
|
+
const paragraphs = [finding.explanation.trim()];
|
|
125
|
+
if (reason !== undefined) {
|
|
126
|
+
paragraphs.push(`Verifier: ${reason.trim()}`);
|
|
127
|
+
}
|
|
128
|
+
// Indented to the list marker so each paragraph stays in the item.
|
|
129
|
+
return `${index + 1}. ${paragraphs.join("\n\n").replaceAll(/\n(?=.)/g, "\n ")}`;
|
|
130
|
+
});
|
|
131
|
+
return `${summary}\n\n${details.join("\n")}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rowsBody(rows: Row[], html: boolean) {
|
|
135
|
+
const sorted = rows.toSorted((a, b) => {
|
|
136
|
+
return bySeverity(a.finding, b.finding);
|
|
137
|
+
});
|
|
138
|
+
if (html) {
|
|
139
|
+
return htmlTable(sorted);
|
|
140
|
+
}
|
|
141
|
+
return terminalTable(sorted);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findingsTable(findings: Finding[], html: boolean) {
|
|
145
|
+
return rowsBody(
|
|
146
|
+
findings.map((finding) => {
|
|
147
|
+
return { finding: finding };
|
|
148
|
+
}),
|
|
149
|
+
html,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function droppedTable(dropped: DroppedFinding[], html: boolean) {
|
|
154
|
+
return rowsBody(dropped, html);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Patterns can contain backticks, so fence with a longer run than the text has.
|
|
158
|
+
export function code(text: string) {
|
|
159
|
+
const runs = text.match(/`+/g) ?? [];
|
|
160
|
+
if (runs.length === 0) {
|
|
161
|
+
return `\`${text}\``;
|
|
162
|
+
}
|
|
163
|
+
const fence = "`".repeat(
|
|
164
|
+
Math.max(
|
|
165
|
+
...runs.map((run) => {
|
|
166
|
+
return run.length;
|
|
167
|
+
}),
|
|
168
|
+
) + 1,
|
|
169
|
+
);
|
|
170
|
+
return `${fence} ${text} ${fence}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function capped(items: string[]) {
|
|
174
|
+
const shown = items.slice(0, MAX_LISTED).join(", ");
|
|
175
|
+
if (items.length <= MAX_LISTED) {
|
|
176
|
+
return shown;
|
|
177
|
+
}
|
|
178
|
+
return `${shown} and ${items.length - MAX_LISTED} more`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function search({ pattern, path }: Search) {
|
|
182
|
+
if (path === undefined) {
|
|
183
|
+
return code(pattern);
|
|
184
|
+
}
|
|
185
|
+
return `${code(pattern)} in ${code(path)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function exploredBody(summary: string, { filesRead, searches }: Explored) {
|
|
189
|
+
const paragraphs: string[] = [];
|
|
190
|
+
if (summary.trim()) {
|
|
191
|
+
paragraphs.push(summary.trim());
|
|
192
|
+
}
|
|
193
|
+
if (filesRead.length > 0) {
|
|
194
|
+
paragraphs.push(
|
|
195
|
+
`Read (${filesRead.length}): ${capped(filesRead.map(code))}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (searches.length > 0) {
|
|
199
|
+
paragraphs.push(
|
|
200
|
+
`Searched (${searches.length}): ${capped(searches.map(search))}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return paragraphs.join("\n\n") || "Nothing recorded.";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function plural(count: number, noun: string) {
|
|
207
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isIssue({ severity }: Finding) {
|
|
211
|
+
return severity === "high" || severity === "medium";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
type FindingGroup = "issues" | "mismatches" | "nits" | "preexisting";
|
|
215
|
+
|
|
216
|
+
function findingGroup(finding: Finding): FindingGroup {
|
|
217
|
+
switch (finding.scope) {
|
|
218
|
+
case "description":
|
|
219
|
+
return "mismatches";
|
|
220
|
+
case "preexisting":
|
|
221
|
+
return "preexisting";
|
|
222
|
+
case "introduced":
|
|
223
|
+
if (isIssue(finding)) {
|
|
224
|
+
return "issues";
|
|
225
|
+
}
|
|
226
|
+
return "nits";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface GroupLayout {
|
|
231
|
+
group: FindingGroup;
|
|
232
|
+
title: string;
|
|
233
|
+
open: boolean;
|
|
234
|
+
// Shown above the group's table.
|
|
235
|
+
note?: string;
|
|
236
|
+
headline?: (count: number) => Headline;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// In section order, which is also the headline's priority: the first group
|
|
240
|
+
// with findings and a headline sets it. Preexisting bugs don't block the PR.
|
|
241
|
+
const GROUP_LAYOUTS: GroupLayout[] = [
|
|
242
|
+
{
|
|
243
|
+
group: "issues",
|
|
244
|
+
title: "Issues",
|
|
245
|
+
open: true,
|
|
246
|
+
headline: (count) => {
|
|
247
|
+
return {
|
|
248
|
+
icon: "🔴",
|
|
249
|
+
text: `${plural(count, "issue")} found`,
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
group: "mismatches",
|
|
255
|
+
title: "Description doesn't match the diff",
|
|
256
|
+
open: true,
|
|
257
|
+
headline: () => {
|
|
258
|
+
return {
|
|
259
|
+
icon: "🟡",
|
|
260
|
+
text: "Description doesn't match the diff",
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
group: "nits",
|
|
266
|
+
title: "Nits",
|
|
267
|
+
open: false,
|
|
268
|
+
headline: () => {
|
|
269
|
+
return {
|
|
270
|
+
icon: "🟡",
|
|
271
|
+
text: "Only nits",
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
group: "preexisting",
|
|
277
|
+
title: "Outside this PR's scope",
|
|
278
|
+
open: false,
|
|
279
|
+
note: "Already wrong before this change, so not blocking it.",
|
|
280
|
+
},
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
interface GroupedFindings {
|
|
284
|
+
layout: GroupLayout;
|
|
285
|
+
findings: Finding[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function groupSection({ layout, findings }: GroupedFindings, html: boolean) {
|
|
289
|
+
let body = findingsTable(findings, html);
|
|
290
|
+
if (layout.note) {
|
|
291
|
+
body = `${layout.note}\n\n${body}`;
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
title: layout.title,
|
|
295
|
+
count: findings.length,
|
|
296
|
+
body: body,
|
|
297
|
+
open: layout.open,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function headline(groups: GroupedFindings[]): Headline {
|
|
302
|
+
for (const { layout, findings } of groups) {
|
|
303
|
+
if (layout.headline) {
|
|
304
|
+
return layout.headline(findings.length);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return {
|
|
308
|
+
icon: "✅",
|
|
309
|
+
text: "All good",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function sectionHeading({ title, count }: ReviewSection) {
|
|
314
|
+
if (count === undefined) {
|
|
315
|
+
return title;
|
|
316
|
+
}
|
|
317
|
+
return `${title} (${count})`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function reviewLayout(
|
|
321
|
+
{
|
|
322
|
+
summary,
|
|
323
|
+
findings,
|
|
324
|
+
dropped,
|
|
325
|
+
explored,
|
|
326
|
+
}: Pick<ReviewResult, "summary" | "findings" | "dropped" | "explored">,
|
|
327
|
+
{ html = true }: LayoutOptions = {},
|
|
328
|
+
): ReviewLayout {
|
|
329
|
+
const groups = GROUP_LAYOUTS.map((layout) => {
|
|
330
|
+
return {
|
|
331
|
+
layout: layout,
|
|
332
|
+
findings: findings.filter((f) => {
|
|
333
|
+
return findingGroup(f) === layout.group;
|
|
334
|
+
}),
|
|
335
|
+
};
|
|
336
|
+
}).filter((group) => {
|
|
337
|
+
return group.findings.length > 0;
|
|
338
|
+
});
|
|
339
|
+
const sections: ReviewSection[] = groups.map((group) => {
|
|
340
|
+
return groupSection(group, html);
|
|
341
|
+
});
|
|
342
|
+
if (dropped.length > 0) {
|
|
343
|
+
sections.push({
|
|
344
|
+
title: "Dropped by verification",
|
|
345
|
+
count: dropped.length,
|
|
346
|
+
body: droppedTable(dropped, html),
|
|
347
|
+
open: false,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
sections.push({
|
|
351
|
+
title: "What we looked at",
|
|
352
|
+
body: exploredBody(summary, explored),
|
|
353
|
+
open: false,
|
|
354
|
+
});
|
|
355
|
+
return {
|
|
356
|
+
headline: headline(groups),
|
|
357
|
+
sections: sections,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ReviewResult } from "@/review/findings";
|
|
2
|
+
|
|
3
|
+
export interface TextSink {
|
|
4
|
+
write(text: string): void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// `started`, `warning`, `verifying` and `failed` must not throw: a report
|
|
8
|
+
// shouldn't stop the review or hide the error that ended it.
|
|
9
|
+
export interface ReviewReporter {
|
|
10
|
+
started(): Promise<void>;
|
|
11
|
+
// Async so the GitHub reporter can update its status comment before the
|
|
12
|
+
// review starts.
|
|
13
|
+
warning(message: string): Promise<void>;
|
|
14
|
+
toolCall(toolName: string, input: string): void;
|
|
15
|
+
thinking(durationMs: number): void;
|
|
16
|
+
verifying(findings: number): Promise<void>;
|
|
17
|
+
completed(result: ReviewResult): Promise<void>;
|
|
18
|
+
failed(error: Error, options: { cancelled: boolean }): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { RunStats } from "@/agent/reviewer-agent";
|
|
2
|
+
|
|
3
|
+
const compact = new Intl.NumberFormat("en", {
|
|
4
|
+
notation: "compact",
|
|
5
|
+
maximumFractionDigits: 1,
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const percent = new Intl.NumberFormat("en", { style: "percent" });
|
|
9
|
+
|
|
10
|
+
export function formatDuration(ms: number) {
|
|
11
|
+
const seconds = Math.round(ms / 1000);
|
|
12
|
+
if (seconds < 60) {
|
|
13
|
+
return `${seconds}s`;
|
|
14
|
+
}
|
|
15
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatInputTokens(inputTokens: number, cacheReadTokens = 0) {
|
|
19
|
+
const tokens = `${compact.format(inputTokens)} input tokens`;
|
|
20
|
+
if (inputTokens === 0 || cacheReadTokens === 0) {
|
|
21
|
+
return tokens;
|
|
22
|
+
}
|
|
23
|
+
return `${tokens} (${percent.format(cacheReadTokens / inputTokens)} cached)`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatStats(stats: RunStats) {
|
|
27
|
+
const parts = [
|
|
28
|
+
formatDuration(stats.durationMs),
|
|
29
|
+
`${stats.toolCalls} tool call${stats.toolCalls === 1 ? "" : "s"}`,
|
|
30
|
+
];
|
|
31
|
+
if (stats.inputTokens !== undefined) {
|
|
32
|
+
parts.push(formatInputTokens(stats.inputTokens, stats.cacheReadTokens));
|
|
33
|
+
}
|
|
34
|
+
if (stats.outputTokens !== undefined) {
|
|
35
|
+
parts.push(`${compact.format(stats.outputTokens)} output`);
|
|
36
|
+
}
|
|
37
|
+
if (stats.costUsd !== undefined) {
|
|
38
|
+
parts.push(`~$${stats.costUsd.toFixed(2)}`);
|
|
39
|
+
}
|
|
40
|
+
return parts.join(" · ");
|
|
41
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { ChangedFile } from "@/context/repository";
|
|
3
|
+
|
|
4
|
+
// Generated, often huge, and not worth the reviewer's attention.
|
|
5
|
+
const LOCKFILES = new Set([
|
|
6
|
+
"bun.lock",
|
|
7
|
+
"bun.lockb",
|
|
8
|
+
"package-lock.json",
|
|
9
|
+
"yarn.lock",
|
|
10
|
+
"pnpm-lock.yaml",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export interface ChangeSize {
|
|
14
|
+
lines: number;
|
|
15
|
+
files: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChangeSizeLimits {
|
|
19
|
+
maxLines: number;
|
|
20
|
+
maxFiles: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Binary files count as files but add no lines: `git diff --numstat` gives
|
|
24
|
+
// them `-` for both counts.
|
|
25
|
+
export function measureChangeSize(changedFiles: ChangedFile[]): ChangeSize {
|
|
26
|
+
const counted = changedFiles.filter((file) => {
|
|
27
|
+
return !LOCKFILES.has(basename(file.path));
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
lines: counted.reduce((sum, file) => {
|
|
31
|
+
return sum + file.additions + file.deletions;
|
|
32
|
+
}, 0),
|
|
33
|
+
files: counted.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function count(n: number, unit: string) {
|
|
38
|
+
if (n === 1) {
|
|
39
|
+
return `1 ${unit}`;
|
|
40
|
+
}
|
|
41
|
+
return `${n.toLocaleString("en-US")} ${unit}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function overLimit(actual: number, limit: number, unit: string) {
|
|
45
|
+
const text = count(actual, unit);
|
|
46
|
+
if (actual <= limit) {
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
return `${text} (over the ${limit.toLocaleString("en-US")}-${unit} limit)`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ChangeSizeLimit {
|
|
53
|
+
readonly #limits: ChangeSizeLimits;
|
|
54
|
+
|
|
55
|
+
constructor(limits: ChangeSizeLimits) {
|
|
56
|
+
this.#limits = limits;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
warning(changedFiles: ChangedFile[]): string | undefined {
|
|
60
|
+
const { maxLines, maxFiles } = this.#limits;
|
|
61
|
+
const { lines, files } = measureChangeSize(changedFiles);
|
|
62
|
+
if (lines <= maxLines && files <= maxFiles) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
`This change is large: ${overLimit(lines, maxLines, "line")} across ` +
|
|
67
|
+
`${overLimit(files, maxFiles, "file")}, not counting lockfiles. ` +
|
|
68
|
+
"Large changes get less accurate reviews; consider splitting it into stacked PRs."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Far more files than GitHub lists for the PR means the diff used another base,
|
|
74
|
+
// e.g. one changed after the run started, so splitting the PR wouldn't help.
|
|
75
|
+
export function staleBaseWarning(
|
|
76
|
+
base: string,
|
|
77
|
+
localFiles: number,
|
|
78
|
+
githubFiles: number | undefined,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
if (
|
|
81
|
+
githubFiles === undefined ||
|
|
82
|
+
localFiles <= githubFiles * 2 ||
|
|
83
|
+
localFiles - githubFiles <= 10
|
|
84
|
+
) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
`The diff against ${base} has ${count(localFiles, "file")}, but GitHub lists ` +
|
|
89
|
+
`${count(githubFiles, "file")} for this PR, so its base may have changed after ` +
|
|
90
|
+
"this run started. Re-run the review to diff against the current base."
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { mergeExplored } from "@/agent/explored";
|
|
2
|
+
import { sumStats } from "@/agent/reviewer-agent";
|
|
3
|
+
import type { ReviewerAgent } from "@/agent/reviewer-agent";
|
|
4
|
+
import type {
|
|
5
|
+
ReviewContext,
|
|
6
|
+
ReviewContextCollector,
|
|
7
|
+
} from "@/context/review-context-collector";
|
|
8
|
+
import { toError } from "@/util/errors";
|
|
9
|
+
import type { ReviewPromptBuilder } from "@/prompt/review-prompt-builder";
|
|
10
|
+
import type { ReviewReporter } from "@/reporter/review-reporter";
|
|
11
|
+
import { staleBaseWarning } from "@/review/change-size-limit";
|
|
12
|
+
import type { ChangeSizeLimit } from "@/review/change-size-limit";
|
|
13
|
+
import {
|
|
14
|
+
findingsSchema,
|
|
15
|
+
splitByVerdict,
|
|
16
|
+
verdictsSchema,
|
|
17
|
+
} from "@/review/findings";
|
|
18
|
+
import type { ReviewResult } from "@/review/findings";
|
|
19
|
+
|
|
20
|
+
export interface ReviewRequest {
|
|
21
|
+
request: string;
|
|
22
|
+
base?: string;
|
|
23
|
+
abortSignal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Modeled on claude-code-action's context → prompt → agent pipeline, with a
|
|
27
|
+
// second pass that tries to refute each finding before it's reported.
|
|
28
|
+
export class CodeReviewer {
|
|
29
|
+
readonly #collector: ReviewContextCollector;
|
|
30
|
+
readonly #promptBuilder: ReviewPromptBuilder;
|
|
31
|
+
readonly #agent: ReviewerAgent;
|
|
32
|
+
readonly #reporter: ReviewReporter;
|
|
33
|
+
readonly #sizeLimit: ChangeSizeLimit;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
collector: ReviewContextCollector,
|
|
37
|
+
promptBuilder: ReviewPromptBuilder,
|
|
38
|
+
agent: ReviewerAgent,
|
|
39
|
+
reporter: ReviewReporter,
|
|
40
|
+
sizeLimit: ChangeSizeLimit,
|
|
41
|
+
) {
|
|
42
|
+
this.#collector = collector;
|
|
43
|
+
this.#promptBuilder = promptBuilder;
|
|
44
|
+
this.#agent = agent;
|
|
45
|
+
this.#reporter = reporter;
|
|
46
|
+
this.#sizeLimit = sizeLimit;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async review({
|
|
50
|
+
request,
|
|
51
|
+
base,
|
|
52
|
+
abortSignal,
|
|
53
|
+
}: ReviewRequest): Promise<ReviewResult> {
|
|
54
|
+
await this.#reporter.started();
|
|
55
|
+
try {
|
|
56
|
+
const context = await this.#collector.collect(base);
|
|
57
|
+
const sizeWarning =
|
|
58
|
+
staleBaseWarning(
|
|
59
|
+
context.base,
|
|
60
|
+
context.changedFiles.length,
|
|
61
|
+
context.pullRequest?.data.files?.totalCount,
|
|
62
|
+
) ?? this.#sizeLimit.warning(context.changedFiles);
|
|
63
|
+
if (sizeWarning) {
|
|
64
|
+
await this.#reporter.warning(sizeWarning);
|
|
65
|
+
}
|
|
66
|
+
const result = await this.#findAndVerify(context, request, abortSignal);
|
|
67
|
+
await this.#reporter.completed(result);
|
|
68
|
+
return result;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await this.#reporter.failed(toError(error), {
|
|
71
|
+
cancelled: abortSignal?.aborted ?? false,
|
|
72
|
+
});
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async #findAndVerify(
|
|
78
|
+
context: ReviewContext,
|
|
79
|
+
request: string,
|
|
80
|
+
abortSignal: AbortSignal | undefined,
|
|
81
|
+
): Promise<ReviewResult> {
|
|
82
|
+
const found = await this.#agent.run({
|
|
83
|
+
cwd: context.repoRoot,
|
|
84
|
+
prompt: this.#promptBuilder.build(context, request),
|
|
85
|
+
output: findingsSchema,
|
|
86
|
+
reporter: this.#reporter,
|
|
87
|
+
abortSignal: abortSignal,
|
|
88
|
+
});
|
|
89
|
+
const { summary, findings } = found.output;
|
|
90
|
+
if (findings.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
summary: summary,
|
|
93
|
+
findings: findings,
|
|
94
|
+
dropped: [],
|
|
95
|
+
explored: found.explored,
|
|
96
|
+
stats: found.stats,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await this.#reporter.verifying(findings.length);
|
|
101
|
+
const verified = await this.#agent.run({
|
|
102
|
+
cwd: context.repoRoot,
|
|
103
|
+
prompt: this.#promptBuilder.buildVerification(context, findings),
|
|
104
|
+
output: verdictsSchema,
|
|
105
|
+
reporter: this.#reporter,
|
|
106
|
+
abortSignal: abortSignal,
|
|
107
|
+
});
|
|
108
|
+
const { confirmed, dropped } = splitByVerdict(
|
|
109
|
+
findings,
|
|
110
|
+
verified.output.verdicts,
|
|
111
|
+
);
|
|
112
|
+
return {
|
|
113
|
+
summary: summary,
|
|
114
|
+
findings: confirmed,
|
|
115
|
+
dropped: dropped,
|
|
116
|
+
explored: mergeExplored(found.explored, verified.explored),
|
|
117
|
+
stats: sumStats(found.stats, verified.stats),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|