korekt-cli 0.2.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.
@@ -0,0 +1,440 @@
1
+ import { execa } from 'execa';
2
+ import chalk from 'chalk';
3
+ import fs from 'fs';
4
+
5
+ /**
6
+ * Truncate content to a maximum number of lines using "head and tail".
7
+ * @param {string} content - The string content to truncate
8
+ * @param {number} maxLines - The maximum number of lines to allow (default: 2000)
9
+ * @returns {string} - Truncated content string
10
+ */
11
+ export function truncateContent(content, maxLines = 2000) {
12
+ const lines = content.split('\n');
13
+ if (lines.length <= maxLines) {
14
+ return content;
15
+ }
16
+
17
+ const halfMax = Math.floor(maxLines / 2);
18
+ const head = lines.slice(0, halfMax).join('\n');
19
+ const tail = lines.slice(-halfMax).join('\n');
20
+ return `${head}\n\n... [truncated] ...\n\n${tail}`;
21
+ }
22
+
23
+ /**
24
+ * Normalize git remote URL to HTTPS format
25
+ * Converts SSH URLs to HTTPS URLs for consistency
26
+ * @param {string} url - The git remote URL
27
+ * @returns {string} - Normalized HTTPS URL
28
+ */
29
+ export function normalizeRepoUrl(url) {
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\/([^\/]+)\/([^\/]+)\/(.+)/);
32
+ if (azureDevOpsSshMatch) {
33
+ const [, org, project, repo] = azureDevOpsSshMatch;
34
+ return `https://dev.azure.com/${org}/${project}/_git/${repo}`;
35
+ }
36
+
37
+ // Handle GitHub SSH format: git@github.com:user/repo.git
38
+ const githubSshMatch = url.match(/git@github\.com:([^\/]+)\/(.+?)(?:\.git)?$/);
39
+ if (githubSshMatch) {
40
+ const [, user, repo] = githubSshMatch;
41
+ return `https://github.com/${user}/${repo}`;
42
+ }
43
+
44
+ // Handle GitLab SSH format: git@gitlab.com:user/repo.git
45
+ const gitlabSshMatch = url.match(/git@gitlab\.com:([^\/]+)\/(.+?)(?:\.git)?$/);
46
+ if (gitlabSshMatch) {
47
+ const [, user, repo] = gitlabSshMatch;
48
+ return `https://gitlab.com/${user}/${repo}`;
49
+ }
50
+
51
+ // If already HTTPS or other format, return as-is (possibly removing .git suffix)
52
+ return url.replace(/\.git$/, '');
53
+ }
54
+
55
+ /**
56
+ * Check if a file path should be ignored based on patterns
57
+ * Supports glob patterns like *.lock, dist/*
58
+ * @param {string} filePath - The file path to check
59
+ * @param {string[]} patterns - Array of glob patterns to match against
60
+ * @returns {boolean} - True if the file should be ignored
61
+ */
62
+ export function shouldIgnoreFile(filePath, patterns) {
63
+ if (!patterns || patterns.length === 0) {
64
+ return false;
65
+ }
66
+
67
+ for (const pattern of patterns) {
68
+ // Convert glob pattern to regex
69
+ // Replace * with [^/]* (matches anything except /)
70
+ // Replace ** with .* (matches anything including /)
71
+ let regexPattern = pattern
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
+
78
+ // Handle leading **/ pattern - make it optional so it matches both with and without directory prefix
79
+ // For example, **/*.sql should match both "file.sql" and "dir/file.sql"
80
+ regexPattern = regexPattern.replace(/^\.\*\//, '(?:.*/)?');
81
+
82
+ // Add start and end anchors
83
+ regexPattern = '^' + regexPattern + '$';
84
+
85
+ const regex = new RegExp(regexPattern);
86
+
87
+ if (regex.test(filePath)) {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Helper function to parse the complex output of git diff --name-status
97
+ */
98
+ export function parseNameStatus(output) {
99
+ const files = [];
100
+ const lines = output.split('\n').filter(Boolean);
101
+
102
+ for (const line of lines) {
103
+ const parts = line.split('\t');
104
+ const statusRaw = parts[0];
105
+ let oldPath = null;
106
+ let path = null;
107
+ let status = null;
108
+
109
+ if (statusRaw.startsWith('R')) {
110
+ // Renamed files have format R<score>\told-path\tnew-path
111
+ status = 'R';
112
+ oldPath = parts[1];
113
+ path = parts[2];
114
+ } else if (statusRaw.startsWith('C')) {
115
+ // Copied files have format C<score>\told-path\tnew-path
116
+ status = 'C';
117
+ oldPath = parts[1];
118
+ path = parts[2];
119
+ } else {
120
+ // M, A, D files have format <status>\t<path>
121
+ status = statusRaw;
122
+ path = parts[1];
123
+ oldPath = parts[1]; // For consistency, oldPath is the same for M, A, D
124
+ }
125
+ files.push({ status, path, oldPath });
126
+ }
127
+ return files;
128
+ }
129
+
130
+ /**
131
+ * Analyze uncommitted changes (staged, unstaged, or all)
132
+ * @param {string} mode - 'staged', 'unstaged', or 'all'
133
+ * @param {string|null} ticketSystem - The ticket system to use (jira or ado), or null to skip ticket extraction
134
+ * @returns {Object|null} - The payload object ready for API submission, or null on error
135
+ */
136
+ export async function runUncommittedReview(mode = 'all', ticketSystem = null, includeUntracked = false) {
137
+ try {
138
+ // 1. Get Repo URL and current branch name
139
+ const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
140
+ const { stdout: sourceBranch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
141
+ const branchName = sourceBranch.trim();
142
+
143
+ // 2. Get changed files based on mode
144
+ let nameStatusOutput;
145
+ if (mode === 'staged') {
146
+ const { stdout } = await execa('git', ['diff', '--cached', '--name-status']);
147
+ nameStatusOutput = stdout;
148
+ console.log(chalk.gray('Analyzing staged changes...'));
149
+ } else if (mode === 'unstaged') {
150
+ const { stdout } = await execa('git', ['diff', '--name-status']);
151
+ nameStatusOutput = stdout;
152
+ console.log(chalk.gray('Analyzing unstaged changes...'));
153
+ } else {
154
+ // mode === 'all': combine staged and unstaged
155
+ const { stdout: staged } = await execa('git', ['diff', '--cached', '--name-status']);
156
+ const { stdout: unstaged } = await execa('git', ['diff', '--name-status']);
157
+ nameStatusOutput = [staged, unstaged].filter(Boolean).join('\n');
158
+ console.log(chalk.gray('Analyzing all uncommitted changes...'));
159
+ }
160
+
161
+ const fileList = parseNameStatus(nameStatusOutput);
162
+ const changedFiles = [];
163
+
164
+ // Handle untracked files if requested
165
+ if (includeUntracked) {
166
+ console.log(chalk.gray('Analyzing untracked files...'));
167
+ const { stdout: untrackedFilesOutput } = await execa('git', ['ls-files', '--others', '--exclude-standard']);
168
+ const untrackedFiles = untrackedFilesOutput.split('\n').filter(Boolean);
169
+
170
+ for (const file of untrackedFiles) {
171
+ const content = fs.readFileSync(file, 'utf-8');
172
+ const diff = content.split('\n').map(line => `+${line}`).join('\n');
173
+ changedFiles.push({
174
+ path: file,
175
+ status: 'A', // Untracked files are always additions
176
+ diff: diff,
177
+ content: '', // No old content
178
+ });
179
+ // Add to fileList to prevent duplication if it's also in nameStatusOutput (edge case)
180
+ fileList.push({ status: 'A', path: file, oldPath: file });
181
+ }
182
+ }
183
+
184
+ // 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));
187
+
188
+ for (const file of uniqueFileList) {
189
+ const { status, path, oldPath } = file;
190
+
191
+ // Get diff based on mode
192
+ let diff;
193
+ if (mode === 'staged') {
194
+ const { stdout } = await execa('git', ['diff', '--cached', '-U15', '--', path]);
195
+ diff = stdout;
196
+ } else if (mode === 'unstaged') {
197
+ const { stdout } = await execa('git', ['diff', '-U15', '--', path]);
198
+ diff = stdout;
199
+ } else {
200
+ // For 'all', try staged first, then unstaged
201
+ try {
202
+ const { stdout: stagedDiff } = await execa('git', ['diff', '--cached', '-U15', '--', path]);
203
+ if (stagedDiff) {
204
+ diff = stagedDiff;
205
+ } else {
206
+ const { stdout: unstagedDiff } = await execa('git', ['diff', '-U15', '--', path]);
207
+ diff = unstagedDiff;
208
+ }
209
+ } catch {
210
+ const { stdout: unstagedDiff } = await execa('git', ['diff', '-U15', '--', path]);
211
+ diff = unstagedDiff;
212
+ }
213
+ }
214
+
215
+ // Get current content from HEAD (before changes)
216
+ let content = '';
217
+ if (status !== 'A') {
218
+ try {
219
+ const { stdout: headContent } = await execa('git', ['show', `HEAD:${oldPath}`]);
220
+ content = headContent;
221
+ } catch (e) {
222
+ console.warn(chalk.yellow(`Could not get HEAD content for ${oldPath}. Assuming it's new.`));
223
+ }
224
+ }
225
+
226
+ // Truncate content
227
+ content = truncateContent(content);
228
+
229
+ // For deleted files, truncate the diff as well
230
+ if (status === 'D') {
231
+ diff = truncateContent(diff);
232
+ }
233
+
234
+ changedFiles.push({
235
+ path: path,
236
+ status: status,
237
+ diff: diff,
238
+ content: content,
239
+ ...((status === 'R' || status === 'C') && { old_path: oldPath }),
240
+ });
241
+ }
242
+
243
+ if (!nameStatusOutput.trim() && changedFiles.length === 0) {
244
+ console.log(chalk.yellow('No changes found to review.'));
245
+ return null;
246
+ }
247
+
248
+ // 3. Assemble payload
249
+ return {
250
+ repo_url: normalizeRepoUrl(repoUrl.trim()),
251
+ commit_messages: [], // No commits for uncommitted changes
252
+ changed_files: changedFiles,
253
+ source_branch: branchName,
254
+ };
255
+ } catch (error) {
256
+ console.error(chalk.red('Failed to analyze uncommitted changes:'), error.message);
257
+ if (error.stderr) {
258
+ console.error(chalk.red('Git Error:'), error.stderr);
259
+ }
260
+ return null;
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Main function to analyze local git changes and prepare review payload
266
+ * @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
267
+ * @param {string|null} ticketSystem - The ticket system to use (jira or ado), or null to skip ticket extraction
268
+ * @param {string[]|null} ignorePatterns - Array of glob patterns to ignore files
269
+ * @returns {Object|null} - The payload object ready for API submission, or null on error
270
+ */
271
+ export async function runLocalReview(targetBranch = null, ticketSystem = null, ignorePatterns = null) {
272
+ try {
273
+ // 1. Get Repo URL, current branch name, and repository root
274
+ const { stdout: repoUrl } = await execa('git', ['remote', 'get-url', 'origin']);
275
+ const { stdout: sourceBranch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
276
+ const branchName = sourceBranch.trim();
277
+
278
+ // Get the repository root directory - we'll run all git commands from there
279
+ const { stdout: repoRoot } = await execa('git', ['rev-parse', '--show-toplevel']);
280
+ const repoRootPath = repoRoot.trim();
281
+
282
+ // If a branch is provided, check it exists and try to fetch latest remote version
283
+ let targetBranchRef = targetBranch; // Will be updated to origin/branch if remote exists
284
+ if (targetBranch) {
285
+ // Check if the branch exists locally
286
+ try {
287
+ await execa('git', ['rev-parse', '--verify', targetBranch]);
288
+ } catch (error) {
289
+ console.error(chalk.red(`Branch '${targetBranch}' does not exist locally.`));
290
+ console.error(chalk.gray(`Please check out the branch first or specify a different one.`));
291
+ return null;
292
+ }
293
+
294
+ // Try to fetch the latest changes from remote (non-destructive)
295
+ try {
296
+ console.log(chalk.gray(`Fetching latest changes for branch '${targetBranch}'...`));
297
+ await execa('git', ['fetch', 'origin', targetBranch]);
298
+
299
+ // If fetch succeeded, use the remote-tracking branch for comparison
300
+ // This is safer as it doesn't modify the user's local branch
301
+ targetBranchRef = `origin/${targetBranch}`;
302
+ console.log(chalk.gray(`Using remote-tracking branch 'origin/${targetBranch}' for comparison.`));
303
+ } catch (fetchError) {
304
+ console.warn(chalk.yellow(`Could not fetch remote branch 'origin/${targetBranch}'.`));
305
+ console.warn(chalk.gray(`Proceeding with local branch '${targetBranch}' for comparison.`));
306
+ // targetBranchRef stays as targetBranch (local branch)
307
+ }
308
+ }
309
+
310
+ let mergeBase;
311
+
312
+ // 2. If no target branch, use git reflog to find fork point
313
+ if (!targetBranch) {
314
+ try {
315
+ // Use git reflog to find where the branch was created
316
+ const { stdout: reflog } = await execa('git', ['reflog', 'show', '--no-abbrev-commit', branchName]);
317
+ const lines = reflog.split('\n');
318
+
319
+ // Look for the branch creation point (last line in reflog)
320
+ const creationLine = lines[lines.length - 1];
321
+ if (creationLine) {
322
+ const match = creationLine.match(/^([a-f0-9]{40})/);
323
+ if (match) {
324
+ mergeBase = match[1];
325
+ console.log(chalk.gray(`Auto-detected fork point from reflog: ${mergeBase.substring(0, 7)}`));
326
+ }
327
+ }
328
+
329
+ if (!mergeBase) {
330
+ throw new Error('Could not find fork point in reflog');
331
+ }
332
+ } catch (error) {
333
+ console.error(chalk.red('Could not auto-detect fork point. Please specify a target branch.'));
334
+ console.error(chalk.gray('Usage: kk review <target-branch>'));
335
+ return null;
336
+ }
337
+ } else {
338
+ // 3. Use specified target branch (either remote-tracking or local)
339
+ const { stdout: base } = await execa('git', ['merge-base', targetBranchRef, 'HEAD']);
340
+ mergeBase = base.trim();
341
+ console.log(
342
+ chalk.gray(
343
+ `Comparing against ${targetBranchRef} (merge-base: ${mergeBase.substring(0, 7)})...`
344
+ )
345
+ );
346
+ }
347
+
348
+ const diffRange = `${mergeBase}..HEAD`;
349
+ console.log(chalk.gray(`Analyzing commits from ${mergeBase.substring(0, 7)} to HEAD...`));
350
+
351
+ // 3. Get Commit Messages with proper delimiter
352
+ const { stdout: logOutput } = await execa('git', ['log', '--pretty=%B---EOC---', diffRange], { cwd: repoRootPath });
353
+ const commitMessages = logOutput
354
+ .split('---EOC---')
355
+ .map((msg) => msg.trim())
356
+ .filter(Boolean);
357
+
358
+ // 4. Get changed files and their status
359
+ const { stdout: nameStatusOutput } = await execa('git', ['diff', '--name-status', diffRange], { cwd: repoRootPath });
360
+ const fileList = parseNameStatus(nameStatusOutput);
361
+
362
+ // Filter out ignored files
363
+ let filteredFileList = fileList;
364
+ let ignoredCount = 0;
365
+ if (ignorePatterns && ignorePatterns.length > 0) {
366
+ filteredFileList = fileList.filter(file => {
367
+ const ignored = shouldIgnoreFile(file.path, ignorePatterns);
368
+ if (ignored) {
369
+ ignoredCount++;
370
+ console.log(chalk.gray(` Ignoring: ${file.path}`));
371
+ }
372
+ return !ignored;
373
+ });
374
+ }
375
+
376
+ if (ignoredCount > 0) {
377
+ console.log(chalk.gray(`Ignored ${ignoredCount} file(s) based on patterns\n`));
378
+ }
379
+
380
+ console.log(chalk.gray(`Collecting diffs for ${filteredFileList.length} file(s)...`));
381
+
382
+ const changedFiles = [];
383
+ for (const file of filteredFileList) {
384
+ const { status, path, oldPath } = file;
385
+
386
+ // Run git commands from the repository root to handle all file paths correctly
387
+ // 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], { cwd: repoRootPath });
389
+
390
+ // Get the original content from the base commit
391
+ let content = '';
392
+ if (status !== 'A') {
393
+ // Added files have no original content
394
+ try {
395
+ const { stdout: originalContent } = await execa('git', [
396
+ 'show',
397
+ `${mergeBase.trim()}:${oldPath}`,
398
+ ], { cwd: repoRootPath });
399
+ content = originalContent;
400
+ } catch (e) {
401
+ // This can happen if a file was added and modified in the same branch
402
+ console.warn(
403
+ chalk.yellow(`Could not get original content for ${oldPath}. Assuming it was added.`)
404
+ );
405
+ }
406
+ }
407
+
408
+ // Truncate content
409
+ content = truncateContent(content);
410
+
411
+ // For deleted files, truncate the diff as well
412
+ let truncatedDiff = diff;
413
+ if (status === 'D') {
414
+ truncatedDiff = truncateContent(diff);
415
+ }
416
+
417
+ changedFiles.push({
418
+ path: path,
419
+ status: status,
420
+ diff: truncatedDiff,
421
+ content: content,
422
+ ...((status === 'R' || status === 'C') && { old_path: oldPath }), // Include old_path for renames and copies
423
+ });
424
+ }
425
+
426
+ // 5. Assemble the final payload
427
+ return {
428
+ repo_url: normalizeRepoUrl(repoUrl.trim()),
429
+ commit_messages: commitMessages,
430
+ changed_files: changedFiles,
431
+ source_branch: branchName,
432
+ };
433
+ } catch (error) {
434
+ console.error(chalk.red('Failed to run local review analysis:'), error.message);
435
+ if (error.stderr) {
436
+ console.error(chalk.red('Git Error:'), error.stderr);
437
+ }
438
+ return null; // Return null to indicate failure
439
+ }
440
+ }