korekt-cli 0.8.4 โ†’ 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korekt-cli",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/formatter.js CHANGED
@@ -29,6 +29,8 @@ const CATEGORY_ICONS = {
29
29
  documentation: '๐Ÿ“',
30
30
  test_coverage: '๐Ÿงช',
31
31
  readability: '๐Ÿ“–',
32
+ architectural: '๐Ÿ—๏ธ',
33
+ file_structure: '๐Ÿ“',
32
34
  default: 'โš™๏ธ', // Default icon
33
35
  };
34
36
 
package/src/git-logic.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execa } from 'execa';
2
2
  import chalk from 'chalk';
3
+ import { detectCIProvider, getPrUrl } from './utils.js';
3
4
 
4
5
  /**
5
6
  * Truncate content to a maximum number of lines using "head and tail".
@@ -244,6 +245,8 @@ export async function runUncommittedReview(mode = 'unstaged') {
244
245
  changed_files: changedFiles,
245
246
  source_branch: branchName,
246
247
  changed_lines: calculateChangedLines(changedFiles),
248
+ is_ci: detectCIProvider() !== null,
249
+ pr_url: null, // Uncommitted changes are never part of a PR
247
250
  };
248
251
  } catch (error) {
249
252
  console.error(chalk.red('Failed to analyze uncommitted changes:'), error.message);
@@ -524,6 +527,8 @@ export async function runLocalReview(targetBranch = null, ignorePatterns = null)
524
527
  author_name,
525
528
  contributors,
526
529
  changed_lines: calculateChangedLines(changedFiles),
530
+ is_ci: detectCIProvider() !== null,
531
+ pr_url: getPrUrl(),
527
532
  };
528
533
  } catch (error) {
529
534
  console.error(chalk.red('Failed to run local review analysis:'), error.message);
@@ -9,6 +9,7 @@ import {
9
9
  getContributors,
10
10
  } from './git-logic.js';
11
11
  import { execa } from 'execa';
12
+ import { detectCIProvider } from './utils.js';
12
13
 
13
14
  describe('parseNameStatus', () => {
14
15
  it('should correctly parse M, A, and D statuses', () => {
@@ -830,3 +831,183 @@ describe('changed_lines calculation', () => {
830
831
  expect(result.changed_lines).toBe(0);
831
832
  });
832
833
  });
834
+
835
+ describe('is_ci flag in payload', () => {
836
+ beforeEach(() => {
837
+ vi.mock('execa');
838
+ vi.mock('./utils.js', () => ({
839
+ detectCIProvider: vi.fn(),
840
+ getPrUrl: vi.fn().mockReturnValue(null),
841
+ }));
842
+ });
843
+
844
+ afterEach(() => {
845
+ vi.restoreAllMocks();
846
+ });
847
+
848
+ it('should set is_ci to true when detectCIProvider returns a provider', async () => {
849
+ vi.mocked(detectCIProvider).mockReturnValue('github');
850
+
851
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
852
+ const command = [cmd, ...args].join(' ');
853
+
854
+ if (command.includes('remote get-url origin')) {
855
+ return { stdout: 'https://github.com/user/repo.git' };
856
+ }
857
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
858
+ return { stdout: 'feature-branch' };
859
+ }
860
+ if (command.includes('rev-parse --show-toplevel')) {
861
+ return { stdout: '/fake/repo/path' };
862
+ }
863
+ if (command.includes('diff --cached --name-status')) {
864
+ return { stdout: 'M\tfile.js' };
865
+ }
866
+ if (command.includes('diff --cached -U15 -- file.js')) {
867
+ return { stdout: 'diff --git a/file.js b/file.js\n+new line' };
868
+ }
869
+ if (command.includes('show HEAD:file.js')) {
870
+ return { stdout: 'old content' };
871
+ }
872
+
873
+ throw new Error(`Unmocked command: ${command}`);
874
+ });
875
+
876
+ const result = await runUncommittedReview('staged');
877
+
878
+ expect(result).toBeDefined();
879
+ expect(result.is_ci).toBe(true);
880
+ });
881
+
882
+ it('should set is_ci to false when detectCIProvider returns null', async () => {
883
+ vi.mocked(detectCIProvider).mockReturnValue(null);
884
+
885
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
886
+ const command = [cmd, ...args].join(' ');
887
+
888
+ if (command.includes('remote get-url origin')) {
889
+ return { stdout: 'https://github.com/user/repo.git' };
890
+ }
891
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
892
+ return { stdout: 'feature-branch' };
893
+ }
894
+ if (command.includes('rev-parse --show-toplevel')) {
895
+ return { stdout: '/fake/repo/path' };
896
+ }
897
+ if (command.includes('diff --cached --name-status')) {
898
+ return { stdout: 'M\tfile.js' };
899
+ }
900
+ if (command.includes('diff --cached -U15 -- file.js')) {
901
+ return { stdout: 'diff --git a/file.js b/file.js\n+new line' };
902
+ }
903
+ if (command.includes('show HEAD:file.js')) {
904
+ return { stdout: 'old content' };
905
+ }
906
+
907
+ throw new Error(`Unmocked command: ${command}`);
908
+ });
909
+
910
+ const result = await runUncommittedReview('staged');
911
+
912
+ expect(result).toBeDefined();
913
+ expect(result.is_ci).toBe(false);
914
+ });
915
+
916
+ it('should include is_ci in runLocalReview payload', async () => {
917
+ vi.mocked(detectCIProvider).mockReturnValue('azure');
918
+
919
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
920
+ const command = [cmd, ...args].join(' ');
921
+
922
+ if (command.includes('remote get-url origin')) {
923
+ return { stdout: 'https://github.com/user/repo.git' };
924
+ }
925
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
926
+ return { stdout: 'feature-branch' };
927
+ }
928
+ if (command.includes('rev-parse --show-toplevel')) {
929
+ return { stdout: '/path/to/repo' };
930
+ }
931
+ if (command.includes('rev-parse --verify main')) {
932
+ return { stdout: 'commit-hash' };
933
+ }
934
+ if (command === 'git fetch origin main') {
935
+ return { stdout: '' };
936
+ }
937
+ if (command.includes('merge-base origin/main HEAD')) {
938
+ return { stdout: 'abc123' };
939
+ }
940
+ if (command.includes('log --no-merges --pretty=%B---EOC---')) {
941
+ return { stdout: 'feat: add feature---EOC---' };
942
+ }
943
+ if (command.includes('log --no-merges --format=%ae|%an')) {
944
+ return { stdout: 'user@example.com|User Name' };
945
+ }
946
+ if (command.includes('diff --name-status')) {
947
+ return { stdout: 'M\tfile.js' };
948
+ }
949
+ if (command.includes('diff -U15')) {
950
+ return { stdout: 'diff content' };
951
+ }
952
+ if (command.includes('show abc123:file.js')) {
953
+ return { stdout: 'original content' };
954
+ }
955
+
956
+ return { stdout: '' };
957
+ });
958
+
959
+ const result = await runLocalReview('main');
960
+
961
+ expect(result).toBeDefined();
962
+ expect(result.is_ci).toBe(true);
963
+ });
964
+
965
+ it('should set is_ci to false in runLocalReview when not in CI', async () => {
966
+ vi.mocked(detectCIProvider).mockReturnValue(null);
967
+
968
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
969
+ const command = [cmd, ...args].join(' ');
970
+
971
+ if (command.includes('remote get-url origin')) {
972
+ return { stdout: 'https://github.com/user/repo.git' };
973
+ }
974
+ if (command.includes('rev-parse --abbrev-ref HEAD')) {
975
+ return { stdout: 'feature-branch' };
976
+ }
977
+ if (command.includes('rev-parse --show-toplevel')) {
978
+ return { stdout: '/path/to/repo' };
979
+ }
980
+ if (command.includes('rev-parse --verify main')) {
981
+ return { stdout: 'commit-hash' };
982
+ }
983
+ if (command === 'git fetch origin main') {
984
+ return { stdout: '' };
985
+ }
986
+ if (command.includes('merge-base origin/main HEAD')) {
987
+ return { stdout: 'abc123' };
988
+ }
989
+ if (command.includes('log --no-merges --pretty=%B---EOC---')) {
990
+ return { stdout: 'feat: add feature---EOC---' };
991
+ }
992
+ if (command.includes('log --no-merges --format=%ae|%an')) {
993
+ return { stdout: 'user@example.com|User Name' };
994
+ }
995
+ if (command.includes('diff --name-status')) {
996
+ return { stdout: 'M\tfile.js' };
997
+ }
998
+ if (command.includes('diff -U15')) {
999
+ return { stdout: 'diff content' };
1000
+ }
1001
+ if (command.includes('show abc123:file.js')) {
1002
+ return { stdout: 'original content' };
1003
+ }
1004
+
1005
+ return { stdout: '' };
1006
+ });
1007
+
1008
+ const result = await runLocalReview('main');
1009
+
1010
+ expect(result).toBeDefined();
1011
+ expect(result.is_ci).toBe(false);
1012
+ });
1013
+ });
package/src/index.js CHANGED
@@ -14,6 +14,10 @@ import { tmpdir } from 'os';
14
14
  import { runLocalReview } from './git-logic.js';
15
15
  import { getApiKey, setApiKey, getApiEndpoint, setApiEndpoint } from './config.js';
16
16
  import { formatReviewOutput } from './formatter.js';
17
+ import { detectCIProvider, truncateFileData, formatErrorOutput } from './utils.js';
18
+
19
+ // Re-export utilities for backward compatibility
20
+ export { detectCIProvider, truncateFileData, formatErrorOutput, getPrUrl } from './utils.js';
17
21
 
18
22
  const require = createRequire(import.meta.url);
19
23
  const { version } = require('../package.json');
@@ -26,44 +30,6 @@ const { version } = require('../package.json');
26
30
  const log = (msg) => process.stderr.write(msg + '\n');
27
31
  const output = (msg) => process.stdout.write(msg + '\n');
28
32
 
29
- /**
30
- * Truncates file data (diff and content) for display purposes
31
- * @param {Object} file - File object with path, status, diff, content, etc.
32
- * @param {number} maxLength - Maximum length before truncation (default: 500)
33
- * @returns {Object} File object with truncated diff and content
34
- */
35
- export function truncateFileData(file, maxLength = 500) {
36
- return {
37
- path: file.path,
38
- status: file.status,
39
- ...(file.old_path && { old_path: file.old_path }),
40
- diff:
41
- file.diff.length > maxLength
42
- ? `${file.diff.substring(0, maxLength)}... [truncated ${file.diff.length - maxLength} chars]`
43
- : file.diff,
44
- content:
45
- file.content.length > maxLength
46
- ? `${file.content.substring(0, maxLength)}... [truncated ${file.content.length - maxLength} chars]`
47
- : file.content,
48
- };
49
- }
50
-
51
- /**
52
- * Formats error object for JSON output
53
- * @param {Error} error - Error object from axios or other source
54
- * @returns {Object} Formatted error output with success: false
55
- */
56
- export function formatErrorOutput(error) {
57
- return {
58
- success: false,
59
- error: error.message,
60
- ...(error.response && {
61
- status: error.response.status,
62
- data: error.response.data,
63
- }),
64
- };
65
- }
66
-
67
33
  /**
68
34
  * Ask for user confirmation before proceeding
69
35
  */
