korekt-cli 0.11.2 → 0.13.1
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/config.js +0 -10
- package/src/formatter.js +6 -1
- package/src/index.js +125 -0
- package/src/index.test.js +195 -1
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -44,13 +44,3 @@ export function getApiEndpoint() {
|
|
|
44
44
|
export function setApiEndpoint(endpoint) {
|
|
45
45
|
config.set('apiEndpoint', endpoint);
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Get all configuration
|
|
50
|
-
*/
|
|
51
|
-
export function getConfig() {
|
|
52
|
-
return {
|
|
53
|
-
apiKey: getApiKey(),
|
|
54
|
-
apiEndpoint: getApiEndpoint(),
|
|
55
|
-
};
|
|
56
|
-
}
|
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/index.js
CHANGED
|
@@ -72,6 +72,75 @@ async function confirmAction(message) {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Handle skipped response from API (when integration mode prevents CLI review)
|
|
77
|
+
* @param {Object} response - Axios response object
|
|
78
|
+
* @param {Object} options - Command options (json flag, etc.)
|
|
79
|
+
* @param {Object} spinner - Ora spinner instance
|
|
80
|
+
* @returns {boolean} - True if response was skipped, false otherwise
|
|
81
|
+
*/
|
|
82
|
+
export function handleSkippedResponse(response, options, spinner) {
|
|
83
|
+
if (response.data.skipped) {
|
|
84
|
+
spinner.info(response.data.message || 'Review skipped.');
|
|
85
|
+
if (options.json) {
|
|
86
|
+
output(JSON.stringify(response.data, null, 2));
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Available Gemini models for code review
|
|
95
|
+
*/
|
|
96
|
+
const GEMINI_MODELS = [
|
|
97
|
+
{ value: 'gemini-2.5-pro', label: 'gemini-2.5-pro (high quality)' },
|
|
98
|
+
{ value: 'gemini-2.5-flash', label: 'gemini-2.5-flash (avoid, the worst quality)' },
|
|
99
|
+
{ value: 'gemini-3-pro-preview', label: 'gemini-3-pro-preview (experimental - the best model)' },
|
|
100
|
+
{
|
|
101
|
+
value: 'gemini-3-flash-preview',
|
|
102
|
+
label: 'gemini-3-flash-preview (experimental - the most efficient model)',
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Prompt user to select a Gemini model
|
|
108
|
+
* @returns {Promise<string>} - Selected model name
|
|
109
|
+
* @throws {Error} - If not running in interactive terminal
|
|
110
|
+
*/
|
|
111
|
+
async function selectModel() {
|
|
112
|
+
// Check if running in interactive terminal
|
|
113
|
+
if (!process.stdin.isTTY) {
|
|
114
|
+
log(chalk.red('Error: --model flag requires a value in non-interactive environments.'));
|
|
115
|
+
log(chalk.gray('Example: kk review --model=gemini-2.5-pro'));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rl = readline.createInterface({
|
|
120
|
+
input: process.stdin,
|
|
121
|
+
output: process.stderr,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
log(chalk.yellow('\nSelect Gemini model:\n'));
|
|
125
|
+
GEMINI_MODELS.forEach((model, index) => {
|
|
126
|
+
log(` ${index + 1}. ${model.label}`);
|
|
127
|
+
});
|
|
128
|
+
log('');
|
|
129
|
+
|
|
130
|
+
return new Promise((resolvePromise) => {
|
|
131
|
+
rl.question(chalk.bold('Enter number (1-4): '), (answer) => {
|
|
132
|
+
rl.close();
|
|
133
|
+
const num = parseInt(answer, 10);
|
|
134
|
+
if (num >= 1 && num <= GEMINI_MODELS.length) {
|
|
135
|
+
resolvePromise(GEMINI_MODELS[num - 1].value);
|
|
136
|
+
} else {
|
|
137
|
+
log(chalk.red('Invalid selection. Please run the command again and enter 1-4.'));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
75
144
|
/**
|
|
76
145
|
* Run the CI integration script to post comments
|
|
77
146
|
* @param {string} provider - CI provider (github, azure, bitbucket)
|
|
@@ -128,6 +197,8 @@ program
|
|
|
128
197
|
Examples:
|
|
129
198
|
$ kk review Review committed changes (auto-detect base)
|
|
130
199
|
$ kk review main Review changes against main branch
|
|
200
|
+
$ kk review --model Select Gemini model interactively
|
|
201
|
+
$ kk review -m gemini-2.5-pro Use specific model
|
|
131
202
|
$ kk stg --dry-run Preview staged changes review
|
|
132
203
|
$ kk diff Review unstaged changes
|
|
133
204
|
$ kk review main --json Output raw JSON (for CI/CD integration)
|
|
@@ -137,6 +208,7 @@ Common Options:
|
|
|
137
208
|
--dry-run Show payload without sending to API
|
|
138
209
|
--json Output raw API response as JSON
|
|
139
210
|
--comment Post review results as PR comments
|
|
211
|
+
-m, --model [name] Gemini model (picker if no value)
|
|
140
212
|
|
|
141
213
|
Configuration:
|
|
142
214
|
$ kk config --key YOUR_KEY
|
|
@@ -159,6 +231,7 @@ program
|
|
|
159
231
|
.option('--json', 'Output raw API response as JSON')
|
|
160
232
|
.option('--comment', 'Post review results as PR comments (auto-detects CI provider)')
|
|
161
233
|
.option('--post-ticket', 'Post review results to linked ticket (e.g., JIRA)')
|
|
234
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
162
235
|
.action(async (targetBranch, options) => {
|
|
163
236
|
const reviewTarget = targetBranch ? `against '${targetBranch}'` : '(auto-detecting fork point)';
|
|
164
237
|
|
|
@@ -179,6 +252,15 @@ program
|
|
|
179
252
|
process.exit(1);
|
|
180
253
|
}
|
|
181
254
|
|
|
255
|
+
// Handle model selection: if --model without value, show picker
|
|
256
|
+
let selectedModel = null;
|
|
257
|
+
if (options.model === true) {
|
|
258
|
+
selectedModel = await selectModel();
|
|
259
|
+
log(chalk.green(`Using model: ${selectedModel}\n`));
|
|
260
|
+
} else if (typeof options.model === 'string') {
|
|
261
|
+
selectedModel = options.model;
|
|
262
|
+
}
|
|
263
|
+
|
|
182
264
|
// Fetch file rules config from API
|
|
183
265
|
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
184
266
|
|
|
@@ -206,6 +288,11 @@ program
|
|
|
206
288
|
changed_files: payload.changed_files.map((file) => truncateFileData(file)),
|
|
207
289
|
};
|
|
208
290
|
|
|
291
|
+
// Add model if specified
|
|
292
|
+
if (selectedModel) {
|
|
293
|
+
displayPayload.model = selectedModel;
|
|
294
|
+
}
|
|
295
|
+
|
|
209
296
|
log(JSON.stringify(displayPayload, null, 2));
|
|
210
297
|
log(chalk.gray('\n💡 Run without --dry-run to send to API'));
|
|
211
298
|
log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
|
|
@@ -255,6 +342,11 @@ program
|
|
|
255
342
|
payload.post_to_ticket = true;
|
|
256
343
|
}
|
|
257
344
|
|
|
345
|
+
// Add model if specified
|
|
346
|
+
if (selectedModel) {
|
|
347
|
+
payload.model = selectedModel;
|
|
348
|
+
}
|
|
349
|
+
|
|
258
350
|
try {
|
|
259
351
|
const response = await axios.post(apiEndpoint, payload, {
|
|
260
352
|
headers: {
|
|
@@ -265,6 +357,12 @@ program
|
|
|
265
357
|
|
|
266
358
|
clearInterval(timer);
|
|
267
359
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
360
|
+
|
|
361
|
+
// Handle skipped response (integration mode prevents CLI review)
|
|
362
|
+
if (handleSkippedResponse(response, options, spinner)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
268
366
|
spinner.succeed(`Review completed in ${elapsed}s!`);
|
|
269
367
|
|
|
270
368
|
// Handle --comment flag: post results to PR
|
|
@@ -328,6 +426,7 @@ program
|
|
|
328
426
|
.description('Review staged changes (git diff --cached)')
|
|
329
427
|
.option('--dry-run', 'Show payload without sending to API')
|
|
330
428
|
.option('--json', 'Output raw API response as JSON')
|
|
429
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
331
430
|
.action(async (options) => {
|
|
332
431
|
log(chalk.blue.bold('🚀 Reviewing staged changes...'));
|
|
333
432
|
await reviewUncommitted('staged', options);
|
|
@@ -339,6 +438,7 @@ program
|
|
|
339
438
|
.description('Review unstaged changes (git diff)')
|
|
340
439
|
.option('--dry-run', 'Show payload without sending to API')
|
|
341
440
|
.option('--json', 'Output raw API response as JSON')
|
|
441
|
+
.option('-m, --model [name]', 'Gemini model to use (interactive picker if no value provided)')
|
|
342
442
|
.action(async (options) => {
|
|
343
443
|
log(chalk.blue.bold('🚀 Reviewing unstaged changes...'));
|
|
344
444
|
await reviewUncommitted('unstaged', options);
|
|
@@ -359,6 +459,15 @@ async function reviewUncommitted(mode, options) {
|
|
|
359
459
|
process.exit(1);
|
|
360
460
|
}
|
|
361
461
|
|
|
462
|
+
// Handle model selection: if --model without value, show picker
|
|
463
|
+
let selectedModel = null;
|
|
464
|
+
if (options.model === true) {
|
|
465
|
+
selectedModel = await selectModel();
|
|
466
|
+
log(chalk.green(`Using model: ${selectedModel}\n`));
|
|
467
|
+
} else if (typeof options.model === 'string') {
|
|
468
|
+
selectedModel = options.model;
|
|
469
|
+
}
|
|
470
|
+
|
|
362
471
|
// Fetch file rules config from API
|
|
363
472
|
const fileRulesConfig = await fetchFileRulesConfig(apiEndpoint, apiKey);
|
|
364
473
|
|
|
@@ -378,6 +487,11 @@ async function reviewUncommitted(mode, options) {
|
|
|
378
487
|
changed_files: payload.changed_files.map((file) => truncateFileData(file)),
|
|
379
488
|
};
|
|
380
489
|
|
|
490
|
+
// Add model if specified
|
|
491
|
+
if (selectedModel) {
|
|
492
|
+
displayPayload.model = selectedModel;
|
|
493
|
+
}
|
|
494
|
+
|
|
381
495
|
log(JSON.stringify(displayPayload, null, 2));
|
|
382
496
|
log(chalk.gray('\n💡 Run without --dry-run to send to API'));
|
|
383
497
|
log(chalk.gray('💡 Diffs and content are truncated in dry-run for readability'));
|
|
@@ -410,6 +524,11 @@ async function reviewUncommitted(mode, options) {
|
|
|
410
524
|
}
|
|
411
525
|
}
|
|
412
526
|
|
|
527
|
+
// Add model if specified
|
|
528
|
+
if (selectedModel) {
|
|
529
|
+
payload.model = selectedModel;
|
|
530
|
+
}
|
|
531
|
+
|
|
413
532
|
const spinner = ora('Submitting review to the AI...').start();
|
|
414
533
|
const startTime = Date.now();
|
|
415
534
|
|
|
@@ -428,6 +547,12 @@ async function reviewUncommitted(mode, options) {
|
|
|
428
547
|
|
|
429
548
|
clearInterval(timer);
|
|
430
549
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
550
|
+
|
|
551
|
+
// Handle skipped response (integration mode prevents CLI review)
|
|
552
|
+
if (handleSkippedResponse(response, options, spinner)) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
431
556
|
spinner.succeed(`Review completed in ${elapsed}s!`);
|
|
432
557
|
|
|
433
558
|
// Output results to stdout
|
package/src/index.test.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
truncateFileData,
|
|
4
|
+
formatErrorOutput,
|
|
5
|
+
detectCIProvider,
|
|
6
|
+
getPrUrl,
|
|
7
|
+
handleSkippedResponse,
|
|
8
|
+
} from './index.js';
|
|
3
9
|
|
|
4
10
|
describe('CLI JSON output mode', () => {
|
|
5
11
|
let stdoutSpy;
|
|
@@ -469,6 +475,194 @@ describe('--comment flag behavior', () => {
|
|
|
469
475
|
});
|
|
470
476
|
});
|
|
471
477
|
|
|
478
|
+
describe('--model flag behavior', () => {
|
|
479
|
+
describe('model selection logic', () => {
|
|
480
|
+
it('should treat --model without value as true (picker mode)', () => {
|
|
481
|
+
const options = { model: true };
|
|
482
|
+
|
|
483
|
+
// When --model is used without a value, Commander sets it to true
|
|
484
|
+
const shouldShowPicker = options.model === true;
|
|
485
|
+
const hasDirectValue = typeof options.model === 'string';
|
|
486
|
+
|
|
487
|
+
expect(shouldShowPicker).toBe(true);
|
|
488
|
+
expect(hasDirectValue).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should treat --model=value as string (direct mode)', () => {
|
|
492
|
+
const options = { model: 'gemini-2.5-flash' };
|
|
493
|
+
|
|
494
|
+
const shouldShowPicker = options.model === true;
|
|
495
|
+
const hasDirectValue = typeof options.model === 'string';
|
|
496
|
+
|
|
497
|
+
expect(shouldShowPicker).toBe(false);
|
|
498
|
+
expect(hasDirectValue).toBe(true);
|
|
499
|
+
expect(options.model).toBe('gemini-2.5-flash');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should not include model when flag is not used', () => {
|
|
503
|
+
const options = {};
|
|
504
|
+
|
|
505
|
+
const shouldIncludeModel = options.model !== undefined;
|
|
506
|
+
|
|
507
|
+
expect(shouldIncludeModel).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
describe('payload model field', () => {
|
|
512
|
+
it('should add model to payload when specified', () => {
|
|
513
|
+
const payload = {
|
|
514
|
+
repo_url: 'https://github.com/user/repo',
|
|
515
|
+
changed_files: [],
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const selectedModel = 'gemini-2.5-flash';
|
|
519
|
+
if (selectedModel) {
|
|
520
|
+
payload.model = selectedModel;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
expect(payload.model).toBe('gemini-2.5-flash');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should not add model to payload when not specified', () => {
|
|
527
|
+
const payload = {
|
|
528
|
+
repo_url: 'https://github.com/user/repo',
|
|
529
|
+
changed_files: [],
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const selectedModel = null;
|
|
533
|
+
if (selectedModel) {
|
|
534
|
+
payload.model = selectedModel;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
expect(payload.model).toBeUndefined();
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('available models', () => {
|
|
542
|
+
it('should have valid model values', () => {
|
|
543
|
+
const validModels = [
|
|
544
|
+
'gemini-2.5-pro',
|
|
545
|
+
'gemini-2.5-flash',
|
|
546
|
+
'gemini-3-pro-preview',
|
|
547
|
+
'gemini-3-flash-preview',
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
// Test that all expected models are valid Gemini model names
|
|
551
|
+
validModels.forEach((model) => {
|
|
552
|
+
expect(model).toMatch(/^gemini-/);
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe('skipped response handling', () => {
|
|
559
|
+
let stdoutSpy;
|
|
560
|
+
|
|
561
|
+
beforeEach(() => {
|
|
562
|
+
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
afterEach(() => {
|
|
566
|
+
vi.restoreAllMocks();
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('should return true and call spinner.info when response is skipped', () => {
|
|
570
|
+
const response = {
|
|
571
|
+
data: {
|
|
572
|
+
skipped: true,
|
|
573
|
+
reason: 'webhook_mode_active',
|
|
574
|
+
message: 'Review skipped due to webhook mode.',
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
const options = { json: false };
|
|
578
|
+
const spinner = { info: vi.fn() };
|
|
579
|
+
|
|
580
|
+
const result = handleSkippedResponse(response, options, spinner);
|
|
581
|
+
|
|
582
|
+
expect(result).toBe(true);
|
|
583
|
+
expect(spinner.info).toHaveBeenCalledWith('Review skipped due to webhook mode.');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('should return false when response is not skipped', () => {
|
|
587
|
+
const response = {
|
|
588
|
+
data: {
|
|
589
|
+
review: { issues: [], praises: [] },
|
|
590
|
+
summary: { total_issues: 0 },
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
const options = { json: false };
|
|
594
|
+
const spinner = { info: vi.fn() };
|
|
595
|
+
|
|
596
|
+
const result = handleSkippedResponse(response, options, spinner);
|
|
597
|
+
|
|
598
|
+
expect(result).toBe(false);
|
|
599
|
+
expect(spinner.info).not.toHaveBeenCalled();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should output JSON to stdout when json option is true and response is skipped', () => {
|
|
603
|
+
const response = {
|
|
604
|
+
data: {
|
|
605
|
+
skipped: true,
|
|
606
|
+
reason: 'local_reviews_disabled',
|
|
607
|
+
message: 'Local reviews disabled.',
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
const options = { json: true };
|
|
611
|
+
const spinner = { info: vi.fn() };
|
|
612
|
+
|
|
613
|
+
handleSkippedResponse(response, options, spinner);
|
|
614
|
+
|
|
615
|
+
expect(stdoutSpy).toHaveBeenCalledWith(JSON.stringify(response.data, null, 2) + '\n');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('should not output JSON when json option is false', () => {
|
|
619
|
+
const response = {
|
|
620
|
+
data: {
|
|
621
|
+
skipped: true,
|
|
622
|
+
reason: 'reviews_disabled',
|
|
623
|
+
message: 'Reviews disabled.',
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
const options = { json: false };
|
|
627
|
+
const spinner = { info: vi.fn() };
|
|
628
|
+
|
|
629
|
+
handleSkippedResponse(response, options, spinner);
|
|
630
|
+
|
|
631
|
+
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('should use default message when response message is empty', () => {
|
|
635
|
+
const response = {
|
|
636
|
+
data: {
|
|
637
|
+
skipped: true,
|
|
638
|
+
reason: 'webhook_mode_active',
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
const options = { json: false };
|
|
642
|
+
const spinner = { info: vi.fn() };
|
|
643
|
+
|
|
644
|
+
handleSkippedResponse(response, options, spinner);
|
|
645
|
+
|
|
646
|
+
expect(spinner.info).toHaveBeenCalledWith('Review skipped.');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should handle all skip reason types', () => {
|
|
650
|
+
const reasons = ['webhook_mode_active', 'reviews_disabled', 'local_reviews_disabled'];
|
|
651
|
+
const spinner = { info: vi.fn() };
|
|
652
|
+
|
|
653
|
+
reasons.forEach((reason) => {
|
|
654
|
+
const response = {
|
|
655
|
+
data: { skipped: true, reason, message: `Skipped: ${reason}` },
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const result = handleSkippedResponse(response, { json: false }, spinner);
|
|
659
|
+
expect(result).toBe(true);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
expect(spinner.info).toHaveBeenCalledTimes(3);
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
472
666
|
describe('getPrUrl', () => {
|
|
473
667
|
const originalEnv = process.env;
|
|
474
668
|
|