korekt-cli 0.2.0 → 0.4.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/LICENSE +21 -0
- package/README.md +11 -1
- package/package.json +19 -3
- package/src/config.js +1 -1
- package/src/formatter.js +11 -9
- package/src/git-logic.js +75 -34
- package/src/git-logic.test.js +23 -16
- package/src/index.js +68 -38
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vladan Djokic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Korekt CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/korekt-cli)
|
|
4
|
+
[](https://www.npmjs.com/package/korekt-cli)
|
|
5
|
+
[](https://www.npmjs.com/package/korekt-cli)
|
|
6
|
+
|
|
7
|
+
AI-powered code review CLI - Keep your kode korekt
|
|
4
8
|
|
|
5
9
|
`kk` integrates seamlessly with your local Git workflow to provide intelligent code reviews powered by AI.
|
|
6
10
|
|
|
@@ -128,3 +132,9 @@ To run tests:
|
|
|
128
132
|
```bash
|
|
129
133
|
npm test
|
|
130
134
|
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT © [Vladan Djokic](https://korekt.ai)
|
|
139
|
+
|
|
140
|
+
See [LICENSE](./LICENSE) for details.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "korekt-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -34,7 +45,7 @@
|
|
|
34
45
|
"files": [
|
|
35
46
|
"src"
|
|
36
47
|
],
|
|
37
|
-
"license": "
|
|
48
|
+
"license": "MIT",
|
|
38
49
|
"dependencies": {
|
|
39
50
|
"axios": "^1.12.2",
|
|
40
51
|
"chalk": "^5.6.2",
|
|
@@ -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,21 +28,21 @@ 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}`;
|
|
@@ -69,11 +69,11 @@ export function shouldIgnoreFile(filePath, patterns) {
|
|
|
69
69
|
// Replace * with [^/]* (matches anything except /)
|
|
70
70
|
// Replace ** with .* (matches anything including /)
|
|
71
71
|
let regexPattern = pattern
|
|
72
|
-
.replace(/\./g, '\\.')
|
|
73
|
-
.replace(/\*\*/g, '___DOUBLESTAR___')
|
|
74
|
-
.replace(/\*/g, '[^/]*')
|
|
75
|
-
.replace(/___DOUBLESTAR___/g, '.*')
|
|
76
|
-
.replace(/\?/g, '.');
|
|
72
|
+
.replace(/\./g, '\\.') // Escape dots
|
|
73
|
+
.replace(/\*\*/g, '___DOUBLESTAR___') // Temporarily replace **
|
|
74
|
+
.replace(/\*/g, '[^/]*') // Replace single * with [^/]*
|
|
75
|
+
.replace(/___DOUBLESTAR___/g, '.*') // Replace ** with .*
|
|
76
|
+
.replace(/\?/g, '.'); // Replace ? with .
|
|
77
77
|
|
|
78
78
|
// Handle leading **/ pattern - make it optional so it matches both with and without directory prefix
|
|
79
79
|
// For example, **/*.sql should match both "file.sql" and "dir/file.sql"
|
|
@@ -133,7 +133,11 @@ export function parseNameStatus(output) {
|
|
|
133
133
|
* @param {string|null} ticketSystem - The ticket system to use (jira or ado), or null to skip ticket extraction
|
|
134
134
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
135
135
|
*/
|
|
136
|
-
export async function runUncommittedReview(
|
|
136
|
+
export async function runUncommittedReview(
|
|
137
|
+
mode = 'all',
|
|
138
|
+
_ticketSystem = null,
|
|
139
|
+
includeUntracked = false
|
|
140
|
+
) {
|
|
137
141
|
try {
|
|
138
142
|
// 1. Get Repo URL and current branch name
|
|
139
143
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -164,12 +168,19 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
164
168
|
// Handle untracked files if requested
|
|
165
169
|
if (includeUntracked) {
|
|
166
170
|
console.log(chalk.gray('Analyzing untracked files...'));
|
|
167
|
-
const { stdout: untrackedFilesOutput } = await execa('git', [
|
|
171
|
+
const { stdout: untrackedFilesOutput } = await execa('git', [
|
|
172
|
+
'ls-files',
|
|
173
|
+
'--others',
|
|
174
|
+
'--exclude-standard',
|
|
175
|
+
]);
|
|
168
176
|
const untrackedFiles = untrackedFilesOutput.split('\n').filter(Boolean);
|
|
169
177
|
|
|
170
178
|
for (const file of untrackedFiles) {
|
|
171
179
|
const content = fs.readFileSync(file, 'utf-8');
|
|
172
|
-
const diff = content
|
|
180
|
+
const diff = content
|
|
181
|
+
.split('\n')
|
|
182
|
+
.map((line) => `+${line}`)
|
|
183
|
+
.join('\n');
|
|
173
184
|
changedFiles.push({
|
|
174
185
|
path: file,
|
|
175
186
|
status: 'A', // Untracked files are always additions
|
|
@@ -182,8 +193,8 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
182
193
|
}
|
|
183
194
|
|
|
184
195
|
// 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));
|
|
196
|
+
const processedPaths = new Set(changedFiles.map((f) => f.path));
|
|
197
|
+
const uniqueFileList = fileList.filter((file) => !processedPaths.has(file.path));
|
|
187
198
|
|
|
188
199
|
for (const file of uniqueFileList) {
|
|
189
200
|
const { status, path, oldPath } = file;
|
|
@@ -199,7 +210,13 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
199
210
|
} else {
|
|
200
211
|
// For 'all', try staged first, then unstaged
|
|
201
212
|
try {
|
|
202
|
-
const { stdout: stagedDiff } = await execa('git', [
|
|
213
|
+
const { stdout: stagedDiff } = await execa('git', [
|
|
214
|
+
'diff',
|
|
215
|
+
'--cached',
|
|
216
|
+
'-U15',
|
|
217
|
+
'--',
|
|
218
|
+
path,
|
|
219
|
+
]);
|
|
203
220
|
if (stagedDiff) {
|
|
204
221
|
diff = stagedDiff;
|
|
205
222
|
} else {
|
|
@@ -218,8 +235,10 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
218
235
|
try {
|
|
219
236
|
const { stdout: headContent } = await execa('git', ['show', `HEAD:${oldPath}`]);
|
|
220
237
|
content = headContent;
|
|
221
|
-
} catch
|
|
222
|
-
console.warn(
|
|
238
|
+
} catch {
|
|
239
|
+
console.warn(
|
|
240
|
+
chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`)
|
|
241
|
+
);
|
|
223
242
|
}
|
|
224
243
|
}
|
|
225
244
|
|
|
@@ -268,7 +287,11 @@ export async function runUncommittedReview(mode = 'all', ticketSystem = null, in
|
|
|
268
287
|
* @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
|
|
269
288
|
* @returns {Object|null} - The payload object ready for API submission, or null on error
|
|
270
289
|
*/
|
|
271
|
-
export async function runLocalReview(
|
|
290
|
+
export async function runLocalReview(
|
|
291
|
+
targetBranch = null,
|
|
292
|
+
_ticketSystem = null,
|
|
293
|
+
ignorePatterns = null
|
|
294
|
+
) {
|
|
272
295
|
try {
|
|
273
296
|
// 1. Get Repo URL, current branch name, and repository root
|
|
274
297
|
const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
|
|
@@ -285,7 +308,7 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
285
308
|
// Check if the branch exists locally
|
|
286
309
|
try {
|
|
287
310
|
await execa('git', ['rev-parse', '--verify', targetBranch]);
|
|
288
|
-
} catch
|
|
311
|
+
} catch {
|
|
289
312
|
console.error(chalk.red(`Branch '${targetBranch}' does not exist locally.`));
|
|
290
313
|
console.error(chalk.gray(`Please check out the branch first or specify a different one.`));
|
|
291
314
|
return null;
|
|
@@ -299,8 +322,10 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
299
322
|
// If fetch succeeded, use the remote-tracking branch for comparison
|
|
300
323
|
// This is safer as it doesn't modify the user's local branch
|
|
301
324
|
targetBranchRef = `origin/${targetBranch}`;
|
|
302
|
-
console.log(
|
|
303
|
-
|
|
325
|
+
console.log(
|
|
326
|
+
chalk.gray(`Using remote-tracking branch 'origin/${targetBranch}' for comparison.`)
|
|
327
|
+
);
|
|
328
|
+
} catch {
|
|
304
329
|
console.warn(chalk.yellow(`Could not fetch remote branch 'origin/${targetBranch}'.`));
|
|
305
330
|
console.warn(chalk.gray(`Proceeding with local branch '${targetBranch}' for comparison.`));
|
|
306
331
|
// targetBranchRef stays as targetBranch (local branch)
|
|
@@ -313,7 +338,12 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
313
338
|
if (!targetBranch) {
|
|
314
339
|
try {
|
|
315
340
|
// Use git reflog to find where the branch was created
|
|
316
|
-
const { stdout: reflog } = await execa('git', [
|
|
341
|
+
const { stdout: reflog } = await execa('git', [
|
|
342
|
+
'reflog',
|
|
343
|
+
'show',
|
|
344
|
+
'--no-abbrev-commit',
|
|
345
|
+
branchName,
|
|
346
|
+
]);
|
|
317
347
|
const lines = reflog.split('\n');
|
|
318
348
|
|
|
319
349
|
// Look for the branch creation point (last line in reflog)
|
|
@@ -322,15 +352,19 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
322
352
|
const match = creationLine.match(/^([a-f0-9]{40})/);
|
|
323
353
|
if (match) {
|
|
324
354
|
mergeBase = match[1];
|
|
325
|
-
console.log(
|
|
355
|
+
console.log(
|
|
356
|
+
chalk.gray(`Auto-detected fork point from reflog: ${mergeBase.substring(0, 7)}`)
|
|
357
|
+
);
|
|
326
358
|
}
|
|
327
359
|
}
|
|
328
360
|
|
|
329
361
|
if (!mergeBase) {
|
|
330
362
|
throw new Error('Could not find fork point in reflog');
|
|
331
363
|
}
|
|
332
|
-
} catch
|
|
333
|
-
console.error(
|
|
364
|
+
} catch {
|
|
365
|
+
console.error(
|
|
366
|
+
chalk.red('Could not auto-detect fork point. Please specify a target branch.')
|
|
367
|
+
);
|
|
334
368
|
console.error(chalk.gray('Usage: kk review <target-branch>'));
|
|
335
369
|
return null;
|
|
336
370
|
}
|
|
@@ -349,21 +383,25 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
349
383
|
console.log(chalk.gray(`Analyzing commits from ${mergeBase.substring(0, 7)} to HEAD...`));
|
|
350
384
|
|
|
351
385
|
// 3. Get Commit Messages with proper delimiter
|
|
352
|
-
const { stdout: logOutput } = await execa('git', ['log', '--pretty=%B---EOC---', diffRange], {
|
|
386
|
+
const { stdout: logOutput } = await execa('git', ['log', '--pretty=%B---EOC---', diffRange], {
|
|
387
|
+
cwd: repoRootPath,
|
|
388
|
+
});
|
|
353
389
|
const commitMessages = logOutput
|
|
354
390
|
.split('---EOC---')
|
|
355
391
|
.map((msg) => msg.trim())
|
|
356
392
|
.filter(Boolean);
|
|
357
393
|
|
|
358
394
|
// 4. Get changed files and their status
|
|
359
|
-
const { stdout: nameStatusOutput } = await execa('git', ['diff', '--name-status', diffRange], {
|
|
395
|
+
const { stdout: nameStatusOutput } = await execa('git', ['diff', '--name-status', diffRange], {
|
|
396
|
+
cwd: repoRootPath,
|
|
397
|
+
});
|
|
360
398
|
const fileList = parseNameStatus(nameStatusOutput);
|
|
361
399
|
|
|
362
400
|
// Filter out ignored files
|
|
363
401
|
let filteredFileList = fileList;
|
|
364
402
|
let ignoredCount = 0;
|
|
365
403
|
if (ignorePatterns && ignorePatterns.length > 0) {
|
|
366
|
-
filteredFileList = fileList.filter(file => {
|
|
404
|
+
filteredFileList = fileList.filter((file) => {
|
|
367
405
|
const ignored = shouldIgnoreFile(file.path, ignorePatterns);
|
|
368
406
|
if (ignored) {
|
|
369
407
|
ignoredCount++;
|
|
@@ -385,19 +423,22 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
385
423
|
|
|
386
424
|
// Run git commands from the repository root to handle all file paths correctly
|
|
387
425
|
// 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], {
|
|
426
|
+
const { stdout: diff } = await execa('git', ['diff', '-U15', diffRange, '--', path], {
|
|
427
|
+
cwd: repoRootPath,
|
|
428
|
+
});
|
|
389
429
|
|
|
390
430
|
// Get the original content from the base commit
|
|
391
431
|
let content = '';
|
|
392
432
|
if (status !== 'A') {
|
|
393
433
|
// Added files have no original content
|
|
394
434
|
try {
|
|
395
|
-
const { stdout: originalContent } = await execa(
|
|
396
|
-
'
|
|
397
|
-
`${mergeBase.trim()}:${oldPath}
|
|
398
|
-
|
|
435
|
+
const { stdout: originalContent } = await execa(
|
|
436
|
+
'git',
|
|
437
|
+
['show', `${mergeBase.trim()}:${oldPath}`],
|
|
438
|
+
{ cwd: repoRootPath }
|
|
439
|
+
);
|
|
399
440
|
content = originalContent;
|
|
400
|
-
} catch
|
|
441
|
+
} catch {
|
|
401
442
|
// This can happen if a file was added and modified in the same branch
|
|
402
443
|
console.warn(
|
|
403
444
|
chalk.yellow(`Could not get original content for ${oldPath}. Assuming it was added.`)
|
|
@@ -437,4 +478,4 @@ export async function runLocalReview(targetBranch = null, ticketSystem = null, i
|
|
|
437
478
|
}
|
|
438
479
|
return null; // Return null to indicate failure
|
|
439
480
|
}
|
|
440
|
-
}
|
|
481
|
+
}
|
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
|
});
|
|
@@ -539,4 +546,4 @@ describe('shouldIgnoreFile', () => {
|
|
|
539
546
|
expect(shouldIgnoreFile('file.sql', pattern)).toBe(true);
|
|
540
547
|
expect(shouldIgnoreFile('file.js', pattern)).toBe(false);
|
|
541
548
|
});
|
|
542
|
-
});
|
|
549
|
+
});
|
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
|
/**
|
|
@@ -30,8 +37,10 @@ async function confirmAction(message) {
|
|
|
30
37
|
program
|
|
31
38
|
.name('kk')
|
|
32
39
|
.description('AI-powered code review CLI - Keep your kode korekt')
|
|
33
|
-
.version('
|
|
34
|
-
.addHelpText(
|
|
40
|
+
.version('0.2.0')
|
|
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
|
|