diff-hound 1.0.2 → 1.2.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 +105 -25
- package/dist/cli/index.js +14 -3
- package/dist/config/index.js +3 -3
- package/dist/config/index.test.d.ts +1 -0
- package/dist/config/index.test.js +330 -0
- package/dist/core/parseUnifiedDiff.test.d.ts +1 -0
- package/dist/core/parseUnifiedDiff.test.js +310 -0
- package/dist/index.js +17 -9
- package/dist/models/base.d.ts +74 -0
- package/dist/models/base.js +236 -0
- package/dist/models/base.test.d.ts +1 -0
- package/dist/models/base.test.js +241 -0
- package/dist/models/index.d.ts +6 -2
- package/dist/models/index.js +9 -2
- package/dist/models/ollama.d.ts +28 -0
- package/dist/models/ollama.js +88 -0
- package/dist/models/ollama.test.d.ts +1 -0
- package/dist/models/ollama.test.js +235 -0
- package/dist/models/openai.d.ts +14 -17
- package/dist/models/openai.js +41 -125
- package/dist/models/openai.test.d.ts +1 -0
- package/dist/models/openai.test.js +209 -0
- package/dist/platforms/index.d.ts +3 -2
- package/dist/platforms/index.js +8 -1
- package/dist/platforms/local.d.ts +41 -0
- package/dist/platforms/local.js +247 -0
- package/dist/schemas/review-response.d.ts +37 -0
- package/dist/schemas/review-response.js +39 -0
- package/dist/schemas/review-response.json +68 -0
- package/dist/schemas/validate.d.ts +27 -0
- package/dist/schemas/validate.js +108 -0
- package/dist/schemas/validate.test.d.ts +1 -0
- package/dist/schemas/validate.test.js +484 -0
- package/dist/types/index.d.ts +7 -2
- package/package.json +12 -3
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const parseUnifiedDiff_1 = require("./parseUnifiedDiff");
|
|
5
|
+
(0, vitest_1.describe)("parseUnifiedDiff", () => {
|
|
6
|
+
(0, vitest_1.describe)("basic functionality", () => {
|
|
7
|
+
(0, vitest_1.it)("should return empty array for empty input", () => {
|
|
8
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)([]);
|
|
9
|
+
(0, vitest_1.expect)(result).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
(0, vitest_1.it)("should handle file with no patch", () => {
|
|
12
|
+
const files = [
|
|
13
|
+
{
|
|
14
|
+
filename: "src/unchanged.ts",
|
|
15
|
+
status: "modified",
|
|
16
|
+
additions: 0,
|
|
17
|
+
deletions: 0,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
21
|
+
(0, vitest_1.expect)(result[0].patch).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
(0, vitest_1.it)("should skip deleted files", () => {
|
|
24
|
+
const files = [
|
|
25
|
+
{
|
|
26
|
+
filename: "src/deleted.ts",
|
|
27
|
+
status: "deleted",
|
|
28
|
+
additions: 0,
|
|
29
|
+
deletions: 20,
|
|
30
|
+
patch: "diff content here",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
34
|
+
(0, vitest_1.expect)(result[0].patch).toBe("diff content here");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
(0, vitest_1.describe)("line number annotation", () => {
|
|
38
|
+
(0, vitest_1.it)("should annotate added lines with line numbers", () => {
|
|
39
|
+
const files = [
|
|
40
|
+
{
|
|
41
|
+
filename: "src/utils.ts",
|
|
42
|
+
status: "added",
|
|
43
|
+
additions: 3,
|
|
44
|
+
deletions: 0,
|
|
45
|
+
patch: `@@ -0,0 +1,3 @@
|
|
46
|
+
+line one
|
|
47
|
+
+line two
|
|
48
|
+
+line three`,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
52
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
53
|
+
(0, vitest_1.expect)(lines[0]).toBe("@@ -0,0 +1,3 @@");
|
|
54
|
+
(0, vitest_1.expect)(lines[1]).toBe("+line one // LINE_NUMBER: 1");
|
|
55
|
+
(0, vitest_1.expect)(lines[2]).toBe("+line two // LINE_NUMBER: 2");
|
|
56
|
+
(0, vitest_1.expect)(lines[3]).toBe("+line three // LINE_NUMBER: 3");
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.it)("should handle multiple hunks correctly", () => {
|
|
59
|
+
const files = [
|
|
60
|
+
{
|
|
61
|
+
filename: "src/app.ts",
|
|
62
|
+
status: "modified",
|
|
63
|
+
additions: 6,
|
|
64
|
+
deletions: 2,
|
|
65
|
+
patch: `@@ -1,5 +1,5 @@
|
|
66
|
+
context line
|
|
67
|
+
-old line
|
|
68
|
+
+new line 1
|
|
69
|
+
+new line 2
|
|
70
|
+
context line
|
|
71
|
+
context line
|
|
72
|
+
@@ -20,3 +20,5 @@
|
|
73
|
+
context line
|
|
74
|
+
-old line 2
|
|
75
|
+
+new line 3
|
|
76
|
+
+new line 4
|
|
77
|
+
+new line 5`,
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
81
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
82
|
+
// First hunk starts at line 1
|
|
83
|
+
(0, vitest_1.expect)(lines).toContain("+new line 1 // LINE_NUMBER: 2");
|
|
84
|
+
(0, vitest_1.expect)(lines).toContain("+new line 2 // LINE_NUMBER: 3");
|
|
85
|
+
// Second hunk starts at line 20
|
|
86
|
+
(0, vitest_1.expect)(lines).toContain("+new line 3 // LINE_NUMBER: 21");
|
|
87
|
+
(0, vitest_1.expect)(lines).toContain("+new line 4 // LINE_NUMBER: 22");
|
|
88
|
+
(0, vitest_1.expect)(lines).toContain("+new line 5 // LINE_NUMBER: 23");
|
|
89
|
+
});
|
|
90
|
+
(0, vitest_1.it)("should handle context lines without annotation", () => {
|
|
91
|
+
const files = [
|
|
92
|
+
{
|
|
93
|
+
filename: "src/file.ts",
|
|
94
|
+
status: "modified",
|
|
95
|
+
additions: 1,
|
|
96
|
+
deletions: 0,
|
|
97
|
+
patch: `@@ -5,5 +5,6 @@
|
|
98
|
+
context line 1
|
|
99
|
+
context line 2
|
|
100
|
+
+added line
|
|
101
|
+
context line 3
|
|
102
|
+
context line 4`,
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
106
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
107
|
+
(0, vitest_1.expect)(lines[1]).toBe(" context line 1");
|
|
108
|
+
(0, vitest_1.expect)(lines[2]).toBe(" context line 2");
|
|
109
|
+
(0, vitest_1.expect)(lines[3]).toBe("+added line // LINE_NUMBER: 7");
|
|
110
|
+
(0, vitest_1.expect)(lines[4]).toBe(" context line 3");
|
|
111
|
+
});
|
|
112
|
+
(0, vitest_1.it)("should handle deleted lines without line numbers", () => {
|
|
113
|
+
const files = [
|
|
114
|
+
{
|
|
115
|
+
filename: "src/file.ts",
|
|
116
|
+
status: "modified",
|
|
117
|
+
additions: 0,
|
|
118
|
+
deletions: 2,
|
|
119
|
+
patch: `@@ -10,5 +8,3 @@
|
|
120
|
+
context line
|
|
121
|
+
-deleted line 1
|
|
122
|
+
-deleted line 2
|
|
123
|
+
context line`,
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
127
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
128
|
+
(0, vitest_1.expect)(lines).toContain("-deleted line 1");
|
|
129
|
+
(0, vitest_1.expect)(lines).toContain("-deleted line 2");
|
|
130
|
+
(0, vitest_1.expect)(lines[2]).not.toContain("LINE_NUMBER");
|
|
131
|
+
(0, vitest_1.expect)(lines[3]).not.toContain("LINE_NUMBER");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
(0, vitest_1.describe)("hunk header parsing", () => {
|
|
135
|
+
(0, vitest_1.it)("should parse hunk header with single line addition", () => {
|
|
136
|
+
const files = [
|
|
137
|
+
{
|
|
138
|
+
filename: "src/single.ts",
|
|
139
|
+
status: "modified",
|
|
140
|
+
additions: 1,
|
|
141
|
+
deletions: 0,
|
|
142
|
+
patch: `@@ -10 +10,2 @@
|
|
143
|
+
context
|
|
144
|
+
+added`,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
148
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
149
|
+
(0, vitest_1.expect)(lines[0]).toBe("@@ -10 +10,2 @@");
|
|
150
|
+
// Line 10 is context, added line is at line 11
|
|
151
|
+
(0, vitest_1.expect)(lines[2]).toBe("+added // LINE_NUMBER: 11");
|
|
152
|
+
});
|
|
153
|
+
(0, vitest_1.it)("should parse hunk header starting at line 1", () => {
|
|
154
|
+
const files = [
|
|
155
|
+
{
|
|
156
|
+
filename: "src/start.ts",
|
|
157
|
+
status: "modified",
|
|
158
|
+
additions: 2,
|
|
159
|
+
deletions: 0,
|
|
160
|
+
patch: `@@ -0,0 +1,2 @@
|
|
161
|
+
+first
|
|
162
|
+
+second`,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
166
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
167
|
+
(0, vitest_1.expect)(lines[1]).toBe("+first // LINE_NUMBER: 1");
|
|
168
|
+
(0, vitest_1.expect)(lines[2]).toBe("+second // LINE_NUMBER: 2");
|
|
169
|
+
});
|
|
170
|
+
(0, vitest_1.it)("should reset line offset for each hunk", () => {
|
|
171
|
+
const files = [
|
|
172
|
+
{
|
|
173
|
+
filename: "src/multi.ts",
|
|
174
|
+
status: "modified",
|
|
175
|
+
additions: 4,
|
|
176
|
+
deletions: 0,
|
|
177
|
+
patch: `@@ -1,3 +1,5 @@
|
|
178
|
+
line1
|
|
179
|
+
+added1
|
|
180
|
+
+added2
|
|
181
|
+
line2
|
|
182
|
+
line3
|
|
183
|
+
@@ -50,3 +52,5 @@
|
|
184
|
+
line50
|
|
185
|
+
+added3
|
|
186
|
+
+added4
|
|
187
|
+
line51
|
|
188
|
+
line52`,
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
192
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
193
|
+
// First hunk
|
|
194
|
+
(0, vitest_1.expect)(lines).toContain("+added1 // LINE_NUMBER: 2");
|
|
195
|
+
(0, vitest_1.expect)(lines).toContain("+added2 // LINE_NUMBER: 3");
|
|
196
|
+
// Second hunk should start at 53
|
|
197
|
+
(0, vitest_1.expect)(lines).toContain("+added3 // LINE_NUMBER: 53");
|
|
198
|
+
(0, vitest_1.expect)(lines).toContain("+added4 // LINE_NUMBER: 54");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
(0, vitest_1.describe)("edge cases", () => {
|
|
202
|
+
(0, vitest_1.it)("should handle file paths with special characters", () => {
|
|
203
|
+
const files = [
|
|
204
|
+
{
|
|
205
|
+
filename: "src/components/my-component.ts",
|
|
206
|
+
status: "modified",
|
|
207
|
+
additions: 1,
|
|
208
|
+
deletions: 0,
|
|
209
|
+
patch: `@@ -1,2 +1,3 @@
|
|
210
|
+
import React from 'react';
|
|
211
|
+
+import { useEffect } from 'react';
|
|
212
|
+
export function Component() {},
|
|
213
|
+
`,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
217
|
+
(0, vitest_1.expect)(result[0].filename).toBe("src/components/my-component.ts");
|
|
218
|
+
(0, vitest_1.expect)(result[0].patch).toContain("LINE_NUMBER: 2");
|
|
219
|
+
});
|
|
220
|
+
(0, vitest_1.it)("should handle empty lines in patches", () => {
|
|
221
|
+
const files = [
|
|
222
|
+
{
|
|
223
|
+
filename: "src/spacing.ts",
|
|
224
|
+
status: "modified",
|
|
225
|
+
additions: 2,
|
|
226
|
+
deletions: 0,
|
|
227
|
+
patch: `@@ -1,3 +1,5 @@
|
|
228
|
+
line1
|
|
229
|
+
+
|
|
230
|
+
+line between
|
|
231
|
+
line2`,
|
|
232
|
+
},
|
|
233
|
+
];
|
|
234
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
235
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
236
|
+
(0, vitest_1.expect)(lines[2]).toBe("+ // LINE_NUMBER: 2");
|
|
237
|
+
(0, vitest_1.expect)(lines[3]).toBe("+line between // LINE_NUMBER: 3");
|
|
238
|
+
});
|
|
239
|
+
(0, vitest_1.it)("should preserve original file metadata", () => {
|
|
240
|
+
const files = [
|
|
241
|
+
{
|
|
242
|
+
filename: "src/test.ts",
|
|
243
|
+
status: "renamed",
|
|
244
|
+
additions: 1,
|
|
245
|
+
deletions: 1,
|
|
246
|
+
previousFilename: "src/old-test.ts",
|
|
247
|
+
patch: `@@ -1,1 +1,1 @@
|
|
248
|
+
-old
|
|
249
|
+
+new`,
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
253
|
+
(0, vitest_1.expect)(result[0].filename).toBe("src/test.ts");
|
|
254
|
+
(0, vitest_1.expect)(result[0].status).toBe("renamed");
|
|
255
|
+
(0, vitest_1.expect)(result[0].previousFilename).toBe("src/old-test.ts");
|
|
256
|
+
(0, vitest_1.expect)(result[0].additions).toBe(1);
|
|
257
|
+
(0, vitest_1.expect)(result[0].deletions).toBe(1);
|
|
258
|
+
});
|
|
259
|
+
(0, vitest_1.it)("should handle multiple files in a single call", () => {
|
|
260
|
+
const files = [
|
|
261
|
+
{
|
|
262
|
+
filename: "src/file1.ts",
|
|
263
|
+
status: "modified",
|
|
264
|
+
additions: 1,
|
|
265
|
+
deletions: 0,
|
|
266
|
+
patch: `@@ -1,1 +1,2 @@
|
|
267
|
+
line1
|
|
268
|
+
+added`,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
filename: "src/file2.ts",
|
|
272
|
+
status: "modified",
|
|
273
|
+
additions: 1,
|
|
274
|
+
deletions: 0,
|
|
275
|
+
patch: `@@ -5,1 +5,2 @@
|
|
276
|
+
line5
|
|
277
|
+
+added here`,
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
281
|
+
(0, vitest_1.expect)(result).toHaveLength(2);
|
|
282
|
+
(0, vitest_1.expect)(result[0].patch).toContain("LINE_NUMBER: 2");
|
|
283
|
+
// Line 5 is context, added line is at line 6
|
|
284
|
+
(0, vitest_1.expect)(result[1].patch).toContain("LINE_NUMBER: 6");
|
|
285
|
+
});
|
|
286
|
+
(0, vitest_1.it)("should handle lines starting with +++ or --- (diff metadata)", () => {
|
|
287
|
+
const files = [
|
|
288
|
+
{
|
|
289
|
+
filename: "src/file.ts",
|
|
290
|
+
status: "modified",
|
|
291
|
+
additions: 0,
|
|
292
|
+
deletions: 0,
|
|
293
|
+
patch: `--- a/src/file.ts
|
|
294
|
+
+++ b/src/file.ts
|
|
295
|
+
@@ -1,3 +1,3 @@
|
|
296
|
+
context
|
|
297
|
+
-old
|
|
298
|
+
+new`,
|
|
299
|
+
},
|
|
300
|
+
];
|
|
301
|
+
const result = (0, parseUnifiedDiff_1.parseUnifiedDiff)(files);
|
|
302
|
+
const lines = result[0].patch?.split("\n") ?? [];
|
|
303
|
+
// These should not be annotated as added lines
|
|
304
|
+
(0, vitest_1.expect)(lines[0]).toBe("--- a/src/file.ts");
|
|
305
|
+
(0, vitest_1.expect)(lines[1]).toBe("+++ b/src/file.ts");
|
|
306
|
+
(0, vitest_1.expect)(lines[0]).not.toContain("LINE_NUMBER");
|
|
307
|
+
(0, vitest_1.expect)(lines[1]).not.toContain("LINE_NUMBER");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -17,18 +17,23 @@ async function main() {
|
|
|
17
17
|
const config = (0, config_1.validateConfig)(cliOptions, fileConfig);
|
|
18
18
|
(0, cli_1.verboseLog)(config, `Bot configuration: ${JSON.stringify(config, null, 2)}`);
|
|
19
19
|
// Get platform adapter
|
|
20
|
-
const platform = await (0, platforms_1.getPlatform)(config.gitPlatform);
|
|
20
|
+
const platform = await (0, platforms_1.getPlatform)(config.gitPlatform, config);
|
|
21
21
|
(0, cli_1.verboseLog)(config, `Using platform: ${config.gitPlatform}`);
|
|
22
22
|
// Get model adapter
|
|
23
23
|
const model = (0, models_1.getModel)(config.provider, config.model, config.endpoint);
|
|
24
24
|
(0, cli_1.verboseLog)(config, `Using model: ${config.model}`);
|
|
25
|
-
// Ensure repository is specified
|
|
26
|
-
if (!config.repo) {
|
|
25
|
+
// Ensure repository is specified (not needed for local mode)
|
|
26
|
+
if (!config.local && !config.repo) {
|
|
27
27
|
console.error("Error: Repository is not specified. Please add it to the config file or use the --repo CLI option.");
|
|
28
28
|
process.exit(1);
|
|
29
29
|
}
|
|
30
|
+
// In local mode, use a placeholder repo identifier
|
|
31
|
+
const repo = config.local ? "local" : config.repo;
|
|
32
|
+
if (config.local) {
|
|
33
|
+
console.log("Running in local mode (dry-run)");
|
|
34
|
+
}
|
|
30
35
|
// Get pull requests that need review
|
|
31
|
-
const pullRequests = await platform.getPullRequests(
|
|
36
|
+
const pullRequests = await platform.getPullRequests(repo);
|
|
32
37
|
(0, cli_1.verboseLog)(config, `Found ${pullRequests.length} PRs`);
|
|
33
38
|
if (pullRequests.length === 0) {
|
|
34
39
|
console.log("No pull requests found that need review");
|
|
@@ -39,7 +44,7 @@ async function main() {
|
|
|
39
44
|
for (const pr of pullRequests) {
|
|
40
45
|
(0, cli_1.verboseLog)(config, `Processing PR #${pr.number}: ${pr.title}`);
|
|
41
46
|
// Check if AI has already commented since the last update
|
|
42
|
-
const hasCommented = await platform.hasAICommented(
|
|
47
|
+
const hasCommented = await platform.hasAICommented(repo, pr.id);
|
|
43
48
|
if (hasCommented) {
|
|
44
49
|
(0, cli_1.verboseLog)(config, `Skipping PR #${pr.number} - already reviewed since last update`);
|
|
45
50
|
results.push({
|
|
@@ -51,16 +56,19 @@ async function main() {
|
|
|
51
56
|
}
|
|
52
57
|
try {
|
|
53
58
|
// Get PR diff
|
|
54
|
-
const diff = await platform.getPullRequestDiff(
|
|
59
|
+
const diff = await platform.getPullRequestDiff(repo, pr.id);
|
|
55
60
|
(0, cli_1.verboseLog)(config, `Got diff for PR #${pr.number} with ${diff.length} changed files`);
|
|
56
61
|
const parsedDiff = (0, parseUnifiedDiff_1.parseUnifiedDiff)(diff);
|
|
57
62
|
(0, cli_1.verboseLog)(config, `Parsed diff for PR #${pr.number} with ${diff.length} changed files`);
|
|
58
63
|
// Get AI review
|
|
59
64
|
const comments = await model.review(parsedDiff, config);
|
|
60
65
|
(0, cli_1.verboseLog)(config, `Generated ${comments.length} comments for PR #${pr.number}`);
|
|
61
|
-
if (
|
|
66
|
+
if (config.dryRun) {
|
|
62
67
|
// Just print comments in dry run mode
|
|
63
|
-
|
|
68
|
+
const header = config.local
|
|
69
|
+
? `\n== Review: ${pr.title} ==`
|
|
70
|
+
: `\n== Comments for PR #${pr.number}: ${pr.title} ==`;
|
|
71
|
+
console.log(header);
|
|
64
72
|
comments.forEach((comment) => {
|
|
65
73
|
console.log(`\n${comment.type === "inline"
|
|
66
74
|
? `${comment.path}:${comment.line}`
|
|
@@ -71,7 +79,7 @@ async function main() {
|
|
|
71
79
|
else {
|
|
72
80
|
// Post comments to PR
|
|
73
81
|
for (const comment of comments) {
|
|
74
|
-
await platform.postComment(
|
|
82
|
+
await platform.postComment(repo, pr.id, comment);
|
|
75
83
|
}
|
|
76
84
|
console.log(`Posted ${comments.length} comments to PR #${pr.number}`);
|
|
77
85
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { CodeReviewModel, FileChange, AIComment, ReviewConfig } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* Message format for LLM conversations
|
|
4
|
+
*/
|
|
5
|
+
export interface LLMMessage {
|
|
6
|
+
role: "system" | "user" | "assistant";
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Abstract base class for model adapters.
|
|
11
|
+
* Providers share ~70% of logic (prompt gen, parsing, filtering).
|
|
12
|
+
* Provider-specific implementations only need to implement:
|
|
13
|
+
* - callLLM(): Make the actual API call to the LLM
|
|
14
|
+
* - supportsStructuredOutput(): Whether the provider supports JSON schema output
|
|
15
|
+
*/
|
|
16
|
+
export declare abstract class BaseReviewModel implements CodeReviewModel {
|
|
17
|
+
/**
|
|
18
|
+
* Make an LLM API call with the given messages.
|
|
19
|
+
* Provider-specific implementations handle authentication, request formatting,
|
|
20
|
+
* and response extraction.
|
|
21
|
+
* @param messages Array of messages to send to the LLM
|
|
22
|
+
* @param config Review configuration
|
|
23
|
+
* @returns Raw response string from the LLM
|
|
24
|
+
*/
|
|
25
|
+
protected abstract callLLM(messages: LLMMessage[], config: ReviewConfig): Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Check if this provider supports structured JSON output.
|
|
28
|
+
* If true, the model will request JSON schema output and parse it accordingly.
|
|
29
|
+
* If false, the model will request free-text output and use legacy parsing.
|
|
30
|
+
*/
|
|
31
|
+
protected abstract supportsStructuredOutput(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Get the model name/identifier for this adapter
|
|
34
|
+
*/
|
|
35
|
+
protected abstract getModelName(): string;
|
|
36
|
+
/**
|
|
37
|
+
* Review code changes and generate comments.
|
|
38
|
+
* This is the main entry point that orchestrates the review process.
|
|
39
|
+
* @param diff File changes to review
|
|
40
|
+
* @param config Review configuration
|
|
41
|
+
* @returns List of AI comments
|
|
42
|
+
*/
|
|
43
|
+
review(diff: FileChange[], config: ReviewConfig): Promise<AIComment[]>;
|
|
44
|
+
/**
|
|
45
|
+
* Filter out files that match ignore patterns
|
|
46
|
+
*/
|
|
47
|
+
protected filterIgnoredFiles(diff: FileChange[], config: ReviewConfig): FileChange[];
|
|
48
|
+
/**
|
|
49
|
+
* Generate the system prompt for the LLM
|
|
50
|
+
*/
|
|
51
|
+
protected getSystemPrompt(_config: ReviewConfig): string;
|
|
52
|
+
/**
|
|
53
|
+
* Generate a code review prompt for the given diff
|
|
54
|
+
* @param diff File changes to review
|
|
55
|
+
* @param config Review configuration
|
|
56
|
+
* @returns Prompt for the AI
|
|
57
|
+
*/
|
|
58
|
+
protected generatePrompt(diff: FileChange[], config: ReviewConfig): string;
|
|
59
|
+
/**
|
|
60
|
+
* Parse a structured JSON response from the LLM
|
|
61
|
+
* @param response Raw LLM response
|
|
62
|
+
* @param config Review configuration
|
|
63
|
+
* @returns List of AI comments
|
|
64
|
+
*/
|
|
65
|
+
protected parseStructuredResponse(response: string, config: ReviewConfig): AIComment[];
|
|
66
|
+
/**
|
|
67
|
+
* Parse a free-text response from the LLM (legacy mode)
|
|
68
|
+
* Used when structured output is not supported or parsing fails.
|
|
69
|
+
* @param response Raw LLM response
|
|
70
|
+
* @param config Review configuration
|
|
71
|
+
* @returns List of AI comments
|
|
72
|
+
*/
|
|
73
|
+
protected parseFreeTextResponse(response: string, config: ReviewConfig): AIComment[];
|
|
74
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BaseReviewModel = void 0;
|
|
4
|
+
const review_response_1 = require("../schemas/review-response");
|
|
5
|
+
const validate_1 = require("../schemas/validate");
|
|
6
|
+
/**
|
|
7
|
+
* Abstract base class for model adapters.
|
|
8
|
+
* Providers share ~70% of logic (prompt gen, parsing, filtering).
|
|
9
|
+
* Provider-specific implementations only need to implement:
|
|
10
|
+
* - callLLM(): Make the actual API call to the LLM
|
|
11
|
+
* - supportsStructuredOutput(): Whether the provider supports JSON schema output
|
|
12
|
+
*/
|
|
13
|
+
class BaseReviewModel {
|
|
14
|
+
/**
|
|
15
|
+
* Review code changes and generate comments.
|
|
16
|
+
* This is the main entry point that orchestrates the review process.
|
|
17
|
+
* @param diff File changes to review
|
|
18
|
+
* @param config Review configuration
|
|
19
|
+
* @returns List of AI comments
|
|
20
|
+
*/
|
|
21
|
+
async review(diff, config) {
|
|
22
|
+
// Filter out ignored files
|
|
23
|
+
const filteredDiff = this.filterIgnoredFiles(diff, config);
|
|
24
|
+
// Skip if no files to review
|
|
25
|
+
if (filteredDiff.length === 0) {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
type: "summary",
|
|
29
|
+
content: "No files to review after applying ignore patterns.",
|
|
30
|
+
severity: "suggestion",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
const prompt = this.generatePrompt(filteredDiff, config);
|
|
35
|
+
const systemPrompt = this.getSystemPrompt(config);
|
|
36
|
+
try {
|
|
37
|
+
const raw = await this.callLLM([
|
|
38
|
+
{ role: "system", content: systemPrompt },
|
|
39
|
+
{ role: "user", content: prompt },
|
|
40
|
+
], config);
|
|
41
|
+
return this.supportsStructuredOutput()
|
|
42
|
+
? this.parseStructuredResponse(raw, config)
|
|
43
|
+
: this.parseFreeTextResponse(raw, config);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(`Error generating review with ${this.getModelName()}:`, error);
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Filter out files that match ignore patterns
|
|
52
|
+
*/
|
|
53
|
+
filterIgnoredFiles(diff, config) {
|
|
54
|
+
if (!config.ignoreFiles || config.ignoreFiles.length === 0) {
|
|
55
|
+
return diff;
|
|
56
|
+
}
|
|
57
|
+
return diff.filter((file) => {
|
|
58
|
+
return !config.ignoreFiles?.some((pattern) => {
|
|
59
|
+
// Basic glob pattern matching for *.ext
|
|
60
|
+
if (pattern.startsWith("*") && pattern.indexOf(".") > 0) {
|
|
61
|
+
const ext = pattern.substring(1);
|
|
62
|
+
return file.filename.endsWith(ext);
|
|
63
|
+
}
|
|
64
|
+
return file.filename === pattern;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Generate the system prompt for the LLM
|
|
70
|
+
*/
|
|
71
|
+
getSystemPrompt(_config) {
|
|
72
|
+
return "You are a senior software engineer doing a peer code review. Your job is to spot all logic, syntax, and semantic issues in a code diff. Always respond with valid JSON matching the requested schema.";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Generate a code review prompt for the given diff
|
|
76
|
+
* @param diff File changes to review
|
|
77
|
+
* @param config Review configuration
|
|
78
|
+
* @returns Prompt for the AI
|
|
79
|
+
*/
|
|
80
|
+
generatePrompt(diff, config) {
|
|
81
|
+
const rules = config.rules && config.rules.length > 0
|
|
82
|
+
? `\nApply these specific rules:\n${config.rules
|
|
83
|
+
.map((rule) => `- ${rule}`)
|
|
84
|
+
.join("\n")}`
|
|
85
|
+
: "";
|
|
86
|
+
const diffText = diff
|
|
87
|
+
.map((file) => {
|
|
88
|
+
return `File: ${file.filename} (${file.status})
|
|
89
|
+
${file.patch || "No changes"}
|
|
90
|
+
`;
|
|
91
|
+
})
|
|
92
|
+
.join("\n\n");
|
|
93
|
+
return `
|
|
94
|
+
Review the following code changes and provide specific, actionable feedback.
|
|
95
|
+
|
|
96
|
+
Output your response as a JSON object matching this structure:
|
|
97
|
+
{
|
|
98
|
+
"summary": "Brief overall assessment (optional)",
|
|
99
|
+
"comments": [
|
|
100
|
+
{
|
|
101
|
+
"file": "path/to/file.ts",
|
|
102
|
+
"line": 42,
|
|
103
|
+
"severity": "critical|warning|suggestion|nitpick",
|
|
104
|
+
"category": "bug|security|performance|style|architecture|testing",
|
|
105
|
+
"confidence": 0.95,
|
|
106
|
+
"title": "One-line summary of the issue (max 80 chars)",
|
|
107
|
+
"explanation": "Detailed explanation of why this is an issue",
|
|
108
|
+
"suggestion": "Suggested fix or improvement (use empty string if none)"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Severity levels:
|
|
114
|
+
- critical: Bugs, security vulnerabilities, or data loss risks
|
|
115
|
+
- warning: Potential issues or code smells
|
|
116
|
+
- suggestion: Improvements that would be nice to have
|
|
117
|
+
- nitpick: Minor style preferences
|
|
118
|
+
|
|
119
|
+
Categories:
|
|
120
|
+
- bug: Logic errors, incorrect behavior
|
|
121
|
+
- security: Security vulnerabilities, unsafe practices
|
|
122
|
+
- performance: Performance bottlenecks, inefficiencies
|
|
123
|
+
- style: Code style, formatting, naming
|
|
124
|
+
- architecture: Design patterns, code organization
|
|
125
|
+
- testing: Test coverage, test quality
|
|
126
|
+
|
|
127
|
+
Important rules:
|
|
128
|
+
- Only comment on lines that are part of the diff (added or modified)
|
|
129
|
+
- Do not comment on unchanged context lines unless directly impacted
|
|
130
|
+
- Be specific and actionable in your feedback
|
|
131
|
+
- Use direct, professional tone
|
|
132
|
+
- Include a suggestion when you can provide a concrete improvement
|
|
133
|
+
${rules}
|
|
134
|
+
|
|
135
|
+
Here are the changes to review:
|
|
136
|
+
|
|
137
|
+
${diffText}
|
|
138
|
+
|
|
139
|
+
${config.customPrompt || ""}`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse a structured JSON response from the LLM
|
|
143
|
+
* @param response Raw LLM response
|
|
144
|
+
* @param config Review configuration
|
|
145
|
+
* @returns List of AI comments
|
|
146
|
+
*/
|
|
147
|
+
parseStructuredResponse(response, config) {
|
|
148
|
+
const comments = [];
|
|
149
|
+
// Check if the response looks like structured JSON
|
|
150
|
+
if (!(0, validate_1.looksLikeStructuredResponse)(response)) {
|
|
151
|
+
// Fall back to legacy parsing if it doesn't look like JSON
|
|
152
|
+
return this.parseFreeTextResponse(response, config);
|
|
153
|
+
}
|
|
154
|
+
const result = (0, validate_1.parseStructuredResponse)(response);
|
|
155
|
+
if (!result.success || !result.data) {
|
|
156
|
+
console.warn("Structured response parsing failed, falling back to legacy parsing:", result.error);
|
|
157
|
+
return this.parseFreeTextResponse(response, config);
|
|
158
|
+
}
|
|
159
|
+
const structuredResponse = result.data;
|
|
160
|
+
// Add summary comment if present
|
|
161
|
+
if (structuredResponse.summary) {
|
|
162
|
+
comments.push({
|
|
163
|
+
type: "summary",
|
|
164
|
+
content: structuredResponse.summary,
|
|
165
|
+
severity: config.severity,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Convert structured comments to AIComment format
|
|
169
|
+
for (const structuredComment of structuredResponse.comments) {
|
|
170
|
+
// Apply severity filtering
|
|
171
|
+
const severityOrder = [
|
|
172
|
+
"critical",
|
|
173
|
+
"warning",
|
|
174
|
+
"suggestion",
|
|
175
|
+
"nitpick",
|
|
176
|
+
];
|
|
177
|
+
const minSeverity = config.severity || "suggestion";
|
|
178
|
+
const commentSeverityIndex = severityOrder.indexOf(structuredComment.severity);
|
|
179
|
+
const minSeverityIndex = severityOrder.indexOf(minSeverity);
|
|
180
|
+
// Skip comments below the minimum severity threshold
|
|
181
|
+
if (commentSeverityIndex > minSeverityIndex) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Apply confidence filtering (default min: 0.6)
|
|
185
|
+
const minConfidence = 0.6;
|
|
186
|
+
if (structuredComment.confidence < minConfidence) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const aiComment = (0, review_response_1.toAIComment)(structuredComment);
|
|
190
|
+
comments.push(aiComment);
|
|
191
|
+
}
|
|
192
|
+
return comments;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Parse a free-text response from the LLM (legacy mode)
|
|
196
|
+
* Used when structured output is not supported or parsing fails.
|
|
197
|
+
* @param response Raw LLM response
|
|
198
|
+
* @param config Review configuration
|
|
199
|
+
* @returns List of AI comments
|
|
200
|
+
*/
|
|
201
|
+
parseFreeTextResponse(response, config) {
|
|
202
|
+
const comments = [];
|
|
203
|
+
if (config.commentStyle === "summary") {
|
|
204
|
+
comments.push({
|
|
205
|
+
type: "summary",
|
|
206
|
+
content: response.trim(),
|
|
207
|
+
severity: config.severity,
|
|
208
|
+
});
|
|
209
|
+
return comments;
|
|
210
|
+
}
|
|
211
|
+
// Look for patterns like "filename.ext:123 — comment text"
|
|
212
|
+
const inlineCommentRegex = /([\w/.-]+):(\d+)\s*[—–-]\s*(.*?)(?=\s+[\w/.-]+:\d+\s*[—–-]|$)/gs;
|
|
213
|
+
let match;
|
|
214
|
+
while ((match = inlineCommentRegex.exec(response + "\n\n")) !== null) {
|
|
215
|
+
const [, path, lineStr, content] = match;
|
|
216
|
+
const line = parseInt(lineStr, 10);
|
|
217
|
+
comments.push({
|
|
218
|
+
type: "inline",
|
|
219
|
+
path,
|
|
220
|
+
line,
|
|
221
|
+
content: content.trim(),
|
|
222
|
+
severity: config.severity,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// If no inline comments were parsed, create a summary comment
|
|
226
|
+
if (comments.length === 0) {
|
|
227
|
+
comments.push({
|
|
228
|
+
type: "summary",
|
|
229
|
+
content: response.trim(),
|
|
230
|
+
severity: config.severity,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return comments;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
exports.BaseReviewModel = BaseReviewModel;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|