korekt-cli 0.10.0 → 0.11.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/package.json +1 -1
- package/src/file-rules.js +71 -0
- package/src/file-rules.test.js +187 -0
- package/src/git-logic.js +130 -25
- package/src/git-logic.test.js +222 -0
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,35 @@ 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 this is a large change set (only if config has large_pr_threshold)
|
|
219
|
+
const isLargePr = config.large_pr_threshold && fileList.length > config.large_pr_threshold;
|
|
220
|
+
if (isLargePr) {
|
|
221
|
+
console.error(
|
|
222
|
+
chalk.yellow(
|
|
223
|
+
`Large change set (${fileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
194
228
|
const changedFiles = [];
|
|
195
229
|
|
|
196
230
|
for (const file of fileList) {
|
|
@@ -204,11 +238,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
|
|
|
204
238
|
diff = await git('diff', '-U15', '--', path);
|
|
205
239
|
}
|
|
206
240
|
|
|
241
|
+
// Determine if we should include content for this file
|
|
242
|
+
const skipContent =
|
|
243
|
+
isLargePr ||
|
|
244
|
+
isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
|
|
245
|
+
status === 'A';
|
|
246
|
+
|
|
207
247
|
// Get current content from HEAD (before changes)
|
|
208
248
|
let content = '';
|
|
209
|
-
if (status !== 'A') {
|
|
249
|
+
if (!skipContent && status !== 'A') {
|
|
210
250
|
try {
|
|
211
|
-
|
|
251
|
+
const originalContent = await git('show', `HEAD:${oldPath}`);
|
|
252
|
+
|
|
253
|
+
// Check if content is binary
|
|
254
|
+
if (isBinary(originalContent)) {
|
|
255
|
+
console.error(chalk.gray(` Skipping binary content: ${path}`));
|
|
256
|
+
} else {
|
|
257
|
+
content = truncateContent(originalContent, maxLines);
|
|
258
|
+
}
|
|
212
259
|
} catch {
|
|
213
260
|
console.warn(
|
|
214
261
|
chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
|
|
@@ -216,21 +263,24 @@ export async function runUncommittedReview(mode = 'unstaged') {
|
|
|
216
263
|
}
|
|
217
264
|
}
|
|
218
265
|
|
|
219
|
-
// Truncate content
|
|
220
|
-
content = truncateContent(content);
|
|
221
|
-
|
|
222
266
|
// For deleted files, truncate the diff as well
|
|
223
267
|
if (status === 'D') {
|
|
224
|
-
diff = truncateContent(diff);
|
|
268
|
+
diff = truncateContent(diff, maxLines);
|
|
225
269
|
}
|
|
226
270
|
|
|
227
|
-
|
|
271
|
+
// Build the file object - only include content if we have it
|
|
272
|
+
const fileObj = {
|
|
228
273
|
path: path,
|
|
229
274
|
status: status,
|
|
230
275
|
diff: diff,
|
|
231
|
-
content: content,
|
|
232
276
|
...((status === 'R' || status === 'C') && { old_path: oldPath }),
|
|
233
|
-
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (content) {
|
|
280
|
+
fileObj.content = content;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
changedFiles.push(fileObj);
|
|
234
284
|
}
|
|
235
285
|
|
|
236
286
|
if (!nameStatusOutput.trim() && changedFiles.length === 0) {
|
|
@@ -312,9 +362,18 @@ export async function getContributors(diffRange, repoRootPath) {
|
|
|
312
362
|
* Main function to analyze local git changes and prepare review payload
|
|
313
363
|
* @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
|
|
314
364
|
* @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
|
|
365
|
+
* @param {Object} fileRulesConfig - File rules config from API (or defaults)
|
|
315
366
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
316
367
|
*/
|
|
317
|
-
export async function runLocalReview(
|
|
368
|
+
export async function runLocalReview(
|
|
369
|
+
targetBranch = null,
|
|
370
|
+
ignorePatterns = null,
|
|
371
|
+
fileRulesConfig = null
|
|
372
|
+
) {
|
|
373
|
+
// If no config provided, use empty config (no filtering applied)
|
|
374
|
+
const config = fileRulesConfig || {};
|
|
375
|
+
const maxLines = config.max_lines ?? 2000;
|
|
376
|
+
|
|
318
377
|
try {
|
|
319
378
|
// 1. Get Repo URL, current branch name, commit hash, and repository root
|
|
320
379
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -454,7 +513,7 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
454
513
|
});
|
|
455
514
|
const fileList = parseNameStatus(nameStatusOutput);
|
|
456
515
|
|
|
457
|
-
// Filter out ignored files
|
|
516
|
+
// Filter out ignored files (user-specified patterns)
|
|
458
517
|
let filteredFileList = fileList;
|
|
459
518
|
let ignoredCount = 0;
|
|
460
519
|
if (ignorePatterns && ignorePatterns.length > 0) {
|
|
@@ -472,9 +531,38 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
472
531
|
console.error(chalk.gray(`Ignored ${ignoredCount} file(s) based on patterns\n`));
|
|
473
532
|
}
|
|
474
533
|
|
|
534
|
+
// Filter out binary/non-reviewable files (only if config has skip_extensions)
|
|
535
|
+
if (config.skip_extensions) {
|
|
536
|
+
let skippedCount = 0;
|
|
537
|
+
filteredFileList = filteredFileList.filter((file) => {
|
|
538
|
+
if (shouldSkip(file.path, config.skip_extensions)) {
|
|
539
|
+
skippedCount++;
|
|
540
|
+
console.error(chalk.gray(` Skipping binary: ${file.path}`));
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
if (skippedCount > 0) {
|
|
547
|
+
console.error(chalk.gray(`Skipped ${skippedCount} binary file(s)\n`));
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Check if this is a large PR (only if config has large_pr_threshold)
|
|
552
|
+
const isLargePr =
|
|
553
|
+
config.large_pr_threshold && filteredFileList.length > config.large_pr_threshold;
|
|
554
|
+
if (isLargePr) {
|
|
555
|
+
console.error(
|
|
556
|
+
chalk.yellow(
|
|
557
|
+
`Large PR detected (${filteredFileList.length} files > ${config.large_pr_threshold}). Sending diffs only.`
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
475
562
|
console.error(chalk.gray(`Collecting diffs for ${filteredFileList.length} file(s)...`));
|
|
476
563
|
|
|
477
564
|
const changedFiles = [];
|
|
565
|
+
|
|
478
566
|
for (const file of filteredFileList) {
|
|
479
567
|
const { status, path, oldPath } = file;
|
|
480
568
|
|
|
@@ -484,17 +572,30 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
484
572
|
cwd: repoRootPath,
|
|
485
573
|
});
|
|
486
574
|
|
|
487
|
-
//
|
|
575
|
+
// Determine if we should include content for this file
|
|
576
|
+
// Skip content for: large PRs, DIFF_ONLY files, deleted files, or added files
|
|
577
|
+
const skipContent =
|
|
578
|
+
isLargePr ||
|
|
579
|
+
isDiffOnly(path, config.diff_only_extensions, config.diff_only_files) ||
|
|
580
|
+
status === 'A';
|
|
581
|
+
|
|
582
|
+
// Get the original content from the base commit (unless we're skipping it)
|
|
488
583
|
let content = '';
|
|
489
|
-
if (status !== 'A') {
|
|
490
|
-
// Added files have no original content
|
|
584
|
+
if (!skipContent && status !== 'A') {
|
|
491
585
|
try {
|
|
492
586
|
const { stdout: originalContent } = await execa(
|
|
493
587
|
'git',
|
|
494
588
|
['show', `${mergeBase.trim()}:${oldPath}`],
|
|
495
589
|
{ cwd: repoRootPath }
|
|
496
590
|
);
|
|
497
|
-
|
|
591
|
+
|
|
592
|
+
// Check if content is binary
|
|
593
|
+
if (isBinary(originalContent)) {
|
|
594
|
+
console.error(chalk.gray(` Skipping binary content: ${path}`));
|
|
595
|
+
// Don't include content for binary files
|
|
596
|
+
} else {
|
|
597
|
+
content = truncateContent(originalContent, maxLines);
|
|
598
|
+
}
|
|
498
599
|
} catch {
|
|
499
600
|
// This can happen if a file was added and modified in the same branch
|
|
500
601
|
console.warn(
|
|
@@ -503,22 +604,26 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
|
|
|
503
604
|
}
|
|
504
605
|
}
|
|
505
606
|
|
|
506
|
-
// Truncate content
|
|
507
|
-
content = truncateContent(content);
|
|
508
|
-
|
|
509
607
|
// For deleted files, truncate the diff as well
|
|
510
608
|
let truncatedDiff = diff;
|
|
511
609
|
if (status === 'D') {
|
|
512
|
-
truncatedDiff = truncateContent(diff);
|
|
610
|
+
truncatedDiff = truncateContent(diff, maxLines);
|
|
513
611
|
}
|
|
514
612
|
|
|
515
|
-
|
|
613
|
+
// Build the file object - only include content if we have it
|
|
614
|
+
const fileObj = {
|
|
516
615
|
path: path,
|
|
517
616
|
status: status,
|
|
518
617
|
diff: truncatedDiff,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
618
|
+
...((status === 'R' || status === 'C') && { old_path: oldPath }),
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
// Only include content field if we have content
|
|
622
|
+
if (content) {
|
|
623
|
+
fileObj.content = content;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
changedFiles.push(fileObj);
|
|
522
627
|
}
|
|
523
628
|
|
|
524
629
|
// 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');
|