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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korekt-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- return {
76
+ const result = {
77
77
  path: file.path,
78
78
  status: file.status,
79
79
  ...(file.old_path && { old_path: file.old_path }),
80
- diff:
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
- content:
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
  /**