korekt-cli 0.3.0 → 0.4.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/README.md +1 -1
- package/package.json +18 -2
- package/src/config.js +1 -1
- package/src/formatter.js +11 -9
- package/src/git-logic.js +82 -34
- package/src/git-logic.test.js +35 -16
- package/src/index.js +67 -37
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/korekt-cli)
|
|
5
5
|
[](https://www.npmjs.com/package/korekt-cli)
|
|
6
6
|
|
|
7
|
-
AI-powered code review CLI - Keep your kode korekt
|
|
7
|
+
AI-powered code review CLI - Keep your kode korekt
|
|
8
8
|
|
|
9
9
|
`kk` integrates seamlessly with your local Git workflow to provide intelligent code reviews powered by AI.
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "korekt-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "AI-powered code review CLI - Keep your kode korekt",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,18 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "vitest",
|
|
13
13
|
"test:watch": "vitest --watch",
|
|
14
|
-
"test:coverage": "vitest --coverage"
|
|
14
|
+
"test:coverage": "vitest --coverage",
|
|
15
|
+
"lint": "eslint src/**/*.js",
|
|
16
|
+
"lint:fix": "eslint src/**/*.js --fix",
|
|
17
|
+
"format": "prettier --write \"src/**/*.js\"",
|
|
18
|
+
"format:check": "prettier --check \"src/**/*.js\"",
|
|
19
|
+
"prepare": "husky"
|
|
20
|
+
},
|
|
21
|
+
"lint-staged": {
|
|
22
|
+
"src/**/*.js": [
|
|
23
|
+
"eslint --fix",
|
|
24
|
+
"prettier --write"
|
|
25
|
+
]
|
|
15
26
|
},
|
|
16
27
|
"keywords": [
|
|
17
28
|
"code-review",
|
|
@@ -45,6 +56,11 @@
|
|
|
45
56
|
"ora": "^9.0.0"
|
|
46
57
|
},
|
|
47
58
|
"devDependencies": {
|
|
59
|
+
"@eslint/js": "^9.38.0",
|
|
60
|
+
"eslint": "^9.38.0",
|
|
61
|
+
"husky": "^9.1.7",
|
|
62
|
+
"lint-staged": "^16.2.6",
|
|
63
|
+
"prettier": "^3.6.2",
|
|
48
64
|
"vitest": "^3.2.4"
|
|
49
65
|
}
|
|
50
66
|
}
|
package/src/config.js
CHANGED
package/src/formatter.js
CHANGED
|
@@ -39,9 +39,7 @@ const CATEGORY_ICONS = {
|
|
|
39
39
|
*/
|
|
40
40
|
function formatCategory(category) {
|
|
41
41
|
if (!category) return '';
|
|
42
|
-
return category
|
|
43
|
-
.replace(/_/g, ' ')
|
|
44
|
-
.replace(/\b\w/g, char => char.toUpperCase());
|
|
42
|
+
return category.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/**
|
|
@@ -52,7 +50,7 @@ function getGitRoot() {
|
|
|
52
50
|
try {
|
|
53
51
|
const { stdout } = execaSync('git', ['rev-parse', '--show-toplevel']);
|
|
54
52
|
return stdout.trim();
|
|
55
|
-
} catch
|
|
53
|
+
} catch {
|
|
56
54
|
// Fallback to current working directory if not in a git repo
|
|
57
55
|
return process.cwd();
|
|
58
56
|
}
|
|
@@ -83,11 +81,12 @@ export function formatReviewOutput(data) {
|
|
|
83
81
|
// --- Praises Section ---
|
|
84
82
|
if (review && review.praises && review.praises.length > 0) {
|
|
85
83
|
console.log(chalk.bold.magenta(`✨ Praises (${summary.total_praises})`));
|
|
86
|
-
review.praises.forEach(praise => {
|
|
87
|
-
const categoryIcon = CATEGORY_ICONS[praise.category] || CATEGORY_ICONS.default;
|
|
84
|
+
review.praises.forEach((praise) => {
|
|
88
85
|
const formattedCategory = formatCategory(praise.category);
|
|
89
86
|
const absolutePath = toAbsolutePath(praise.file_path);
|
|
90
|
-
console.log(
|
|
87
|
+
console.log(
|
|
88
|
+
` ✅ ${chalk.green.bold(formattedCategory)} in ${absolutePath}:${praise.line_number}`
|
|
89
|
+
);
|
|
91
90
|
console.log(` ${praise.message}\n`);
|
|
92
91
|
});
|
|
93
92
|
}
|
|
@@ -99,7 +98,7 @@ export function formatReviewOutput(data) {
|
|
|
99
98
|
// Severity Summary Table
|
|
100
99
|
console.log(chalk.underline('Severity Count:'));
|
|
101
100
|
const severities = ['critical', 'high', 'medium', 'low'];
|
|
102
|
-
severities.forEach(severity => {
|
|
101
|
+
severities.forEach((severity) => {
|
|
103
102
|
const count = summary[severity] || 0;
|
|
104
103
|
if (count > 0) {
|
|
105
104
|
const icon = SEVERITY_ICONS[severity];
|
|
@@ -128,7 +127,10 @@ export function formatReviewOutput(data) {
|
|
|
128
127
|
if (issue.suggested_fix) {
|
|
129
128
|
console.log(chalk.bold('\n💡 Suggested Fix:'));
|
|
130
129
|
// Indent the suggested fix for readability
|
|
131
|
-
const indentedFix = issue.suggested_fix
|
|
130
|
+
const indentedFix = issue.suggested_fix
|
|
131
|
+
.split('\n')
|
|
132
|
+
.map((line) => ` ${line}`)
|
|
133
|
+
.join('\n');
|
|
132
134
|
console.log(chalk.green(indentedFix));
|
|
133
135
|
}
|
|
134
136
|
|
package/src/git-logic.js
CHANGED
|
@@ -28,26 +28,33 @@ export function truncateContent(content, maxLines = 2000) {
|
|
|
28
28
|
*/
|
|
29
29
|
export function normalizeRepoUrl(url) {
|
|
30
30
|
// Handle Azure DevOps SSH format: git@ssh.dev.azure.com:v3/org/project/repo
|
|
31
|
-
const azureDevOpsSshMatch = url.match(/git@ssh\.dev\.azure\.com:v3\/([
|
|
31
|
+
const azureDevOpsSshMatch = url.match(/git@ssh\.dev\.azure\.com:v3\/([^/]+)\/([^/]+)\/(.+)/);
|
|
32
32
|
if (azureDevOpsSshMatch) {
|
|
33
33
|
const [, org, project, repo] = azureDevOpsSshMatch;
|
|
34
34
|
return `https://dev.azure.com/${org}/${project}/_git/${repo}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
// Handle GitHub SSH format: git@github.com:user/repo.git
|
|
38
|
-
const githubSshMatch = url.match(/git@github\.com:([
|
|
38
|
+
const githubSshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
39
39
|
if (githubSshMatch) {
|
|
40
40
|
const [, user, repo] = githubSshMatch;
|
|
41
41
|
return `https://github.com/${user}/${repo}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// Handle GitLab SSH format: git@gitlab.com:user/repo.git
|
|
45
|
-
const gitlabSshMatch = url.match(/git@gitlab\.com:([
|
|
45
|
+
const gitlabSshMatch = url.match(/git@gitlab\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
46
46
|
if (gitlabSshMatch) {
|
|
47
47
|
const [, user, repo] = gitlabSshMatch;
|
|
48
48
|
return `https://gitlab.com/${user}/${repo}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// Handle Bitbucket SSH format: git@bitbucket.org:user/repo.git
|
|
52
|
+
const bitbucketSshMatch = url.match(/git@bitbucket\.org:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
53
|
+
if (bitbucketSshMatch) {
|
|
54
|
+
const [, user, repo] = bitbucketSshMatch;
|
|
55
|
+
return `https://bitbucket.org/${user}/${repo}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
51
58
|
// If already HTTPS or other format, return as-is (possibly removing .git suffix)
|
|
52
59
|
return url.replace(/\.git$/, '');
|
|
53
60
|
}
|
|
@@ -69,11 +76,11 @@ export function shouldIgnoreFile(filePath, patterns) {
|
|
|
69
76
|
// Replace * with [^/]* (matches anything except /)
|
|
70
77
|
// Replace ** with .* (matches anything including /)
|
|
71
78
|
let regexPattern = pattern
|
|
72
|
-
.replace(/\./g, '\\.')
|
|
73
|
-
.replace(/\*\*/g, '___DOUBLESTAR___')
|
|
74
|
-
.replace(/\*/g, '[^/]*')
|
|
75
|
-
.replace(/___DOUBLESTAR___/g, '.*')
|
|
76
|
-
.replace(/\?/g, '.');
|
|
79
|
+
.replace(/\./g, '\\.') // Escape dots
|
|
80
|
+
.replace(/\*\*/g, '___DOUBLESTAR___') // Temporarily replace **
|
|
81
|
+
.replace(/\*/g, '[^/]*') // Replace single * with [^/]*
|
|
82
|
+
.replace(/___DOUBLESTAR___/g, '.*') // Replace ** with .*
|
|
83
|
+
.replace(/\?/g, '.'); // Replace ? with .
|
|
77
84
|
|
|
78
85
|
// Handle leading **/ pattern - make it optional so it matches both with and without directory prefix
|
|
79
86
|
// For example, **/*.sql should match both "file.sql" and "dir/file.sql"
|
|
@@ -133,7 +140,11 @@ export function parseNameStatus(output) {
|
|
|
133
140
|
* @param {string|null} ticketSystem - The ticket system to use (jira or ado), or null to skip ticket extraction
|
|
134
141
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
135
142
|
*/
|
|
136
|
-
export async function runUncommittedReview(
|
|
143
|
+
export async function runUncommittedReview(
|
|
144
|
+
mode = 'all',
|
|
145
|
+
_ticketSystem = null,
|
|
146
|
+
includeUntracked = false
|
|
147
|
+
) {
|
|
137
148
|
try {
|
|
138
149
|
// 1. Get Repo URL and current branch name
|
|
139
150
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -164,12 +175,19 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
164
175
|
// Handle untracked files if requested
|
|
165
176
|
if (includeUntracked) {
|
|
166
177
|
console.log(chalk.gray('Analyzing untracked files...'));
|
|
167
|
-
const { stdout: untrackedFilesOutput } = await execa('git', [
|
|
178
|
+
const { stdout: untrackedFilesOutput } = await execa('git', [
|
|
179
|
+
'ls-files',
|
|
180
|
+
'--others',
|
|
181
|
+
'--exclude-standard',
|
|
182
|
+
]);
|
|
168
183
|
const untrackedFiles = untrackedFilesOutput.split('\n').filter(Boolean);
|
|
169
184
|
|
|
170
185
|
for (const file of untrackedFiles) {
|
|
171
186
|
const content = fs.readFileSync(file, 'utf-8');
|
|
172
|
-
const diff = content
|
|
187
|
+
const diff = content
|
|
188
|
+
.split('\n')
|
|
189
|
+
.map((line) => `+${line}`)
|
|
190
|
+
.join('\n');
|
|
173
191
|
changedFiles.push({
|
|
174
192
|
path: file,
|
|
175
193
|
status: 'A', // Untracked files are always additions
|
|
@@ -182,8 +200,8 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
182
200
|
}
|
|
183
201
|
|
|
184
202
|
// Deduplicate file list before processing diffs
|
|
185
|
-
const processedPaths = new Set(changedFiles.map(f => f.path));
|
|
186
|
-
const uniqueFileList = fileList.filter(file => !processedPaths.has(file.path));
|
|
203
|
+
const processedPaths = new Set(changedFiles.map((f) => f.path));
|
|
204
|
+
const uniqueFileList = fileList.filter((file) => !processedPaths.has(file.path));
|
|
187
205
|
|
|
188
206
|
for (const file of uniqueFileList) {
|
|
189
207
|
const { status, path, oldPath } = file;
|
|
@@ -199,7 +217,13 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
199
217
|
} else {
|
|
200
218
|
// For 'all', try staged first, then unstaged
|
|
201
219
|
try {
|
|
202
|
-
const { stdout: stagedDiff } = await execa('git', [
|
|
220
|
+
const { stdout: stagedDiff } = await execa('git', [
|
|
221
|
+
'diff',
|
|
222
|
+
'--cached',
|
|
223
|
+
'-U15',
|
|
224
|
+
'--',
|
|
225
|
+
path,
|
|
226
|
+
]);
|
|
203
227
|
if (stagedDiff) {
|
|
204
228
|
diff = stagedDiff;
|
|
205
229
|
} else {
|
|
@@ -218,8 +242,10 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
218
242
|
try {
|
|
219
243
|
const { stdout: headContent } = await execa('git', ['show', `HEAD:${oldPath}`]);
|
|
220
244
|
content = headContent;
|
|
221
|
-
} catch
|
|
222
|
-
console.warn(
|
|
245
|
+
} catch {
|
|
246
|
+
console.warn(
|
|
247
|
+
chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
|
|
248
|
+
);
|
|
223
249
|
}
|
|
224
250
|
}
|
|
225
251
|
|
|
@@ -268,7 +294,11 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
268
294
|
* @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
|
|
269
295
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
270
296
|
*/
|
|
271
|
-
export async function runLocalReview(
|
|
297
|
+
export async function runLocalReview(
|
|
298
|
+
targetBranch = null,
|
|
299
|
+
_ticketSystem = null,
|
|
300
|
+
ignorePatterns = null
|
|
301
|
+
) {
|
|
272
302
|
try {
|
|
273
303
|
// 1. Get Repo URL, current branch name, and repository root
|
|
274
304
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -285,7 +315,7 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
285
315
|
// Check if the branch exists locally
|
|
286
316
|
try {
|
|
287
317
|
await execa('git', ['rev-parse', '--verify', targetBranch]);
|
|
288
|
-
} catch
|
|
318
|
+
} catch {
|
|
289
319
|
console.error(chalk.red(`Branch '${targetBranch}' does not exist locally.`));
|
|
290
320
|
console.error(chalk.gray(`Please check out the branch first or specify a different one.`));
|
|
291
321
|
return null;
|
|
@@ -299,8 +329,10 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
299
329
|
// If fetch succeeded, use the remote-tracking branch for comparison
|
|
300
330
|
// This is safer as it doesn't modify the user's local branch
|
|
301
331
|
targetBranchRef = `origin/${targetBranch}`;
|
|
302
|
-
console.log(
|
|
303
|
-
|
|
332
|
+
console.log(
|
|
333
|
+
chalk.gray(`Using remote-tracking branch 'origin/${targetBranch}' for comparison.`)
|
|
334
|
+
);
|
|
335
|
+
} catch {
|
|
304
336
|
console.warn(chalk.yellow(`Could not fetch remote branch 'origin/${targetBranch}'.`));
|
|
305
337
|
console.warn(chalk.gray(`Proceeding with local branch '${targetBranch}' for comparison.`));
|
|
306
338
|
// targetBranchRef stays as targetBranch (local branch)
|
|
@@ -313,7 +345,12 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
313
345
|
if (!targetBranch) {
|
|
314
346
|
try {
|
|
315
347
|
// Use git reflog to find where the branch was created
|
|
316
|
-
const { stdout: reflog } = await execa('git', [
|
|
348
|
+
const { stdout: reflog } = await execa('git', [
|
|
349
|
+
'reflog',
|
|
350
|
+
'show',
|
|
351
|
+
'--no-abbrev-commit',
|
|
352
|
+
branchName,
|
|
353
|
+
]);
|
|
317
354
|
const lines = reflog.split('\n');
|
|
318
355
|
|
|
319
356
|
// Look for the branch creation point (last line in reflog)
|
|
@@ -322,15 +359,19 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
322
359
|
const match = creationLine.match(/^([a-f0-9]{40})/);
|
|
323
360
|
if (match) {
|
|
324
361
|
mergeBase = match[1];
|
|
325
|
-
console.log(
|
|
362
|
+
console.log(
|
|
363
|
+
chalk.gray(`Auto-detected fork point from reflog: ${mergeBase.substring(0, 7)}`)
|
|
364
|
+
);
|
|
326
365
|
}
|
|
327
366
|
}
|
|
328
367
|
|
|
329
368
|
if (!mergeBase) {
|
|
330
369
|
throw new Error('Could not find fork point in reflog');
|
|
331
370
|
}
|
|
332
|
-
} catch
|
|
333
|
-
console.error(
|
|
371
|
+
} catch {
|
|
372
|
+
console.error(
|
|
373
|
+
chalk.red('Could not auto-detect fork point. Please specify a target branch.')
|
|
374
|
+
);
|
|
334
375
|
console.error(chalk.gray('Usage: kk review <target-branch>'));
|
|
335
376
|
return null;
|
|
336
377
|
}
|
|
@@ -349,21 +390,25 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
349
390
|
console.log(chalk.gray(`Analyzing commits from ${mergeBase.substring(0, 7)} to HEAD...`));
|
|
350
391
|
|
|
351
392
|
// 3. Get Commit Messages with proper delimiter
|
|
352
|
-
const { stdout: logOutput } = await execa('git', ['log', '--pretty=%B---EOC---', diffRange], {
|
|
393
|
+
const { stdout: logOutput } = await execa('git', ['log', '--pretty=%B---EOC---', diffRange], {
|
|
394
|
+
cwd: repoRootPath,
|
|
395
|
+
});
|
|
353
396
|
const commitMessages = logOutput
|
|
354
397
|
.split('---EOC---')
|
|
355
398
|
.map((msg) => msg.trim())
|
|
356
399
|
.filter(Boolean);
|
|
357
400
|
|
|
358
401
|
// 4. Get changed files and their status
|
|
359
|
-
const { stdout: nameStatusOutput } = await execa('git', ['diff', '--name-status', diffRange], {
|
|
402
|
+
const { stdout: nameStatusOutput } = await execa('git', ['diff', '--name-status', diffRange], {
|
|
403
|
+
cwd: repoRootPath,
|
|
404
|
+
});
|
|
360
405
|
const fileList = parseNameStatus(nameStatusOutput);
|
|
361
406
|
|
|
362
407
|
// Filter out ignored files
|
|
363
408
|
let filteredFileList = fileList;
|
|
364
409
|
let ignoredCount = 0;
|
|
365
410
|
if (ignorePatterns && ignorePatterns.length > 0) {
|
|
366
|
-
filteredFileList = fileList.filter(file => {
|
|
411
|
+
filteredFileList = fileList.filter((file) => {
|
|
367
412
|
const ignored = shouldIgnoreFile(file.path, ignorePatterns);
|
|
368
413
|
if (ignored) {
|
|
369
414
|
ignoredCount++;
|
|
@@ -385,19 +430,22 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
385
430
|
|
|
386
431
|
// Run git commands from the repository root to handle all file paths correctly
|
|
387
432
|
// This works regardless of whether we're in a subdirectory or at the repo root
|
|
388
|
-
const { stdout: diff } = await execa('git', ['diff', '-U15', diffRange, '--', path], {
|
|
433
|
+
const { stdout: diff } = await execa('git', ['diff', '-U15', diffRange, '--', path], {
|
|
434
|
+
cwd: repoRootPath,
|
|
435
|
+
});
|
|
389
436
|
|
|
390
437
|
// Get the original content from the base commit
|
|
391
438
|
let content = '';
|
|
392
439
|
if (status !== 'A') {
|
|
393
440
|
// Added files have no original content
|
|
394
441
|
try {
|
|
395
|
-
const { stdout: originalContent } = await execa(
|
|
396
|
-
'
|
|
397
|
-
`${mergeBase.trim()}:${oldPath}
|
|
398
|
-
|
|
442
|
+
const { stdout: originalContent } = await execa(
|
|
443
|
+
'git',
|
|
444
|
+
['show', `${mergeBase.trim()}:${oldPath}`],
|
|
445
|
+
{ cwd: repoRootPath }
|
|
446
|
+
);
|
|
399
447
|
content = originalContent;
|
|
400
|
-
} catch
|
|
448
|
+
} catch {
|
|
401
449
|
// This can happen if a file was added and modified in the same branch
|
|
402
450
|
console.warn(
|
|
403
451
|
chalk.yellow(`Could not get original content for ${oldPath}. Assuming it was added.`)
|
|
@@ -437,4 +485,4 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
437
485
|
}
|
|
438
486
|
return null; // Return null to indicate failure
|
|
439
487
|
}
|
|
440
|
-
}
|
|
488
|
+
}
|
package/src/git-logic.test.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
parseNameStatus,
|
|
4
|
-
findJiraTicketIds,
|
|
5
|
-
findAdoTicketIds,
|
|
6
|
-
extractTicketIds,
|
|
7
4
|
runLocalReview,
|
|
8
5
|
runUncommittedReview,
|
|
9
6
|
truncateContent,
|
|
@@ -221,14 +218,17 @@ describe('runLocalReview - branch fetching', () => {
|
|
|
221
218
|
|
|
222
219
|
const result = await runLocalReview('non-existent-branch');
|
|
223
220
|
expect(result).toBeNull();
|
|
224
|
-
expect(console.error).toHaveBeenCalledWith(
|
|
221
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
222
|
+
expect.stringContaining("Branch 'non-existent-branch' does not exist locally.")
|
|
223
|
+
);
|
|
225
224
|
});
|
|
226
225
|
|
|
227
226
|
it('should fetch latest changes if target branch exists locally', async () => {
|
|
228
227
|
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
229
228
|
const command = [cmd, ...args].join(' ');
|
|
230
229
|
// Common setup
|
|
231
|
-
if (command.includes('remote get-url origin'))
|
|
230
|
+
if (command.includes('remote get-url origin'))
|
|
231
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
232
232
|
if (command.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'current-branch' };
|
|
233
233
|
if (command.includes('rev-parse --show-toplevel')) return { stdout: '/path/to/repo' };
|
|
234
234
|
|
|
@@ -249,14 +249,15 @@ describe('runLocalReview - branch fetching', () => {
|
|
|
249
249
|
const result = await runLocalReview('main');
|
|
250
250
|
expect(result).not.toBeNull();
|
|
251
251
|
const execaCalls = vi.mocked(execa).mock.calls;
|
|
252
|
-
const fetchCall = execaCalls.find(call => call[0] === 'git' && call[1].includes('fetch'));
|
|
252
|
+
const fetchCall = execaCalls.find((call) => call[0] === 'git' && call[1].includes('fetch'));
|
|
253
253
|
expect(fetchCall).toBeDefined();
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
it('should warn and continue if fetch fails', async () => {
|
|
257
257
|
vi.mocked(execa).mockImplementation(async (cmd, args) => {
|
|
258
258
|
const command = [cmd, ...args].join(' ');
|
|
259
|
-
if (command.includes('remote get-url origin'))
|
|
259
|
+
if (command.includes('remote get-url origin'))
|
|
260
|
+
return { stdout: 'https://github.com/user/repo.git' };
|
|
260
261
|
if (command.includes('rev-parse --abbrev-ref HEAD')) return { stdout: 'current-branch' };
|
|
261
262
|
if (command.includes('rev-parse --show-toplevel')) return { stdout: '/path/to/repo' };
|
|
262
263
|
if (command.includes('rev-parse --verify main')) return { stdout: 'commit-hash' };
|
|
@@ -278,8 +279,12 @@ describe('runLocalReview - branch fetching', () => {
|
|
|
278
279
|
|
|
279
280
|
const result = await runLocalReview('main');
|
|
280
281
|
expect(result).not.toBeNull(); // Should still proceed
|
|
281
|
-
expect(console.warn).toHaveBeenCalledWith(
|
|
282
|
-
|
|
282
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
283
|
+
expect.stringContaining("Could not fetch remote branch 'origin/main'.")
|
|
284
|
+
);
|
|
285
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
286
|
+
expect.stringContaining("Proceeding with local branch 'main' for comparison.")
|
|
287
|
+
);
|
|
283
288
|
});
|
|
284
289
|
});
|
|
285
290
|
|
|
@@ -308,7 +313,10 @@ describe('runLocalReview - fork point detection', () => {
|
|
|
308
313
|
}
|
|
309
314
|
if (command.includes('reflog show --no-abbrev-commit feature-branch')) {
|
|
310
315
|
// Simulate reflog output - last line is where branch was created
|
|
311
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
stdout:
|
|
318
|
+
'abc123def456 feature-branch@{0}: commit: latest\nfedcba654321 feature-branch@{1}: commit: middle\n510572bc5197788770004d0d0585822adab0128f feature-branch@{2}: branch: Created from master',
|
|
319
|
+
};
|
|
312
320
|
}
|
|
313
321
|
if (command.includes('log --pretty=%B---EOC---') && command.includes('510572bc')) {
|
|
314
322
|
return { stdout: 'feat: add feature---EOC---' };
|
|
@@ -333,9 +341,7 @@ describe('runLocalReview - fork point detection', () => {
|
|
|
333
341
|
|
|
334
342
|
// Should have used reflog to find fork point
|
|
335
343
|
const execaCalls = vi.mocked(execa).mock.calls;
|
|
336
|
-
const reflogCall = execaCalls.find(call =>
|
|
337
|
-
call[0] === 'git' && call[1].includes('reflog')
|
|
338
|
-
);
|
|
344
|
+
const reflogCall = execaCalls.find((call) => call[0] === 'git' && call[1].includes('reflog'));
|
|
339
345
|
expect(reflogCall).toBeDefined();
|
|
340
346
|
});
|
|
341
347
|
|
|
@@ -384,8 +390,9 @@ describe('runLocalReview - fork point detection', () => {
|
|
|
384
390
|
|
|
385
391
|
// Should have used merge-base with origin/main (since fetch succeeded)
|
|
386
392
|
const execaCalls = vi.mocked(execa).mock.calls;
|
|
387
|
-
const mergeBaseCall = execaCalls.find(
|
|
388
|
-
call
|
|
393
|
+
const mergeBaseCall = execaCalls.find(
|
|
394
|
+
(call) =>
|
|
395
|
+
call[0] === 'git' && call[1].includes('merge-base') && call[1].includes('origin/main')
|
|
389
396
|
);
|
|
390
397
|
expect(mergeBaseCall).toBeDefined();
|
|
391
398
|
});
|
|
@@ -450,6 +457,18 @@ describe('normalizeRepoUrl', () => {
|
|
|
450
457
|
expect(normalizeRepoUrl(sshUrl)).toBe(expected);
|
|
451
458
|
});
|
|
452
459
|
|
|
460
|
+
it('should normalize Bitbucket SSH URL to HTTPS', () => {
|
|
461
|
+
const sshUrl = 'git@bitbucket.org:user/repo.git';
|
|
462
|
+
const expected = 'https://bitbucket.org/user/repo';
|
|
463
|
+
expect(normalizeRepoUrl(sshUrl)).toBe(expected);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should normalize Bitbucket SSH URL without .git suffix', () => {
|
|
467
|
+
const sshUrl = 'git@bitbucket.org:user/repo';
|
|
468
|
+
const expected = 'https://bitbucket.org/user/repo';
|
|
469
|
+
expect(normalizeRepoUrl(sshUrl)).toBe(expected);
|
|
470
|
+
});
|
|
471
|
+
|
|
453
472
|
it('should remove .git suffix from HTTPS URLs', () => {
|
|
454
473
|
const httpsUrl = 'https://github.com/user/repo.git';
|
|
455
474
|
const expected = 'https://github.com/user/repo';
|
|
@@ -539,4 +558,4 @@ describe('shouldIgnoreFile', () => {
|
|
|
539
558
|
expect(shouldIgnoreFile('file.sql', pattern)).toBe(true);
|
|
540
559
|
expect(shouldIgnoreFile('file.js', pattern)).toBe(false);
|
|
541
560
|
});
|
|
542
|
-
});
|
|
561
|
+
});
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,14 @@ import chalk from 'chalk';
|
|
|
6
6
|
import readline from 'readline';
|
|
7
7
|
import ora from 'ora';
|
|
8
8
|
import { runLocalReview } from './git-logic.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getApiKey,
|
|
11
|
+
setApiKey,
|
|
12
|
+
getApiEndpoint,
|
|
13
|
+
setApiEndpoint,
|
|
14
|
+
getTicketSystem,
|
|
15
|
+
setTicketSystem,
|
|
16
|
+
} from './config.js';
|
|
10
17
|
import { formatReviewOutput } from './formatter.js';
|
|
11
18
|
|
|
12
19
|
/**
|
|
@@ -31,7 +38,9 @@ program
|
|
|
31
38
|
.name('kk')
|
|
32
39
|
.description('AI-powered code review CLI - Keep your kode korekt')
|
|
33
40
|
.version('0.2.0')
|
|
34
|
-
.addHelpText(
|
|
41
|
+
.addHelpText(
|
|
42
|
+
'after',
|
|
43
|
+
`
|
|
35
44
|
Examples:
|
|
36
45
|
$ kk review Review committed changes (auto-detect base)
|
|
37
46
|
$ kk review main Review changes against main branch
|
|
@@ -47,24 +56,29 @@ Configuration:
|
|
|
47
56
|
$ kk config --key YOUR_KEY
|
|
48
57
|
$ kk config --endpoint https://api.korekt.ai/review/local
|
|
49
58
|
$ kk config --ticket-system ado
|
|
50
|
-
`
|
|
59
|
+
`
|
|
60
|
+
);
|
|
51
61
|
|
|
52
62
|
program
|
|
53
63
|
.command('review')
|
|
54
64
|
.description('Review the changes in the current branch.')
|
|
55
|
-
.argument(
|
|
65
|
+
.argument(
|
|
66
|
+
'[target-branch]',
|
|
67
|
+
'The branch to compare against (e.g., main, develop). If not specified, auto-detects fork point.'
|
|
68
|
+
)
|
|
56
69
|
.option('--ticket-system <system>', 'Ticket system to use (jira or ado)')
|
|
57
70
|
.option('--dry-run', 'Show payload without sending to API')
|
|
58
|
-
.option(
|
|
71
|
+
.option(
|
|
72
|
+
'--ignore <patterns...>',
|
|
73
|
+
'Ignore files matching these patterns (e.g., "*.lock" "dist/*")'
|
|
74
|
+
)
|
|
59
75
|
.action(async (targetBranch, options) => {
|
|
60
76
|
const reviewTarget = targetBranch ? `against '${targetBranch}'` : '(auto-detecting fork point)';
|
|
61
77
|
console.log(chalk.blue.bold(`🚀 Starting AI Code Review ${reviewTarget}...`));
|
|
62
78
|
|
|
63
79
|
const apiKey = getApiKey();
|
|
64
80
|
if (!apiKey) {
|
|
65
|
-
console.error(
|
|
66
|
-
chalk.red('API Key not found! Please run `kk config --key YOUR_KEY` first.')
|
|
67
|
-
);
|
|
81
|
+
console.error(chalk.red('API Key not found! Please run `kk config --key YOUR_KEY` first.'));
|
|
68
82
|
return;
|
|
69
83
|
}
|
|
70
84
|
|
|
@@ -107,12 +121,18 @@ program
|
|
|
107
121
|
// Create a shortened version for display
|
|
108
122
|
const displayPayload = {
|
|
109
123
|
...payload,
|
|
110
|
-
changed_files: payload.changed_files.map(file => ({
|
|
124
|
+
changed_files: payload.changed_files.map((file) => ({
|
|
111
125
|
path: file.path,
|
|
112
126
|
status: file.status,
|
|
113
127
|
...(file.old_path && { old_path: file.old_path }),
|
|
114
|
-
diff:
|
|
115
|
-
|
|
128
|
+
diff:
|
|
129
|
+
file.diff.length > 500
|
|
130
|
+
? `${file.diff.substring(0, 500)}... [truncated ${file.diff.length - 500} chars]`
|
|
131
|
+
: file.diff,
|
|
132
|
+
content:
|
|
133
|
+
file.content.length > 500
|
|
134
|
+
? `${file.content.substring(0, 500)}... [truncated ${file.content.length - 500} chars]`
|
|
135
|
+
: file.content,
|
|
116
136
|
})),
|
|
117
137
|
};
|
|
118
138
|
|
|
@@ -129,14 +149,15 @@ program
|
|
|
129
149
|
console.log(` Files: ${chalk.cyan(payload.changed_files.length)}\n`);
|
|
130
150
|
|
|
131
151
|
console.log(chalk.bold(' Files to review:'));
|
|
132
|
-
payload.changed_files.forEach(file => {
|
|
133
|
-
const statusColor =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
152
|
+
payload.changed_files.forEach((file) => {
|
|
153
|
+
const statusColor =
|
|
154
|
+
{
|
|
155
|
+
M: chalk.yellow,
|
|
156
|
+
A: chalk.green,
|
|
157
|
+
D: chalk.red,
|
|
158
|
+
R: chalk.blue,
|
|
159
|
+
C: chalk.cyan,
|
|
160
|
+
}[file.status] || ((text) => text);
|
|
140
161
|
console.log(` ${statusColor(file.status + ' ' + file.path)}`);
|
|
141
162
|
});
|
|
142
163
|
console.log();
|
|
@@ -161,7 +182,7 @@ program
|
|
|
161
182
|
try {
|
|
162
183
|
const response = await axios.post(apiEndpoint, payload, {
|
|
163
184
|
headers: {
|
|
164
|
-
|
|
185
|
+
Authorization: `Bearer ${apiKey}`,
|
|
165
186
|
'Content-Type': 'application/json',
|
|
166
187
|
},
|
|
167
188
|
});
|
|
@@ -224,9 +245,7 @@ program
|
|
|
224
245
|
async function reviewUncommitted(mode, options) {
|
|
225
246
|
const apiKey = getApiKey();
|
|
226
247
|
if (!apiKey) {
|
|
227
|
-
console.error(
|
|
228
|
-
chalk.red('API Key not found! Please run `kk config --key YOUR_KEY` first.')
|
|
229
|
-
);
|
|
248
|
+
console.error(chalk.red('API Key not found! Please run `kk config --key YOUR_KEY` first.'));
|
|
230
249
|
return;
|
|
231
250
|
}
|
|
232
251
|
|
|
@@ -265,12 +284,18 @@ async function reviewUncommitted(mode, options) {
|
|
|
265
284
|
|
|
266
285
|
const displayPayload = {
|
|
267
286
|
...payload,
|
|
268
|
-
changed_files: payload.changed_files.map(file => ({
|
|
287
|
+
changed_files: payload.changed_files.map((file) => ({
|
|
269
288
|
path: file.path,
|
|
270
289
|
status: file.status,
|
|
271
290
|
...(file.old_path && { old_path: file.old_path }),
|
|
272
|
-
diff:
|
|
273
|
-
|
|
291
|
+
diff:
|
|
292
|
+
file.diff.length > 500
|
|
293
|
+
? `${file.diff.substring(0, 500)}... [truncated ${file.diff.length - 500} chars]`
|
|
294
|
+
: file.diff,
|
|
295
|
+
content:
|
|
296
|
+
file.content.length > 500
|
|
297
|
+
? `${file.content.substring(0, 500)}... [truncated ${file.content.length - 500} chars]`
|
|
298
|
+
: file.content,
|
|
274
299
|
})),
|
|
275
300
|
};
|
|
276
301
|
|
|
@@ -284,14 +309,15 @@ async function reviewUncommitted(mode, options) {
|
|
|
284
309
|
console.log(chalk.yellow('\n📋 Ready to submit uncommitted changes for review:\n'));
|
|
285
310
|
console.log(chalk.gray(' Comparing against HEAD (last commit)\n'));
|
|
286
311
|
console.log(chalk.bold(' Files to review:'));
|
|
287
|
-
payload.changed_files.forEach(file => {
|
|
288
|
-
const statusColor =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
312
|
+
payload.changed_files.forEach((file) => {
|
|
313
|
+
const statusColor =
|
|
314
|
+
{
|
|
315
|
+
M: chalk.yellow,
|
|
316
|
+
A: chalk.green,
|
|
317
|
+
D: chalk.red,
|
|
318
|
+
R: chalk.blue,
|
|
319
|
+
C: chalk.cyan,
|
|
320
|
+
}[file.status] || ((text) => text);
|
|
295
321
|
console.log(` ${statusColor(file.status + ' ' + file.path)}`);
|
|
296
322
|
});
|
|
297
323
|
console.log();
|
|
@@ -315,7 +341,7 @@ async function reviewUncommitted(mode, options) {
|
|
|
315
341
|
try {
|
|
316
342
|
const response = await axios.post(apiEndpoint, payload, {
|
|
317
343
|
headers: {
|
|
318
|
-
|
|
344
|
+
Authorization: `Bearer ${apiKey}`,
|
|
319
345
|
'Content-Type': 'application/json',
|
|
320
346
|
},
|
|
321
347
|
});
|
|
@@ -355,8 +381,12 @@ program
|
|
|
355
381
|
|
|
356
382
|
console.log(chalk.bold('\nCurrent Configuration:\n'));
|
|
357
383
|
console.log(` API Key: ${apiKey ? chalk.green('✓ Set') : chalk.red('✗ Not set')}`);
|
|
358
|
-
console.log(
|
|
359
|
-
|
|
384
|
+
console.log(
|
|
385
|
+
` API Endpoint: ${apiEndpoint ? chalk.cyan(apiEndpoint) : chalk.red('✗ Not set')}`
|
|
386
|
+
);
|
|
387
|
+
console.log(
|
|
388
|
+
` Ticket System: ${ticketSystem ? chalk.cyan(ticketSystem) : chalk.gray('Not configured')}\n`
|
|
389
|
+
);
|
|
360
390
|
return;
|
|
361
391
|
}
|
|
362
392
|
|