korekt-cli 0.5.0 → 0.6.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.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "AI-powered code review CLI - Keep your kode korekt",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/git-logic.js CHANGED
@@ -280,6 +280,55 @@ export async function runUncommittedReview(
280
280
  }
281
281
  }
282
282
 
283
+ /**
284
+ * Extract contributors from git commits in a range
285
+ * Returns the author (most commits) and full list of contributors
286
+ * @param {string} diffRange - The git range to analyze (e.g., "abc123..HEAD")
287
+ * @param {string} repoRootPath - The repository root directory
288
+ * @returns {Object} - { author_email, author_name, contributors[] }
289
+ */
290
+ export async function getContributors(diffRange, repoRootPath) {
291
+ try {
292
+ // Get all commit authors with email and name
293
+ const { stdout: authorOutput } = await execa('git', ['log', '--format=%ae|%an', diffRange], {
294
+ cwd: repoRootPath,
295
+ });
296
+
297
+ if (!authorOutput.trim()) {
298
+ return { author_email: null, author_name: null, contributors: [] };
299
+ }
300
+
301
+ const lines = authorOutput.trim().split('\n').filter(Boolean);
302
+
303
+ // Count commits per email and track name
304
+ const contributorMap = new Map();
305
+ for (const line of lines) {
306
+ const [email, name] = line.split('|');
307
+ if (!email) continue;
308
+
309
+ if (!contributorMap.has(email)) {
310
+ contributorMap.set(email, { email, name: name || email, commits: 0 });
311
+ }
312
+ contributorMap.get(email).commits++;
313
+ }
314
+
315
+ // Convert to array and sort by commits (descending)
316
+ const contributors = Array.from(contributorMap.values()).sort((a, b) => b.commits - a.commits);
317
+
318
+ // Author = most commits
319
+ const author = contributors[0] || null;
320
+
321
+ return {
322
+ author_email: author?.email || null,
323
+ author_name: author?.name || null,
324
+ contributors,
325
+ };
326
+ } catch (error) {
327
+ console.warn(chalk.yellow('Could not extract contributors:'), error.message);
328
+ return { author_email: null, author_name: null, contributors: [] };
329
+ }
330
+ }
331
+
283
332
  /**
284
333
  * Main function to analyze local git changes and prepare review payload
285
334
  * @param {string|null} targetBranch - The branch to compare against. If null, uses git reflog to find fork point.
@@ -486,12 +535,21 @@ export async function runLocalReview(
486
535
  });
487
536
  }
488
537
 
489
- // 5. Assemble the final payload
538
+ // 5. Get contributors from commits
539
+ const { author_email, author_name, contributors } = await getContributors(
540
+ diffRange,
541
+ repoRootPath
542
+ );
543
+
544
+ // 6. Assemble the final payload
490
545
  return {
491
546
  repo_url: normalizeRepoUrl(repoUrl.trim()),
492
547
  commit_messages: commitMessages,
493
548
  changed_files: changedFiles,
494
549
  source_branch: branchName,
550
+ author_email,
551
+ author_name,
552
+ contributors,
495
553
  };
496
554
  } catch (error) {
497
555
  console.error(chalk.red('Failed to run local review analysis:'), error.message);
@@ -6,6 +6,7 @@ import {
6
6
  truncateContent,
7
7
  normalizeRepoUrl,
8
8
  shouldIgnoreFile,
9
+ getContributors,
9
10
  } from './git-logic.js';
10
11
  import { execa } from 'execa';
11
12
 
@@ -571,3 +572,138 @@ describe('shouldIgnoreFile', () => {
571
572
  expect(shouldIgnoreFile('file.js', pattern)).toBe(false);
572
573
  });
573
574
  });
575
+
576
+ describe('getContributors', () => {
577
+ beforeEach(() => {
578
+ vi.mock('execa');
579
+ });
580
+
581
+ afterEach(() => {
582
+ vi.restoreAllMocks();
583
+ });
584
+
585
+ it('should extract single contributor with commit count', async () => {
586
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
587
+ const command = [cmd, ...args].join(' ');
588
+ if (command.includes('log --format=%ae|%an')) {
589
+ return {
590
+ stdout: 'john@example.com|John Doe\njohn@example.com|John Doe\njohn@example.com|John Doe',
591
+ };
592
+ }
593
+ throw new Error(`Unmocked command: ${command}`);
594
+ });
595
+
596
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
597
+
598
+ expect(result.author_email).toBe('john@example.com');
599
+ expect(result.author_name).toBe('John Doe');
600
+ expect(result.contributors).toHaveLength(1);
601
+ expect(result.contributors[0]).toEqual({
602
+ email: 'john@example.com',
603
+ name: 'John Doe',
604
+ commits: 3,
605
+ });
606
+ });
607
+
608
+ it('should identify author as contributor with most commits', async () => {
609
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
610
+ const command = [cmd, ...args].join(' ');
611
+ if (command.includes('log --format=%ae|%an')) {
612
+ return {
613
+ stdout: [
614
+ 'alice@example.com|Alice Smith',
615
+ 'alice@example.com|Alice Smith',
616
+ 'alice@example.com|Alice Smith',
617
+ 'alice@example.com|Alice Smith',
618
+ 'alice@example.com|Alice Smith',
619
+ 'bob@example.com|Bob Jones',
620
+ 'bob@example.com|Bob Jones',
621
+ 'charlie@example.com|Charlie Brown',
622
+ ].join('\n'),
623
+ };
624
+ }
625
+ throw new Error(`Unmocked command: ${command}`);
626
+ });
627
+
628
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
629
+
630
+ expect(result.author_email).toBe('alice@example.com');
631
+ expect(result.author_name).toBe('Alice Smith');
632
+ expect(result.contributors).toHaveLength(3);
633
+ expect(result.contributors[0].commits).toBe(5); // Alice - most commits
634
+ expect(result.contributors[1].commits).toBe(2); // Bob
635
+ expect(result.contributors[2].commits).toBe(1); // Charlie
636
+ });
637
+
638
+ it('should handle empty commit range', async () => {
639
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
640
+ const command = [cmd, ...args].join(' ');
641
+ if (command.includes('log --format=%ae|%an')) {
642
+ return { stdout: '' };
643
+ }
644
+ throw new Error(`Unmocked command: ${command}`);
645
+ });
646
+
647
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
648
+
649
+ expect(result.author_email).toBeNull();
650
+ expect(result.author_name).toBeNull();
651
+ expect(result.contributors).toEqual([]);
652
+ });
653
+
654
+ it('should handle git command errors gracefully', async () => {
655
+ vi.mocked(execa).mockImplementation(async () => {
656
+ throw new Error('Git error');
657
+ });
658
+
659
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
660
+
661
+ expect(result.author_email).toBeNull();
662
+ expect(result.author_name).toBeNull();
663
+ expect(result.contributors).toEqual([]);
664
+ });
665
+
666
+ it('should handle missing name in git log', async () => {
667
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
668
+ const command = [cmd, ...args].join(' ');
669
+ if (command.includes('log --format=%ae|%an')) {
670
+ return { stdout: 'user@example.com|' };
671
+ }
672
+ throw new Error(`Unmocked command: ${command}`);
673
+ });
674
+
675
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
676
+
677
+ expect(result.author_email).toBe('user@example.com');
678
+ expect(result.author_name).toBe('user@example.com'); // Falls back to email
679
+ expect(result.contributors[0].name).toBe('user@example.com');
680
+ });
681
+
682
+ it('should sort contributors by commit count descending', async () => {
683
+ vi.mocked(execa).mockImplementation(async (cmd, args) => {
684
+ const command = [cmd, ...args].join(' ');
685
+ if (command.includes('log --format=%ae|%an')) {
686
+ return {
687
+ stdout: [
688
+ 'c@example.com|C User',
689
+ 'a@example.com|A User',
690
+ 'a@example.com|A User',
691
+ 'a@example.com|A User',
692
+ 'b@example.com|B User',
693
+ 'b@example.com|B User',
694
+ ].join('\n'),
695
+ };
696
+ }
697
+ throw new Error(`Unmocked command: ${command}`);
698
+ });
699
+
700
+ const result = await getContributors('abc123..HEAD', '/path/to/repo');
701
+
702
+ expect(result.contributors[0].email).toBe('a@example.com');
703
+ expect(result.contributors[0].commits).toBe(3);
704
+ expect(result.contributors[1].email).toBe('b@example.com');
705
+ expect(result.contributors[1].commits).toBe(2);
706
+ expect(result.contributors[2].email).toBe('c@example.com');
707
+ expect(result.contributors[2].commits).toBe(1);
708
+ });
709
+ });
package/src/index.js CHANGED
@@ -200,7 +200,7 @@ program
200
200
  log(` Commits: ${chalk.cyan(payload.commit_messages.length)}`);
201
201
  log(` Files: ${chalk.cyan(payload.changed_files.length)}\n`);
202
202
 
203
- log(chalk.bold(' Files to review:'));
203
+ log(chalk.bold(` ${payload.changed_files.length} files to review:`));
204
204
  payload.changed_files.forEach((file) => {
205
205
  const statusColor =
206
206
  {
@@ -364,7 +364,7 @@ async function reviewUncommitted(mode, options) {
364
364
  if (!options.json) {
365
365
  log(chalk.yellow('\nšŸ“‹ Ready to submit uncommitted changes for review:\n'));
366
366
  log(chalk.gray(' Comparing against HEAD (last commit)\n'));
367
- log(chalk.bold(' Files to review:'));
367
+ log(chalk.bold(` ${payload.changed_files.length} files to review:`));
368
368
  payload.changed_files.forEach((file) => {
369
369
  const statusColor =
370
370
  {