@@ -84,23 +50,6 @@ async function confirmAction(message) {
84
50
  });
85
51
  }
86
52
 
87
- /**
88
- * Detect CI provider from environment variables
89
- * @returns {string|null} Provider name or null if not detected
90
- */
91
- export function detectCIProvider() {
92
- if (process.env.GITHUB_TOKEN && process.env.GITHUB_REPOSITORY) {
93
- return 'github';
94
- }
95
- if (process.env.SYSTEM_ACCESSTOKEN && process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
96
- return 'azure';
97
- }
98
- if (process.env.BITBUCKET_REPO_SLUG && process.env.BITBUCKET_PR_ID) {
99
- return 'bitbucket';
100
- }
101
- return null;
102
- }
103
-
104
53
  /**
105
54
  * Run the CI integration script to post comments
106
55
  * @param {string} provider - CI provider (github, azure, bitbucket)
package/src/index.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { truncateFileData, formatErrorOutput, detectCIProvider } from './index.js';
2
+ import { truncateFileData, formatErrorOutput, detectCIProvider, getPrUrl } from './index.js';
3
3
 
4
4
  describe('CLI JSON output mode', () => {
5
5
  let stdoutSpy;
@@ -438,3 +438,111 @@ describe('--comment flag behavior', () => {
438
438
  expect(shouldShowConfirmation).toBe(true);
439
439
  });
440
440
  });
441
+
442
+ describe('getPrUrl', () => {
443
+ const originalEnv = process.env;
444
+
445
+ beforeEach(() => {
446
+ // Reset environment before each test
447
+ process.env = { ...originalEnv };
448
+ // Clear all PR-related env vars
449
+ delete process.env.GITHUB_REPOSITORY;
450
+ delete process.env.PR_NUMBER;
451
+ delete process.env.BITBUCKET_WORKSPACE;
452
+ delete process.env.BITBUCKET_REPO_SLUG;
453
+ delete process.env.BITBUCKET_PR_ID;
454
+ delete process.env.SYSTEM_COLLECTIONURI;
455
+ delete process.env.SYSTEM_TEAMPROJECT;
456
+ delete process.env.BUILD_REPOSITORY_NAME;
457
+ delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID;
458
+ });
459
+
460
+ afterEach(() => {
461
+ process.env = originalEnv;
462
+ });
463
+
464
+ it('should return GitHub PR URL when GitHub env vars are set', () => {
465
+ process.env.GITHUB_REPOSITORY = 'owner/repo';
466
+ process.env.PR_NUMBER = '123';
467
+
468
+ expect(getPrUrl()).toBe('https://github.com/owner/repo/pull/123');
469
+ });
470
+
471
+ it('should return Bitbucket PR URL when Bitbucket env vars are set', () => {
472
+ process.env.BITBUCKET_WORKSPACE = 'myworkspace';
473
+ process.env.BITBUCKET_REPO_SLUG = 'myrepo';
474
+ process.env.BITBUCKET_PR_ID = '456';
475
+
476
+ expect(getPrUrl()).toBe('https://bitbucket.org/myworkspace/myrepo/pull-requests/456');
477
+ });
478
+
479
+ it('should return Azure DevOps PR URL when Azure env vars are set', () => {
480
+ process.env.SYSTEM_COLLECTIONURI = 'https://dev.azure.com/myorg/';
481
+ process.env.SYSTEM_TEAMPROJECT = 'myproject';
482
+ process.env.BUILD_REPOSITORY_NAME = 'myrepo';
483
+ process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = '789';
484
+
485
+ expect(getPrUrl()).toBe('https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/789');
486
+ });
487
+
488
+ it('should strip trailing slash from Azure DevOps collection URI', () => {
489
+ process.env.SYSTEM_COLLECTIONURI = 'https://dev.azure.com/myorg/';
490
+ process.env.SYSTEM_TEAMPROJECT = 'myproject';
491
+ process.env.BUILD_REPOSITORY_NAME = 'myrepo';
492
+ process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = '789';
493
+
494
+ const url = getPrUrl();
495
+ expect(url).not.toContain('myorg//myproject');
496
+ expect(url).toContain('myorg/myproject');
497
+ });
498
+
499
+ it('should URL-encode spaces in Azure DevOps project and repo names', () => {
500
+ process.env.SYSTEM_COLLECTIONURI = 'https://dev.azure.com/myorg/';
501
+ process.env.SYSTEM_TEAMPROJECT = 'My Project';
502
+ process.env.BUILD_REPOSITORY_NAME = 'My Repo';
503
+ process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = '789';
504
+
505
+ expect(getPrUrl()).toBe(
506
+ 'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo/pullrequest/789'
507
+ );
508
+ });
509
+
510
+ it('should return null when no PR env vars are set', () => {
511
+ expect(getPrUrl()).toBe(null);
512
+ });
513
+
514
+ it('should return null when only partial GitHub env vars are set', () => {
515
+ process.env.GITHUB_REPOSITORY = 'owner/repo';
516
+ // PR_NUMBER not set
517
+
518
+ expect(getPrUrl()).toBe(null);
519
+ });
520
+
521
+ it('should return null when only partial Bitbucket env vars are set', () => {
522
+ process.env.BITBUCKET_WORKSPACE = 'myworkspace';
523
+ process.env.BITBUCKET_REPO_SLUG = 'myrepo';
524
+ // BITBUCKET_PR_ID not set
525
+
526
+ expect(getPrUrl()).toBe(null);
527
+ });
528
+
529
+ it('should return null when only partial Azure env vars are set', () => {
530
+ process.env.SYSTEM_COLLECTIONURI = 'https://dev.azure.com/myorg/';
531
+ process.env.SYSTEM_TEAMPROJECT = 'myproject';
532
+ // BUILD_REPOSITORY_NAME and SYSTEM_PULLREQUEST_PULLREQUESTID not set
533
+
534
+ expect(getPrUrl()).toBe(null);
535
+ });
536
+
537
+ it('should prioritize GitHub over other providers when multiple are set', () => {
538
+ // Set all providers
539
+ process.env.GITHUB_REPOSITORY = 'owner/repo';
540
+ process.env.PR_NUMBER = '123';
541
+ process.env.BITBUCKET_WORKSPACE = 'myworkspace';
542
+ process.env.BITBUCKET_REPO_SLUG = 'myrepo';
543
+ process.env.BITBUCKET_PR_ID = '456';
544
+
545
+ // GitHub should be detected first due to check order
546
+ expect(getPrUrl()).toBe('https://github.com/owner/repo/pull/123');
547
+ });
548
+ });
package/src/utils.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Detect CI provider from environment variables
3
+ * @returns {string|null} Provider name or null if not detected
4
+ */
5
+ export function detectCIProvider() {
6
+ if (process.env.GITHUB_TOKEN && process.env.GITHUB_REPOSITORY) {
7
+ return 'github';
8
+ }
9
+ if (process.env.SYSTEM_ACCESSTOKEN && process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
10
+ return 'azure';
11
+ }
12
+ if (process.env.BITBUCKET_REPO_SLUG && process.env.BITBUCKET_PR_ID) {
13
+ return 'bitbucket';
14
+ }
15
+ return null;
16
+ }
17
+
18
+ /**
19
+ * Build PR URL from CI environment variables
20
+ * @returns {string|null} Full PR URL or null if not in CI PR context
21
+ */
22
+ export function getPrUrl() {
23
+ // GitHub Actions
24
+ if (process.env.GITHUB_REPOSITORY && process.env.PR_NUMBER) {
25
+ return `https://github.com/${process.env.GITHUB_REPOSITORY}/pull/${process.env.PR_NUMBER}`;
26
+ }
27
+ // Bitbucket Pipelines
28
+ if (
29
+ process.env.BITBUCKET_WORKSPACE &&
30
+ process.env.BITBUCKET_REPO_SLUG &&
31
+ process.env.BITBUCKET_PR_ID
32
+ ) {
33
+ return `https://bitbucket.org/${process.env.BITBUCKET_WORKSPACE}/${process.env.BITBUCKET_REPO_SLUG}/pull-requests/${process.env.BITBUCKET_PR_ID}`;
34
+ }
35
+ // Azure DevOps Pipelines
36
+ if (
37
+ process.env.SYSTEM_COLLECTIONURI &&
38
+ process.env.SYSTEM_TEAMPROJECT &&
39
+ process.env.BUILD_REPOSITORY_NAME &&
40
+ process.env.SYSTEM_PULLREQUEST_PULLREQUESTID
41
+ ) {
42
+ const collectionUri = process.env.SYSTEM_COLLECTIONURI.replace(/\/$/, '');
43
+ return `${collectionUri}/${encodeURIComponent(process.env.SYSTEM_TEAMPROJECT)}/_git/${encodeURIComponent(process.env.BUILD_REPOSITORY_NAME)}/pullrequest/${process.env.SYSTEM_PULLREQUEST_PULLREQUESTID}`;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Truncates file data (diff and content) for display purposes
50
+ * @param {Object} file - File object with path, status, diff, content, etc.
51
+ * @param {number} maxLength - Maximum length before truncation (default: 500)
52
+ * @returns {Object} File object with truncated diff and content
53
+ */
54
+ export function truncateFileData(file, maxLength = 500) {
55
+ return {
56
+ path: file.path,
57
+ status: file.status,
58
+ ...(file.old_path && { old_path: file.old_path }),
59
+ diff:
60
+ file.diff.length > maxLength
61
+ ? `${file.diff.substring(0, maxLength)}... [truncated ${file.diff.length - maxLength} chars]`
62
+ : file.diff,
63
+ content:
64
+ file.content.length > maxLength
65
+ ? `${file.content.substring(0, maxLength)}... [truncated ${file.content.length - maxLength} chars]`
66
+ : file.content,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Formats error object for JSON output
72
+ * @param {Error} error - Error object from axios or other source
73
+ * @returns {Object} Formatted error output with success: false
74
+ */
75
+ export function formatErrorOutput(error) {
76
+ return {
77
+ success: false,
78
+ error: error.message,
79
+ ...(error.response && {
80
+ status: error.response.status,
81
+ data: error.response.data,
82
+ }),
83
+ };
84
+ }