korekt-cli 0.11.0 → 0.12.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/formatter.js +6 -1
- package/src/git-logic.js +16 -0
- package/src/index.js +125 -2
- package/src/index.test.js +110 -0
- package/src/utils.js +14 -6
package/package.json
CHANGED
package/src/formatter.js
CHANGED
|
@@ -76,7 +76,7 @@ function toAbsolutePath(filePath) {
|
|
|
76
76
|
* @param {Object} data - The API response data
|
|
77
77
|
*/
|
|
78
78
|
export function formatReviewOutput(data) {
|
|
79
|
-
const { review, summary, change_classification: changeClassification } = data.data;
|
|
79
|
+
const { review, summary, change_classification: changeClassification, model } = data.data;
|
|
80
80
|
|
|
81
81
|
console.log(chalk.bold.blue('🤖 Automated Code Review Results\n'));
|
|
82
82
|
|
|
@@ -162,4 +162,9 @@ export function formatReviewOutput(data) {
|
|
|
162
162
|
console.log(); // Add a blank line for spacing
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
// Footer with model info
|
|
167
|
+
if (model) {
|
|
168
|
+
console.log(chalk.gray(`Analyzed with ${model}`));
|
|
169
|
+
}
|
|
165
170
|
}
|
package/src/git-logic.js
CHANGED
|
@@ -215,6 +215,14 @@ export async function runUncommittedReview(mode = 'unstaged', fileRulesConfig =
|
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
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
|
+
|
|
218
226
|
// Check if this is a large change set (only if config has large_pr_threshold)
|
|
219
227
|
const isLargePr = config.large_pr_threshold && fileList.length > config.large_pr_threshold;
|
|
220
228
|
if (isLargePr) {
|
|
@@ -548,6 +556,14 @@ export async function runLocalReview(
|
|
|
548
556
|
}
|
|
549
557
|
}
|
|
550
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
|
+
|
|
551
567
|
// Check if this is a large PR (only if config has large_pr_threshold)
|
|
552
568
|
const isLargePr =
|
|
553
569
|
config.large_pr_threshold && filteredFileList.length > config.large_pr_threshold;
|
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)
|
|
@@ -50,6 +72,57 @@ async function confirmAction(message) {
|
|
|
50
72
|
});
|
|
51
73
|
}
|
|
52
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Available Gemini models for code review
|
|
77
|
+
*/
|
|
78
|
+
const GEMINI_MODELS = [
|
|
79
|
+
{ value: 'gemini-2.5-pro', label: 'gemini-2.5-pro (high quality)' },
|
|
80
|
+
{ value: 'gemini-2.5-flash', label: 'gemini-2.5-flash (avoid, the worst quality)' },
|
|
81
|
+
{ value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview (experimental - the best model)' },
|
|
82
|
+
{
|
|
83
|
+
value: 'gemini-3-flash-preview',
|
|
84
|
+
label: 'gemini-3-flash-preview (experimental - the most efficient model)',
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Prompt user to select a Gemini model
|
|
90
|
+
* @returns {Promise<string>} - Selected model name
|
|
91
|
+
* @throws {Error} - If not running in interactive terminal
|
|
92
|
+
*/
|
|
93
|
+
async function selectModel() {
|
|
94
|
+
// Check if running in interactive terminal
|
|
95
|
+
if (!process.stdin.isTTY) {
|
|
96
|
+
log(chalk.red('Error: --model flag requires a value in non-interactive environments.'));
|
|
97
|
+
log(chalk.gray('Example: kk review --model=gemini-2.5-pro'));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rl = readline.createInterface({
|
|
102
|
+
input: process.stdin,
|
|
103
|
+
output: process.stderr,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
log(chalk.yellow('\nSelect Gemini model:\n'));
|
|
107
|
+
GEMINI_MODELS.forEach((model, index) => {
|
|
108
|
+
log(` ${index + 1}. ${model.label}`);
|
|
109
|
+
});
|
|
110
|
+
log('');
|
|
111
|
+
|
|
112
|
+
return new Promise((resolvePromise) => {
|
|
113
|
+
rl.question(chalk.bold('Enter number (1-4): '), (answer) => {
|
|
114
|
+
rl.close();
|
|
115
|
+
const num = parseInt(answer, 10);
|
|
116
|
+
if (num >= 1 && num <= GEMINI_MODELS.length) {
|
|
117
|
+
resolvePromise(GEMINI_MODELS[num - 1].value);
|
|
118
|
+
} else {
|
|
119
|
+
log(chalk.red('Invalid selection. Please run the command again and enter 1-4.'));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
/**
|
|
54
127
|
* Run the CI integration script to post comments
|
|
55
128
|
* @param {string} provider - CI provider (github, azure, bitbucket)
|
|
@@ -106,6 +179,8 @@ program
|
|
|
106
179
|
Examples:
|
|
107
180
|
$ kk review Review committed changes (auto-detect base)
|
|
108
181
|
$ kk review main Review changes against main branch
|
|
182
|
+
$ kk review --model Select Gemini model interactively
|
|
183
|
+
$ kk review -m gemini-2.5-pro Use specific model
|
|
109
184
|
$ kk stg --dry-run Preview staged changes review
|
|
110
185
|
$ kk diff Review unstaged changes
|
|
111
186
|
$ kk review main --json Output raw JSON (for CI/CD integration)
|
|
@@ -115,6 +190,7 @@ Common Options:
|
|
|
115
190
|
--dry-run Show payload without sending to API
|
|
116
191
|
--json Output raw API response as JSON
|
|
117
192
|
--comment Post review results as PR comments
|
|
193
|
+
-m, --model [name] Gemini model (picker if no value)
|
|
118
194
|
|
|
119
195
|
Configuration:
|
|
120
196
|
$ kk config --key YOUR_KEY
|
|
@@ -137,6 +213,7 @@ program
|
|
|
137
213
|
.option('--json', 'Output raw API response as JSON')
|
|
138
214
|
.option('--comment', 'Post review results as PR comments (auto-detects CI provider)')
|
|
139
215
|
.option('--post-ticket', 'Post review results to linked ticket (e.g., JIRA)')
|
|
216
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
140
217
|
.action(async (targetBranch, options) => {
|
|
141
218
|
const reviewTarget = targetBranch ? `against '${targetBranch}'` : '(auto-detecting fork point)';
|
|
142
219
|
|
|
@@ -157,8 +234,20 @@ program
|
|
|
157
234
|
process.exit(1);
|
|
158
235
|
}
|
|
159
236
|
|
|
237
|
+
// Handle model selection: if --model without value, show picker
|
|
238
|
+
let selectedModel = null;
|
|
239
|
+
if (options.model === true) {
|
|
240
|
+
selectedModel = await selectModel();
|
|
241
|
+
log(chalk.green(`Using model: ${selectedModel}\n`));
|
|
242
|
+
} else if (typeof options.model === 'string') {
|
|
243
|
+
selectedModel = options.model;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Fetch file rules config from API
|
|
247
|
+
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
248
|
+
|
|
160
249
|
// Gather all data using our git logic module
|
|
161
|
-
const payload = await runLocalReview(targetBranch, options.ignore);
|
|
250
|
+
const payload = await runLocalReview(targetBranch, options.ignore, fileRulesConfig);
|
|
162
251
|
|
|
163
252
|
if (!payload) {
|
|
164
253
|
log(chalk.red('Could not proceed with review due to errors during analysis.'));
|
|
@@ -181,6 +270,11 @@ program
|
|
|
181
270
|
changed_files: payload.changed_files.map((file) => truncateFileData(file)),
|
|
182
271
|
};
|
|
183
272
|
|
|
273
|
+
// Add model if specified
|
|
274
|
+
if (selectedModel) {
|
|
275
|
+
displayPayload.model = selectedModel;
|
|
276
|
+
}
|
|
277
|
+
|
|
184
278
|
log(JSON.stringify(displayPayload, null, 2));
|
|
185
279
|
log(chalk.gray('\n💡 Run without --dry-run to send to API'));
|
|
186
280
|
log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
|
|
@@ -230,6 +324,11 @@ program
|
|
|
230
324
|
payload.post_to_ticket = true;
|
|
231
325
|
}
|
|
232
326
|
|
|
327
|
+
// Add model if specified
|
|
328
|
+
if (selectedModel) {
|
|
329
|
+
payload.model = selectedModel;
|
|
330
|
+
}
|
|
331
|
+
|
|
233
332
|
try {
|
|
234
333
|
const response = await axios.post(apiEndpoint, payload, {
|
|
235
334
|
headers: {
|
|
@@ -303,6 +402,7 @@ program
|
|
|
303
402
|
.description('Review staged changes (git diff --cached)')
|
|
304
403
|
.option('--dry-run', 'Show payload without sending to API')
|
|
305
404
|
.option('--json', 'Output raw API response as JSON')
|
|
405
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
306
406
|
.action(async (options) => {
|
|
307
407
|
log(chalk.blue.bold('🚀 Reviewing staged changes...'));
|
|
308
408
|
await reviewUncommitted('staged', options);
|
|
@@ -314,6 +414,7 @@ program
|
|
|
314
414
|
.description('Review unstaged changes (git diff)')
|
|
315
415
|
.option('--dry-run', 'Show payload without sending to API')
|
|
316
416
|
.option('--json', 'Output raw API response as JSON')
|
|
417
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
317
418
|
.action(async (options) => {
|
|
318
419
|
log(chalk.blue.bold('🚀 Reviewing unstaged changes...'));
|
|
319
420
|
await reviewUncommitted('unstaged', options);
|
|
@@ -334,8 +435,20 @@ async function reviewUncommitted(mode, options) {
|
|
|
334
435
|
process.exit(1);
|
|
335
436
|
}
|
|
336
437
|
|
|
438
|
+
// Handle model selection: if --model without value, show picker
|
|
439
|
+
let selectedModel = null;
|
|
440
|
+
if (options.model === true) {
|
|
441
|
+
selectedModel = await selectModel();
|
|
442
|
+
log(chalk.green(`Using model: ${selectedModel}\n`));
|
|
443
|
+
} else if (typeof options.model === 'string') {
|
|
444
|
+
selectedModel = options.model;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Fetch file rules config from API
|
|
448
|
+
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
449
|
+
|
|
337
450
|
const { runUncommittedReview } = await import('./git-logic.js');
|
|
338
|
-
const payload = await runUncommittedReview(mode);
|
|
451
|
+
const payload = await runUncommittedReview(mode, fileRulesConfig);
|
|
339
452
|
|
|
340
453
|
if (!payload) {
|
|
341
454
|
log(chalk.red('No changes found or error occurred during analysis.'));
|
|
@@ -350,6 +463,11 @@ async function reviewUncommitted(mode, options) {
|
|
|
350
463
|
changed_files: payload.changed_files.map((file) => truncateFileData(file)),
|
|
351
464
|
};
|
|
352
465
|
|
|
466
|
+
// Add model if specified
|
|
467
|
+
if (selectedModel) {
|
|
468
|
+
displayPayload.model = selectedModel;
|
|
469
|
+
}
|
|
470
|
+
|
|
353
471
|
log(JSON.stringify(displayPayload, null, 2));
|
|
354
472
|
log(chalk.gray('\n💡 Run without --dry-run to send to API'));
|
|
355
473
|
log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
|
|
@@ -382,6 +500,11 @@ async function reviewUncommitted(mode, options) {
|
|
|
382
500
|
}
|
|
383
501
|
}
|
|
384
502
|
|
|
503
|
+
// Add model if specified
|
|
504
|
+
if (selectedModel) {
|
|
505
|
+
payload.model = selectedModel;
|
|
506
|
+
}
|
|
507
|
+
|
|
385
508
|
const spinner = ora('Submitting review to the AI...').start();
|
|
386
509
|
const startTime = Date.now();
|
|
387
510
|
|
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', () => {
|
|
@@ -439,6 +469,86 @@ describe('--comment flag behavior', () => {
|
|
|
439
469
|
});
|
|
440
470
|
});
|
|
441
471
|
|
|
472
|
+
describe('--model flag behavior', () => {
|
|
473
|
+
describe('model selection logic', () => {
|
|
474
|
+
it('should treat --model without value as true (picker mode)', () => {
|
|
475
|
+
const options = { model: true };
|
|
476
|
+
|
|
477
|
+
// When --model is used without a value, Commander sets it to true
|
|
478
|
+
const shouldShowPicker = options.model === true;
|
|
479
|
+
const hasDirectValue = typeof options.model === 'string';
|
|
480
|
+
|
|
481
|
+
expect(shouldShowPicker).toBe(true);
|
|
482
|
+
expect(hasDirectValue).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should treat --model=value as string (direct mode)', () => {
|
|
486
|
+
const options = { model: 'gemini-2.5-flash' };
|
|
487
|
+
|
|
488
|
+
const shouldShowPicker = options.model === true;
|
|
489
|
+
const hasDirectValue = typeof options.model === 'string';
|
|
490
|
+
|
|
491
|
+
expect(shouldShowPicker).toBe(false);
|
|
492
|
+
expect(hasDirectValue).toBe(true);
|
|
493
|
+
expect(options.model).toBe('gemini-2.5-flash');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should not include model when flag is not used', () => {
|
|
497
|
+
const options = {};
|
|
498
|
+
|
|
499
|
+
const shouldIncludeModel = options.model !== undefined;
|
|
500
|
+
|
|
501
|
+
expect(shouldIncludeModel).toBe(false);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe('payload model field', () => {
|
|
506
|
+
it('should add model to payload when specified', () => {
|
|
507
|
+
const payload = {
|
|
508
|
+
repo_url: 'https://github.com/user/repo',
|
|
509
|
+
changed_files: [],
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const selectedModel = 'gemini-2.5-flash';
|
|
513
|
+
if (selectedModel) {
|
|
514
|
+
payload.model = selectedModel;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
expect(payload.model).toBe('gemini-2.5-flash');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should not add model to payload when not specified', () => {
|
|
521
|
+
const payload = {
|
|
522
|
+
repo_url: 'https://github.com/user/repo',
|
|
523
|
+
changed_files: [],
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const selectedModel = null;
|
|
527
|
+
if (selectedModel) {
|
|
528
|
+
payload.model = selectedModel;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
expect(payload.model).toBeUndefined();
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
describe('available models', () => {
|
|
536
|
+
it('should have valid model values', () => {
|
|
537
|
+
const validModels = [
|
|
538
|
+
'gemini-2.5-pro',
|
|
539
|
+
'gemini-2.5-flash',
|
|
540
|
+
'gemini-3-pro-preview',
|
|
541
|
+
'gemini-3-flash-preview',
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
// Test that all expected models are valid Gemini model names
|
|
545
|
+
validModels.forEach((model) => {
|
|
546
|
+
expect(model).toMatch(/^gemini-/);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
442
552
|
describe('getPrUrl', () => {
|
|
443
553
|
const originalEnv = process.env;
|
|
444
554
|
|
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
|
/**
|