korekt-cli 0.10.0 → 0.11.2
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/package.json +1 -1
- package/src/file-rules.js +71 -0
- package/src/file-rules.test.js +187 -0
- package/src/git-logic.js +146 -25
- package/src/git-logic.test.js +222 -0
- package/src/index.js +30 -2
- package/src/index.test.js +30 -0
- package/src/utils.js +14 -6
package/package.json
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File processing rules for code review.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a file should be skipped entirely (binary/non-reviewable).
|
|
7
|
+
* @param {string} filePath - The file path to check
|
|
8
|
+
* @param {string[]} skipExtensions - Extensions to skip
|
|
9
|
+
* @returns {boolean} - True if the file should be skipped
|
|
10
|
+
*/
|
|
11
|
+
export function shouldSkip(filePath, skipExtensions) {
|
|
12
|
+
if (!skipExtensions || !Array.isArray(skipExtensions)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const lowerPath = filePath.toLowerCase();
|
|
17
|
+
|
|
18
|
+
for (const ext of skipExtensions) {
|
|
19
|
+
if (lowerPath.endsWith(ext.toLowerCase())) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a file should only show diff (no full content).
|
|
29
|
+
* @param {string} filePath - The file path to check
|
|
30
|
+
* @param {string[]} diffOnlyExtensions - Extensions for diff-only
|
|
31
|
+
* @param {string[]} diffOnlyFiles - Specific filenames for diff-only
|
|
32
|
+
* @returns {boolean} - True if only diff should be shown
|
|
33
|
+
*/
|
|
34
|
+
export function isDiffOnly(filePath, diffOnlyExtensions, diffOnlyFiles) {
|
|
35
|
+
const lowerPath = filePath.toLowerCase();
|
|
36
|
+
const fileName = lowerPath.split('/').pop();
|
|
37
|
+
|
|
38
|
+
// Check exact filename matches first (lowercase comparison)
|
|
39
|
+
if (diffOnlyFiles && Array.isArray(diffOnlyFiles)) {
|
|
40
|
+
if (diffOnlyFiles.some((f) => f.toLowerCase() === fileName)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check extension matches
|
|
46
|
+
if (diffOnlyExtensions && Array.isArray(diffOnlyExtensions)) {
|
|
47
|
+
for (const ext of diffOnlyExtensions) {
|
|
48
|
+
if (lowerPath.endsWith(ext.toLowerCase())) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check if content appears to be binary by looking for null bytes.
|
|
59
|
+
* @param {string} content - The content to check
|
|
60
|
+
* @returns {boolean} - True if content appears binary
|
|
61
|
+
*/
|
|
62
|
+
export function isBinary(content) {
|
|
63
|
+
if (!content || content.length === 0) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sample = content.slice(0, 8192);
|
|
68
|
+
|
|
69
|
+
// Check for null bytes - text files never contain them
|
|
70
|
+
return sample.includes('\0');
|
|
71
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { shouldSkip, isDiffOnly, isBinary } from './file-rules.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tests for file-rules helper functions.
|
|
6
|
+
* These functions take config as parameters - actual rules come from API.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
describe('shouldSkip', () => {
|
|
10
|
+
describe('with null/empty config', () => {
|
|
11
|
+
it('should return false when skipExtensions is null', () => {
|
|
12
|
+
expect(shouldSkip('image.png', null)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should return false when skipExtensions is undefined', () => {
|
|
16
|
+
expect(shouldSkip('image.png', undefined)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should return false when skipExtensions is empty array', () => {
|
|
20
|
+
expect(shouldSkip('image.png', [])).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('with extensions provided', () => {
|
|
25
|
+
const testExtensions = ['.png', '.jpg', '.exe'];
|
|
26
|
+
|
|
27
|
+
it('should skip files matching extensions', () => {
|
|
28
|
+
expect(shouldSkip('image.png', testExtensions)).toBe(true);
|
|
29
|
+
expect(shouldSkip('photo.jpg', testExtensions)).toBe(true);
|
|
30
|
+
expect(shouldSkip('program.exe', testExtensions)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should not skip files not matching extensions', () => {
|
|
34
|
+
expect(shouldSkip('index.js', testExtensions)).toBe(false);
|
|
35
|
+
expect(shouldSkip('style.css', testExtensions)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should be case insensitive', () => {
|
|
39
|
+
expect(shouldSkip('IMAGE.PNG', testExtensions)).toBe(true);
|
|
40
|
+
expect(shouldSkip('Photo.JPG', testExtensions)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle paths with directories', () => {
|
|
44
|
+
expect(shouldSkip('assets/images/logo.png', testExtensions)).toBe(true);
|
|
45
|
+
expect(shouldSkip('src/index.js', testExtensions)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('isDiffOnly', () => {
|
|
51
|
+
describe('with null/empty config', () => {
|
|
52
|
+
it('should return false when both params are null', () => {
|
|
53
|
+
expect(isDiffOnly('config.json', null, null)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return false when both params are empty arrays', () => {
|
|
57
|
+
expect(isDiffOnly('config.json', [], [])).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('with extensions provided', () => {
|
|
62
|
+
const testExtensions = ['.json', '.lock'];
|
|
63
|
+
const testFiles = [];
|
|
64
|
+
|
|
65
|
+
it('should match extension-based diff-only files', () => {
|
|
66
|
+
expect(isDiffOnly('config.json', testExtensions, testFiles)).toBe(true);
|
|
67
|
+
expect(isDiffOnly('composer.lock', testExtensions, testFiles)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should not match non-diff-only files', () => {
|
|
71
|
+
expect(isDiffOnly('index.js', testExtensions, testFiles)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should be case insensitive for extensions', () => {
|
|
75
|
+
expect(isDiffOnly('CONFIG.JSON', testExtensions, testFiles)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('with specific filenames provided', () => {
|
|
80
|
+
const testExtensions = [];
|
|
81
|
+
const testFiles = ['package-lock.json', 'yarn.lock'];
|
|
82
|
+
|
|
83
|
+
it('should match specific filenames', () => {
|
|
84
|
+
expect(isDiffOnly('package-lock.json', testExtensions, testFiles)).toBe(true);
|
|
85
|
+
expect(isDiffOnly('yarn.lock', testExtensions, testFiles)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should match filenames in paths', () => {
|
|
89
|
+
expect(isDiffOnly('node_modules/package-lock.json', testExtensions, testFiles)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should be case insensitive for filenames', () => {
|
|
93
|
+
expect(isDiffOnly('PACKAGE-LOCK.JSON', testExtensions, testFiles)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('with both extensions and filenames', () => {
|
|
98
|
+
const testExtensions = ['.json'];
|
|
99
|
+
const testFiles = ['go.sum'];
|
|
100
|
+
|
|
101
|
+
it('should work with only extensions provided', () => {
|
|
102
|
+
expect(isDiffOnly('config.json', testExtensions, null)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should work with only files provided', () => {
|
|
106
|
+
expect(isDiffOnly('go.sum', null, testFiles)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('isBinary', () => {
|
|
112
|
+
describe('empty/null content', () => {
|
|
113
|
+
it('should return false for empty string', () => {
|
|
114
|
+
expect(isBinary('')).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return false for null', () => {
|
|
118
|
+
expect(isBinary(null)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return false for undefined', () => {
|
|
122
|
+
expect(isBinary(undefined)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('text content', () => {
|
|
127
|
+
it('should return false for plain text', () => {
|
|
128
|
+
expect(isBinary('Hello, World!')).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should return false for code', () => {
|
|
132
|
+
const code = `function hello() {\n console.log('Hello');\n}`;
|
|
133
|
+
expect(isBinary(code)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should return false for content with whitespace', () => {
|
|
137
|
+
expect(isBinary('Line 1\n\tIndented\r\nWindows line')).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return false for UTF-8 non-Latin text', () => {
|
|
141
|
+
// Chinese, Japanese, Cyrillic - all valid UTF-8, no null bytes
|
|
142
|
+
expect(isBinary('你好世界')).toBe(false);
|
|
143
|
+
expect(isBinary('こんにちは')).toBe(false);
|
|
144
|
+
expect(isBinary('Привет мир')).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should return false for text with control characters (non-null)', () => {
|
|
148
|
+
// Control chars like \x01-\x1F are not null bytes
|
|
149
|
+
const content = 'text\x01\x02\x03more text';
|
|
150
|
+
expect(isBinary(content)).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('binary content (null bytes)', () => {
|
|
155
|
+
it('should return true for content with null byte at start', () => {
|
|
156
|
+
expect(isBinary('\x00some text')).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should return true for content with null byte in middle', () => {
|
|
160
|
+
expect(isBinary('some\x00text')).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should return true for content with null byte at end', () => {
|
|
164
|
+
expect(isBinary('some text\x00')).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should return true for content with many null bytes', () => {
|
|
168
|
+
const binary = '\x00\x00\x00\x00some text\x00\x00\x00';
|
|
169
|
+
expect(isBinary(binary)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('edge cases', () => {
|
|
174
|
+
it('should only sample first 8192 characters', () => {
|
|
175
|
+
// Null byte after 8192 chars should not be detected
|
|
176
|
+
const textPart = 'a'.repeat(10000);
|
|
177
|
+
const binaryPart = '\x00';
|
|
178
|
+
expect(isBinary(textPart + binaryPart)).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should detect null byte within first 8192 characters', () => {
|
|
182
|
+
const textPart = 'a'.repeat(8000);
|
|
183
|
+
const binaryPart = '\x00';
|
|
184
|
+
expect(isBinary(textPart + binaryPart)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/git-logic.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { detectCIProvider, getPrUrl, getSourceBranchFromCI } from './utils.js';
|
|
4
|
+
import { shouldSkip, isDiffOnly, isBinary } from './file-rules.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Truncate content to a maximum number of lines using "head and tail".
|
|
@@ -161,9 +162,14 @@ export function parseNameStatus(output) {
|
|
|
161
162
|
/**
|
|
162
163
|
* Analyze uncommitted changes (staged or unstaged)
|
|
163
164
|
* @param {string} mode - 'staged' or 'unstaged'
|
|
165
|
+
* @param {Object} fileRulesConfig - File rules config from API (or defaults)
|
|
164
166
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
165
167
|
*/
|
|
166
|
-
export async function runUncommittedReview(mode = 'unstaged') {
|
|
168
|
+
export async function runUncommittedReview(mode = 'unstaged', fileRulesConfig = null) {
|
|
169
|
+
// If no config provided, use empty config (no filtering applied)
|
|
170
|
+
const config = fileRulesConfig || {};
|
|
171
|
+
const maxLines = config.max_lines ?? 2000;
|
|
172
|
+
|
|
167
173
|
try {
|
|
168
174
|
// 1. Get Repo URL, current branch name, and repository root
|
|
169
175
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -190,7 +196,43 @@ export async function runUncommittedReview(mode = 'unstaged') {
|
|
|
190
196
|
console.error(chalk.gray('Analyzing unstaged changes...'));
|
|
191
197
|
}
|
|
192
198
|
|
|
193
|
-
|
|
199
|
+
let fileList = parseNameStatus(nameStatusOutput);
|
|
200
|
+
|
|
201
|
+
// Filter out binary/non-reviewable files (only if config has skip_extensions)
|
|
202
|
+
if (config.skip_extensions) {
|
|
203
|
+
let skippedCount = 0;
|
|
204
|
+
fileList = fileList.filter((file) => {
|
|
205
|
+
if (shouldSkip(file.path, config.skip_extensions)) {
|
|
206
|
+
skippedCount++;
|
|
207
|
+
console.error(chalk.gray(` Skipping binary: ${file.path}`));
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (skippedCount > 0) {
|
|
214
|
+
console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check if file count exceeds max_files_for_content limit
|
|
219
|
+
if (config.max_files_for_content && fileList.length > config.max_files_for_content) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Too many files: ${fileList.length} files exceeds the limit of ${config.max_files_for_content}. ` +
|
|
222
|
+
`Please reduce the number of changed files before reviewing.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if this is a large change set (only if config has large_pr_threshold)
|
|
227
|
+
const isLargePr = config.large_pr_threshold && fileList.length > config.large_pr_threshold;
|
|
228
|
+
if (isLargePr) {
|
|
229
|
+
console.error(
|
|
230
|
+
chalk.yellow(
|
|
231
|
+
`Large change set (${fileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
194
236
|
const changedFiles = [];
|
|
195
237
|
|
|
196
238
|
for (const file of fileList) {
|
|
@@ -204,11 +246,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
|
|
|
204
246
|
diff = await git('diff', '-U15', '--', path);
|
|
205
247
|
}
|
|
206
248
|
|
|
249
|
+
// Determine if we should include content for this file
|
|
250
|
+
const skipContent =
|
|
251
|
+
isLargePr ||
|
|
252
|
+
isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
|
|
253
|
+
status === 'A';
|
|
254
|
+
|
|
207
255
|
// Get current content from HEAD (before changes)
|
|
208
256
|
let content = '';
|
|
209
|
-
if (status !== 'A') {
|
|
257
|
+
if (!skipContent && status !== 'A') {
|
|
210
258
|
try {
|
|
211
|
-
|
|
259
|
+
const originalContent = await git('show', `HEAD:${oldPath}`);
|
|
260
|
+
|
|
261
|
+
// Check if content is binary
|
|
262
|
+
if (isBinary(originalContent)) {
|
|
263
|
+
console.error(chalk.gray(` Skipping binary content: ${path}`));
|
|
264
|
+
} else {
|
|
265
|
+
content = truncateContent(originalContent, maxLines);
|
|
266
|
+
}
|
|
212
267
|
} catch {
|
|
213
268
|
console.warn(
|
|
214
269
|
chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
|
|
@@ -216,21 +271,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
|
|
|
216
271
|
}
|
|
217
272
|
}
|
|
218
273
|
|
|
219
|
-
// Truncate content
|
|
220
|
-
content = truncateContent(content);
|
|
221
|
-
|
|
222
274
|
// For deleted files, truncate the diff as well
|
|
223
275
|
if (status === 'D') {
|
|
224
|
-
diff = truncateContent(diff);
|
|
276
|
+
diff = truncateContent(diff, maxLines);
|
|
225
277
|
}
|
|
226
278
|
|
|
227
|
-
|
|
279
|
+
// Build the file object - only include content if we have it
|
|
280
|
+
const fileObj = {
|
|
228
281
|
path: path,
|
|
229
282
|
status: status,
|
|
230
283
|
diff: diff,
|
|
231
|
-
content: content,
|
|
232
284
|
...((status === 'R' || status === 'C') && { old_path: oldPath }),
|
|
233
|
-
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
if (content) {
|
|
288
|
+
fileObj.content = content;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
changedFiles.push(fileObj);
|
|
234
292
|
}
|
|
235
293
|
|
|
236
294
|
if (!nameStatusOutput.trim() && changedFiles.length === 0) {
|
|
@@ -312,9 +370,18 @@ export async function getContributors(diffRange, repoRootPath) {
|
|
|
312
370
|
* Main function to analyze local git changes and prepare review payload
|
|
313
371
|
* @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
|
|
314
372
|
* @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
|
|
373
|
+
* @param {Object} fileRulesConfig - File rules config from API (or defaults)
|
|
315
374
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
316
375
|
*/
|
|
317
|
-
export async function runLocalReview(
|
|
376
|
+
export async function runLocalReview(
|
|
377
|
+
targetBranch = null,
|
|
378
|
+
ignorePatterns = null,
|
|
379
|
+
fileRulesConfig = null
|
|
380
|
+
) {
|
|
381
|
+
// If no config provided, use empty config (no filtering applied)
|
|
382
|
+
const config = fileRulesConfig || {};
|
|
383
|
+
const maxLines = config.max_lines ?? 2000;
|
|
384
|
+
|
|
318
385
|
try {
|
|
319
386
|
// 1. Get Repo URL, current branch name, commit hash, and repository root
|
|
320
387
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -454,7 +521,7 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
454
521
|
});
|
|
455
522
|
const fileList = parseNameStatus(nameStatusOutput);
|
|
456
523
|
|
|
457
|
-
// Filter out ignored files
|
|
524
|
+
// Filter out ignored files (user-specified patterns)
|
|
458
525
|
let filteredFileList = fileList;
|
|
459
526
|
let ignoredCount = 0;
|
|
460
527
|
if (ignorePatterns && ignorePatterns.length > 0) {
|
|
@@ -472,9 +539,46 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
472
539
|
console.error(chalk.gray(`Ignored ${ignoredCount} file(s) based on patterns\n`));
|
|
473
540
|
}
|
|
474
541
|
|
|
542
|
+
// Filter out binary/non-reviewable files (only if config has skip_extensions)
|
|
543
|
+
if (config.skip_extensions) {
|
|
544
|
+
let skippedCount = 0;
|
|
545
|
+
filteredFileList = filteredFileList.filter((file) => {
|
|
546
|
+
if (shouldSkip(file.path, config.skip_extensions)) {
|
|
547
|
+
skippedCount++;
|
|
548
|
+
console.error(chalk.gray(` Skipping binary: ${file.path}`));
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
return true;
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (skippedCount > 0) {
|
|
555
|
+
console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Check if file count exceeds max_files_for_content limit
|
|
560
|
+
if (config.max_files_for_content && filteredFileList.length > config.max_files_for_content) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`Too many files: ${filteredFileList.length} files exceeds the limit of ${config.max_files_for_content}. ` +
|
|
563
|
+
`Please split this PR into smaller reviews or use --ignore to exclude some files.`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Check if this is a large PR (only if config has large_pr_threshold)
|
|
568
|
+
const isLargePr =
|
|
569
|
+
config.large_pr_threshold && filteredFileList.length > config.large_pr_threshold;
|
|
570
|
+
if (isLargePr) {
|
|
571
|
+
console.error(
|
|
572
|
+
chalk.yellow(
|
|
573
|
+
`Large PR detected (${filteredFileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
|
|
574
|
+
)
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
475
578
|
console.error(chalk.gray(`Collecting diffs for ${filteredFileList.length} file(s)...`));
|
|
476
579
|
|
|
477
580
|
const changedFiles = [];
|
|
581
|
+
|
|
478
582
|
for (const file of filteredFileList) {
|
|
479
583
|
const { status, path, oldPath } = file;
|
|
480
584
|
|
|
@@ -484,17 +588,30 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
484
588
|
cwd: repoRootPath,
|
|
485
589
|
});
|
|
486
590
|
|
|
487
|
-
//
|
|
591
|
+
// Determine if we should include content for this file
|
|
592
|
+
// Skip content for: large PRs, DIFF_ONLY files, deleted files, or added files
|
|
593
|
+
const skipContent =
|
|
594
|
+
isLargePr ||
|
|
595
|
+
isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
|
|
596
|
+
status === 'A';
|
|
597
|
+
|
|
598
|
+
// Get the original content from the base commit (unless we're skipping it)
|
|
488
599
|
let content = '';
|
|
489
|
-
if (status !== 'A') {
|
|
490
|
-
// Added files have no original content
|
|
600
|
+
if (!skipContent && status !== 'A') {
|
|
491
601
|
try {
|
|
492
602
|
const { stdout: originalContent } = await execa(
|
|
493
603
|
'git',
|
|
494
604
|
['show', `${mergeBase.trim()}:${oldPath}`],
|
|
495
605
|
{ cwd: repoRootPath }
|
|
496
606
|
);
|
|
497
|
-
|
|
607
|
+
|
|
608
|
+
// Check if content is binary
|
|
609
|
+
if (isBinary(originalContent)) {
|
|
610
|
+
console.error(chalk.gray(` Skipping binary content: ${path}`));
|
|
611
|
+
// Don't include content for binary files
|
|
612
|
+
} else {
|
|
613
|
+
content = truncateContent(originalContent, maxLines);
|
|
614
|
+
}
|
|
498
615
|
} catch {
|
|
499
616
|
// This can happen if a file was added and modified in the same branch
|
|
500
617
|
console.warn(
|
|
@@ -503,22 +620,26 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
503
620
|
}
|
|
504
621
|
}
|
|
505
622
|
|
|
506
|
-
// Truncate content
|
|
507
|
-
content = truncateContent(content);
|
|
508
|
-
|
|
509
623
|
// For deleted files, truncate the diff as well
|
|
510
624
|
let truncatedDiff = diff;
|
|
511
625
|
if (status === 'D') {
|
|
512
|
-
truncatedDiff = truncateContent(diff);
|
|
626
|
+
truncatedDiff = truncateContent(diff, maxLines);
|
|
513
627
|
}
|
|
514
628
|
|
|
515
|
-
|
|
629
|
+
// Build the file object - only include content if we have it
|
|
630
|
+
const fileObj = {
|
|
516
631
|
path: path,
|
|
517
632
|
status: status,
|
|
518
633
|
diff: truncatedDiff,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
634
|
+
...((status === 'R' || status === 'C') && { old_path: oldPath }),
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
// Only include content field if we have content
|
|
638
|
+
if (content) {
|
|
639
|
+
fileObj.content = content;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
changedFiles.push(fileObj);
|
|
522
643
|
}
|
|
523
644
|
|
|
524
645
|
// 5. Get contributors from commits
|
package/src/git-logic.test.js
CHANGED
|
@@ -1298,6 +1298,228 @@ describe('getSourceBranchFromCI', () => {
|
|
|
1298
1298
|
});
|
|
1299
1299
|
});
|
|
1300
1300
|
|
|
1301
|
+
describe('fileRulesConfig filtering', () => {
|
|
1302
|
+
beforeEach(() => {
|
|
1303
|
+
vi.mock('execa');
|
|
1304
|
+
vi.mock('./utils.js', () => ({
|
|
1305
|
+
detectCIProvider: vi.fn().mockReturnValue(null),
|
|
1306
|
+
getPrUrl: vi.fn().mockReturnValue(null),
|
|
1307
|
+
getSourceBranchFromCI: vi.fn().mockReturnValue(null),
|
|
1308
|
+
}));
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
afterEach(() => {
|
|
1312
|
+
vi.restoreAllMocks();
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it('should exclude files matching skip_extensions', async () => {
|
|
1316
|
+
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
1317
|
+
const command = [cmd, ...args].join(' ');
|
|
1318
|
+
|
|
1319
|
+
if (command.includes('remote get-url origin')) {
|
|
1320
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
1321
|
+
}
|
|
1322
|
+
if (command.includes('rev-parse --abbrev-ref HEAD')) {
|
|
1323
|
+
return { stdout: 'feature-branch' };
|
|
1324
|
+
}
|
|
1325
|
+
if (command.includes('rev-parse --show-toplevel')) {
|
|
1326
|
+
return { stdout: '/fake/repo/path' };
|
|
1327
|
+
}
|
|
1328
|
+
if (command.includes('diff --cached --name-status')) {
|
|
1329
|
+
return { stdout: 'M\tfile.js\nM\timage.png\nM\tphoto.jpg' };
|
|
1330
|
+
}
|
|
1331
|
+
if (command.includes('diff --cached -U15 -- file.js')) {
|
|
1332
|
+
return { stdout: 'diff for file.js' };
|
|
1333
|
+
}
|
|
1334
|
+
if (command.includes('diff --cached -U15 -- image.png')) {
|
|
1335
|
+
return { stdout: 'diff for image.png' };
|
|
1336
|
+
}
|
|
1337
|
+
if (command.includes('diff --cached -U15 -- photo.jpg')) {
|
|
1338
|
+
return { stdout: 'diff for photo.jpg' };
|
|
1339
|
+
}
|
|
1340
|
+
if (command.includes('show HEAD:file.js')) {
|
|
1341
|
+
return { stdout: 'js content' };
|
|
1342
|
+
}
|
|
1343
|
+
if (command.includes('show HEAD:image.png')) {
|
|
1344
|
+
return { stdout: 'png content' };
|
|
1345
|
+
}
|
|
1346
|
+
if (command.includes('show HEAD:photo.jpg')) {
|
|
1347
|
+
return { stdout: 'jpg content' };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
throw new Error(`Unmocked command: ${command}`);
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
const config = {
|
|
1354
|
+
skip_extensions: ['.png', '.jpg'],
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
const result = await runUncommittedReview('staged', config);
|
|
1358
|
+
|
|
1359
|
+
expect(result).toBeDefined();
|
|
1360
|
+
expect(result.changed_files).toHaveLength(1);
|
|
1361
|
+
expect(result.changed_files[0].path).toBe('file.js');
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('should not include content for diff_only files', async () => {
|
|
1365
|
+
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
1366
|
+
const command = [cmd, ...args].join(' ');
|
|
1367
|
+
|
|
1368
|
+
if (command.includes('remote get-url origin')) {
|
|
1369
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
1370
|
+
}
|
|
1371
|
+
if (command.includes('rev-parse --abbrev-ref HEAD')) {
|
|
1372
|
+
return { stdout: 'feature-branch' };
|
|
1373
|
+
}
|
|
1374
|
+
if (command.includes('rev-parse --show-toplevel')) {
|
|
1375
|
+
return { stdout: '/fake/repo/path' };
|
|
1376
|
+
}
|
|
1377
|
+
if (command.includes('diff --cached --name-status')) {
|
|
1378
|
+
return { stdout: 'M\tfile.js\nM\tpackage-lock.json\nM\tconfig.yaml' };
|
|
1379
|
+
}
|
|
1380
|
+
if (command.includes('diff --cached -U15 -- file.js')) {
|
|
1381
|
+
return { stdout: 'diff for file.js' };
|
|
1382
|
+
}
|
|
1383
|
+
if (command.includes('diff --cached -U15 -- package-lock.json')) {
|
|
1384
|
+
return { stdout: 'diff for package-lock.json' };
|
|
1385
|
+
}
|
|
1386
|
+
if (command.includes('diff --cached -U15 -- config.yaml')) {
|
|
1387
|
+
return { stdout: 'diff for config.yaml' };
|
|
1388
|
+
}
|
|
1389
|
+
if (command.includes('show HEAD:file.js')) {
|
|
1390
|
+
return { stdout: 'js content' };
|
|
1391
|
+
}
|
|
1392
|
+
if (command.includes('show HEAD:package-lock.json')) {
|
|
1393
|
+
return { stdout: 'lock content' };
|
|
1394
|
+
}
|
|
1395
|
+
if (command.includes('show HEAD:config.yaml')) {
|
|
1396
|
+
return { stdout: 'yaml content' };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
throw new Error(`Unmocked command: ${command}`);
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
const config = {
|
|
1403
|
+
diff_only_extensions: ['.yaml'],
|
|
1404
|
+
diff_only_files: ['package-lock.json'],
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
const result = await runUncommittedReview('staged', config);
|
|
1408
|
+
|
|
1409
|
+
expect(result).toBeDefined();
|
|
1410
|
+
expect(result.changed_files).toHaveLength(3);
|
|
1411
|
+
|
|
1412
|
+
const jsFile = result.changed_files.find((f) => f.path === 'file.js');
|
|
1413
|
+
const lockFile = result.changed_files.find((f) => f.path === 'package-lock.json');
|
|
1414
|
+
const yamlFile = result.changed_files.find((f) => f.path === 'config.yaml');
|
|
1415
|
+
|
|
1416
|
+
expect(jsFile.content).toBe('js content');
|
|
1417
|
+
expect(lockFile.content).toBeUndefined();
|
|
1418
|
+
expect(yamlFile.content).toBeUndefined();
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('should not include content for any file when large_pr_threshold exceeded', async () => {
|
|
1422
|
+
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
1423
|
+
const command = [cmd, ...args].join(' ');
|
|
1424
|
+
|
|
1425
|
+
if (command.includes('remote get-url origin')) {
|
|
1426
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
1427
|
+
}
|
|
1428
|
+
if (command.includes('rev-parse --abbrev-ref HEAD')) {
|
|
1429
|
+
return { stdout: 'feature-branch' };
|
|
1430
|
+
}
|
|
1431
|
+
if (command.includes('rev-parse --show-toplevel')) {
|
|
1432
|
+
return { stdout: '/fake/repo/path' };
|
|
1433
|
+
}
|
|
1434
|
+
if (command.includes('diff --cached --name-status')) {
|
|
1435
|
+
return { stdout: 'M\tfile1.js\nM\tfile2.js\nM\tfile3.js' };
|
|
1436
|
+
}
|
|
1437
|
+
if (command.includes('diff --cached -U15 -- file1.js')) {
|
|
1438
|
+
return { stdout: 'diff for file1.js' };
|
|
1439
|
+
}
|
|
1440
|
+
if (command.includes('diff --cached -U15 -- file2.js')) {
|
|
1441
|
+
return { stdout: 'diff for file2.js' };
|
|
1442
|
+
}
|
|
1443
|
+
if (command.includes('diff --cached -U15 -- file3.js')) {
|
|
1444
|
+
return { stdout: 'diff for file3.js' };
|
|
1445
|
+
}
|
|
1446
|
+
if (command.includes('show HEAD:file1.js')) {
|
|
1447
|
+
return { stdout: 'content1' };
|
|
1448
|
+
}
|
|
1449
|
+
if (command.includes('show HEAD:file2.js')) {
|
|
1450
|
+
return { stdout: 'content2' };
|
|
1451
|
+
}
|
|
1452
|
+
if (command.includes('show HEAD:file3.js')) {
|
|
1453
|
+
return { stdout: 'content3' };
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
throw new Error(`Unmocked command: ${command}`);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
const config = {
|
|
1460
|
+
large_pr_threshold: 2, // 3 files > 2 threshold
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
const result = await runUncommittedReview('staged', config);
|
|
1464
|
+
|
|
1465
|
+
expect(result).toBeDefined();
|
|
1466
|
+
expect(result.changed_files).toHaveLength(3);
|
|
1467
|
+
|
|
1468
|
+
// All files should have no content due to large PR
|
|
1469
|
+
for (const file of result.changed_files) {
|
|
1470
|
+
expect(file.content).toBeUndefined();
|
|
1471
|
+
expect(file.diff).toBeDefined();
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('should include content when below large_pr_threshold', async () => {
|
|
1476
|
+
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
1477
|
+
const command = [cmd, ...args].join(' ');
|
|
1478
|
+
|
|
1479
|
+
if (command.includes('remote get-url origin')) {
|
|
1480
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
1481
|
+
}
|
|
1482
|
+
if (command.includes('rev-parse --abbrev-ref HEAD')) {
|
|
1483
|
+
return { stdout: 'feature-branch' };
|
|
1484
|
+
}
|
|
1485
|
+
if (command.includes('rev-parse --show-toplevel')) {
|
|
1486
|
+
return { stdout: '/fake/repo/path' };
|
|
1487
|
+
}
|
|
1488
|
+
if (command.includes('diff --cached --name-status')) {
|
|
1489
|
+
return { stdout: 'M\tfile1.js\nM\tfile2.js' };
|
|
1490
|
+
}
|
|
1491
|
+
if (command.includes('diff --cached -U15 -- file1.js')) {
|
|
1492
|
+
return { stdout: 'diff for file1.js' };
|
|
1493
|
+
}
|
|
1494
|
+
if (command.includes('diff --cached -U15 -- file2.js')) {
|
|
1495
|
+
return { stdout: 'diff for file2.js' };
|
|
1496
|
+
}
|
|
1497
|
+
if (command.includes('show HEAD:file1.js')) {
|
|
1498
|
+
return { stdout: 'content1' };
|
|
1499
|
+
}
|
|
1500
|
+
if (command.includes('show HEAD:file2.js')) {
|
|
1501
|
+
return { stdout: 'content2' };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
throw new Error(`Unmocked command: ${command}`);
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
const config = {
|
|
1508
|
+
large_pr_threshold: 5, // 2 files < 5 threshold
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
const result = await runUncommittedReview('staged', config);
|
|
1512
|
+
|
|
1513
|
+
expect(result).toBeDefined();
|
|
1514
|
+
expect(result.changed_files).toHaveLength(2);
|
|
1515
|
+
|
|
1516
|
+
// All files should have content
|
|
1517
|
+
for (const file of result.changed_files) {
|
|
1518
|
+
expect(file.content).toBeDefined();
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1301
1523
|
describe('runLocalReview - detached HEAD handling', () => {
|
|
1302
1524
|
beforeEach(() => {
|
|
1303
1525
|
vi.mock('execa');
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,28 @@ export { detectCIProvider, truncateFileData, formatErrorOutput, getPrUrl } from
|
|
|
22
22
|
const require = createRequire(import.meta.url);
|
|
23
23
|
const { version } = require('../package.json');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Fetch file processing rules from the API.
|
|
27
|
+
* These rules control which files to skip, which to show diff-only, etc.
|
|
28
|
+
* @param {string} apiEndpoint - The API endpoint URL
|
|
29
|
+
* @param {string} apiKey - The API key for authentication
|
|
30
|
+
* @returns {Promise<Object|null>} - Config object or null if fetch fails
|
|
31
|
+
*/
|
|
32
|
+
async function fetchFileRulesConfig(apiEndpoint, apiKey) {
|
|
33
|
+
try {
|
|
34
|
+
const configEndpoint = apiEndpoint.replace(/\/review\/?$/, '/config/file-rules');
|
|
35
|
+
const response = await axios.get(configEndpoint, {
|
|
36
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
37
|
+
timeout: 3000,
|
|
38
|
+
});
|
|
39
|
+
return response.data;
|
|
40
|
+
} catch {
|
|
41
|
+
// Non-fatal: continue without filtering if config unavailable
|
|
42
|
+
log(chalk.yellow('Warning: Could not fetch file rules config.'));
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
/**
|
|
26
48
|
* Helper functions for clean output separation:
|
|
27
49
|
* - log() writes to stderr (progress, info, errors)
|
|
@@ -157,8 +179,11 @@ program
|
|
|
157
179
|
process.exit(1);
|
|
158
180
|
}
|
|
159
181
|
|
|
182
|
+
// Fetch file rules config from API
|
|
183
|
+
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
184
|
+
|
|
160
185
|
// Gather all data using our git logic module
|
|
161
|
-
const payload = await runLocalReview(targetBranch, options.ignore);
|
|
186
|
+
const payload = await runLocalReview(targetBranch, options.ignore, fileRulesConfig);
|
|
162
187
|
|
|
163
188
|
if (!payload) {
|
|
164
189
|
log(chalk.red('Could not proceed with review due to errors during analysis.'));
|
|
@@ -334,8 +359,11 @@ async function reviewUncommitted(mode, options) {
|
|
|
334
359
|
process.exit(1);
|
|
335
360
|
}
|
|
336
361
|
|
|
362
|
+
// Fetch file rules config from API
|
|
363
|
+
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
364
|
+
|
|
337
365
|
const { runUncommittedReview } = await import('./git-logic.js');
|
|
338
|
-
const payload = await runUncommittedReview(mode);
|
|
366
|
+
const payload = await runUncommittedReview(mode, fileRulesConfig);
|
|
339
367
|
|
|
340
368
|
if (!payload) {
|
|
341
369
|
log(chalk.red('No changes found or error occurred during analysis.'));
|
package/src/index.test.js
CHANGED
|
@@ -153,6 +153,36 @@ describe('CLI JSON output mode', () => {
|
|
|
153
153
|
expect(displayFile.diff).not.toContain('truncated');
|
|
154
154
|
expect(displayFile.content).not.toContain('truncated');
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
it('should handle diff-only files without content', () => {
|
|
158
|
+
const file = {
|
|
159
|
+
path: 'test.js',
|
|
160
|
+
status: 'M',
|
|
161
|
+
diff: 'some diff here',
|
|
162
|
+
// no content field - this is a diff-only file
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const displayFile = truncateFileData(file);
|
|
166
|
+
|
|
167
|
+
expect(displayFile.path).toBe('test.js');
|
|
168
|
+
expect(displayFile.status).toBe('M');
|
|
169
|
+
expect(displayFile.diff).toBe('some diff here');
|
|
170
|
+
expect(displayFile.content).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should handle files with neither diff nor content', () => {
|
|
174
|
+
const file = {
|
|
175
|
+
path: 'deleted.js',
|
|
176
|
+
status: 'D',
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const displayFile = truncateFileData(file);
|
|
180
|
+
|
|
181
|
+
expect(displayFile.path).toBe('deleted.js');
|
|
182
|
+
expect(displayFile.status).toBe('D');
|
|
183
|
+
expect(displayFile.diff).toBeUndefined();
|
|
184
|
+
expect(displayFile.content).toBeUndefined();
|
|
185
|
+
});
|
|
156
186
|
});
|
|
157
187
|
|
|
158
188
|
describe('error formatting for JSON mode', () => {
|
package/src/utils.js
CHANGED
|
@@ -73,19 +73,27 @@ export function getPrUrl() {
|
|
|
73
73
|
* @returns {Object} File object with truncated diff and content
|
|
74
74
|
*/
|
|
75
75
|
export function truncateFileData(file, maxLength = 500) {
|
|
76
|
-
|
|
76
|
+
const result = {
|
|
77
77
|
path: file.path,
|
|
78
78
|
status: file.status,
|
|
79
79
|
...(file.old_path && { old_path: file.old_path }),
|
|
80
|
-
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (file.diff) {
|
|
83
|
+
result.diff =
|
|
81
84
|
file.diff.length > maxLength
|
|
82
85
|
? `${file.diff.substring(0, maxLength)}... [truncated ${file.diff.length - maxLength} chars]`
|
|
83
|
-
: file.diff
|
|
84
|
-
|
|
86
|
+
: file.diff;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (file.content) {
|
|
90
|
+
result.content =
|
|
85
91
|
file.content.length > maxLength
|
|
86
92
|
? `${file.content.substring(0, maxLength)}... [truncated ${file.content.length - maxLength} chars]`
|
|
87
|
-
: file.content
|
|
88
|
-
}
|
|
93
|
+
: file.content;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return result;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
/**
|