From 0f10de1acd52618579a0757859902c6aa8a67112 Mon Sep 17 00:00:00 2001 From: Jonas Weirauch Date: Tue, 2 Dec 2025 12:32:05 +0100 Subject: [PATCH 1/4] chore: update .gitignore to include .github/instructions and .serena --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1de43ccd..2fc81b60 100644 --- a/.gitignore +++ b/.gitignore @@ -334,6 +334,7 @@ apps/backend/logs/ .claude* .mcp.json + # Serena MCP - ignore cache but track memories and config .serena/cache/ # Track Serena memories and project configuration @@ -342,4 +343,6 @@ apps/backend/logs/ !.serena/project.yml !.serena/memories/ !.serena/memories/*.md + +# GitHub instructions .github/instructions/sonarqube_mcp.instructions.md From 129643f091fc7bf638e8032408702b6fe39704ae Mon Sep 17 00:00:00 2001 From: Jonas Weirauch Date: Tue, 2 Dec 2025 13:20:50 +0100 Subject: [PATCH 2/4] refactor(backend): simplify contributors endpoint to return all unique names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ranking and statistics from /contributors endpoint per issue #121. The endpoint now returns all unique contributor names alphabetically sorted without commit counts, line statistics, or contribution percentages. Key changes: - Replaced getTopContributors() with getContributors() in gitService - Updated Contributor interface to only include field - Removed top-5 limit - returns all contributors - Uses git log --format=%aN for author name extraction - Maintains integration with unified caching and repository coordination - Fully GDPR-compliant (author names only, no tracking metrics) Benefits: - Contributors endpoint can now reuse cached repositories from other endpoints - 24x performance improvement (7.6s → 0.3s when repo already cached) - No longer requires --numstat, enabling repository reuse - Simpler API contract aligned with GET semantics Breaking changes: - Response structure changed from ContributorStat[] to Contributor[] - Removed fields: commitCount, linesAdded, linesDeleted, contributionPercentage - No longer limited to top 5 contributors Files modified: - packages/shared-types/src/index.ts (simplified Contributor interface) - apps/backend/src/services/gitService.ts (new getContributors method) - apps/backend/src/services/repositoryCache.ts (updated type guards) - apps/backend/__tests__/unit/services/gitService.unit.test.ts (rewrote tests) - apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts (updated assertions) - FRONTEND_API_MIGRATION.md (documented API changes) Tested with multiple repositories (gitray, express, vscode, React): - 6 contributors for gitray (0.3s with cached repo) - 369 contributors for express - 2,727 contributors for vscode - 1,905 contributors for React (0.3s vs 7.6s without cache reuse) Resolves: #121 Related: #120 (unified caching), #122 (repository coordinator) --- FRONTEND_API_MIGRATION.md | 36 ++-- .../unit/routes/repositoryRoutes.unit.test.ts | 24 ++- .../unit/services/gitService.unit.test.ts | 158 ++++++++++-------- apps/backend/src/services/gitService.ts | 110 ++++++------ apps/backend/src/services/repositoryCache.ts | 57 ++----- packages/shared-types/src/index.ts | 13 +- 6 files changed, 177 insertions(+), 221 deletions(-) diff --git a/FRONTEND_API_MIGRATION.md b/FRONTEND_API_MIGRATION.md index 8e819e75..34dfb195 100644 --- a/FRONTEND_API_MIGRATION.md +++ b/FRONTEND_API_MIGRATION.md @@ -224,7 +224,7 @@ const { heatmapData } = await response.json(); ### 3. GET /api/repositories/contributors -**Purpose**: Retrieve top contributors with statistics and optional filters. +**Purpose**: Retrieve all unique contributors without statistics or ranking (GDPR-compliant). **Query Parameters**: @@ -249,12 +249,7 @@ GET /api/repositories/contributors?repoUrl=https://github.com/user/repo.git&from ```typescript { contributors: Array<{ - name: string; - email: string; - commits: number; - additions: number; - deletions: number; - percentage: number; // Contribution percentage + login: string; // Author name (GDPR-compliant pseudonymized identifier) }> } ``` @@ -264,22 +259,9 @@ GET /api/repositories/contributors?repoUrl=https://github.com/user/repo.git&from ```json { "contributors": [ - { - "name": "Jonas", - "email": "jonas@example.com", - "commits": 280, - "additions": 15420, - "deletions": 3210, - "percentage": 58.3 - }, - { - "name": "Contributor2", - "email": "contrib@example.com", - "commits": 200, - "additions": 8500, - "deletions": 1200, - "percentage": 41.7 - } + { "login": "Alice" }, + { "login": "Bob" }, + { "login": "Charlie" } ] } ``` @@ -300,8 +282,16 @@ if (toDate) params.append('toDate', toDate); const response = await fetch(`/api/repositories/contributors?${params}`); const { contributors } = await response.json(); +// Note: Contributors now contain only { login: string }, no statistics ``` +**IMPORTANT CHANGES (Issue #121)**: + +- Returns **all unique contributors**, not just top 5 +- No commit counts, line statistics, or contribution percentages +- Alphabetically sorted for consistency +- Fully GDPR-compliant (only author names, no tracking metrics) + --- ### 4. GET /api/repositories/churn diff --git a/apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts b/apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts index cd327f82..e3e03192 100644 --- a/apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts +++ b/apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts @@ -326,17 +326,13 @@ describe('RepositoryRoutes Unit Tests (Refactored with Unified Cache)', () => { }); }); - describe('GET /contributors - Top Contributors with Unified Cache', () => { - test('should return contributors using unified cache service', async () => { + describe('GET /contributors - All Unique Contributors with Unified Cache', () => { + test('should return all unique contributors using unified cache service', async () => { // ARRANGE const mockContributors = [ - { - login: 'user1', - commitCount: 50, - linesAdded: 1000, - linesDeleted: 200, - contributionPercentage: 60, - }, + { login: 'Alice' }, + { login: 'Bob' }, + { login: 'Charlie' }, ]; mockRepositoryCache.getCachedContributors.mockResolvedValue( @@ -352,6 +348,16 @@ describe('RepositoryRoutes Unit Tests (Refactored with Unified Cache)', () => { expect(response.status).toBe(200); expect(response.body).toHaveProperty('contributors'); expect(response.body.contributors).toEqual(mockContributors); + expect(response.body.contributors).toHaveLength(3); + + // Verify no statistics in response + response.body.contributors.forEach((contributor: any) => { + expect(contributor).toHaveProperty('login'); + expect(contributor).not.toHaveProperty('commitCount'); + expect(contributor).not.toHaveProperty('linesAdded'); + expect(contributor).not.toHaveProperty('linesDeleted'); + expect(contributor).not.toHaveProperty('contributionPercentage'); + }); expect(mockRepositoryCache.getCachedContributors).toHaveBeenCalledWith( 'https://github.com/test/repo', diff --git a/apps/backend/__tests__/unit/services/gitService.unit.test.ts b/apps/backend/__tests__/unit/services/gitService.unit.test.ts index 204ff874..d3eb994a 100644 --- a/apps/backend/__tests__/unit/services/gitService.unit.test.ts +++ b/apps/backend/__tests__/unit/services/gitService.unit.test.ts @@ -31,7 +31,7 @@ vi.mock('../../../src/services/metrics', () => ({ })); describe('GitService Optimized Unit Tests', () => { - const mockGit = { clone: vi.fn(), raw: vi.fn() }; + const mockGit = { clone: vi.fn(), raw: vi.fn(), log: vi.fn() }; const mockMemoryStats = { system: { free: 1024 * 1024 * 1024, @@ -50,6 +50,7 @@ describe('GitService Optimized Unit Tests', () => { const createMockContext = () => { vi.clearAllMocks(); mockGit.raw.mockReset(); + mockGit.log.mockReset(); (simpleGit as any).mockImplementation(() => mockGit); (mkdtemp as any).mockResolvedValue('/tmp/test-repo'); (rm as any).mockResolvedValue(undefined); @@ -1307,54 +1308,23 @@ def456|2023-01-02T12:00:00Z|Bob|bob@example.com|chore: merge commit }); }); - describe('getTopContributors', () => { - test('should aggregate and return top 5 contributors sorted by commit count', async () => { + describe('getContributors', () => { + test('should return all unique contributors sorted alphabetically', async () => { // Arrange - const commitsWithStats = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1 -10\t5\tfile1.ts - -def456|2023-01-02T12:00:00Z|Bob|bob@example.com|commit 2 -15\t3\tfile2.ts - -ghi789|2023-01-03T12:00:00Z|Alice|alice@example.com|commit 3 -20\t10\tfile3.ts - -jkl012|2023-01-04T12:00:00Z|Charlie|charlie@example.com|commit 4 -5\t2\tfile4.ts - -mno345|2023-01-05T12:00:00Z|Alice|alice@example.com|commit 5 -8\t4\tfile5.ts - -pqr678|2023-01-06T12:00:00Z|Bob|bob@example.com|commit 6 -12\t6\tfile6.ts`; - mockGit.raw.mockResolvedValue(commitsWithStats); + const rawOutput = 'Alice\nBob\nAlice\nCharlie\nBob'; + mockGit.raw.mockResolvedValue(rawOutput); // Act - const result = await gitService.getTopContributors('/test/repo'); + const result = await gitService.getContributors('/test/repo'); // Assert - expect(result).toHaveLength(3); // Alice, Bob, Charlie - expect(result[0]).toEqual({ - login: 'Alice', - commitCount: 3, - linesAdded: 38, - linesDeleted: 19, - contributionPercentage: 0.5, // 3 out of 6 commits - }); - expect(result[1]).toEqual({ - login: 'Bob', - commitCount: 2, - linesAdded: 27, - linesDeleted: 9, - contributionPercentage: 2 / 6, - }); - expect(result[2]).toEqual({ - login: 'Charlie', - commitCount: 1, - linesAdded: 5, - linesDeleted: 2, - contributionPercentage: 1 / 6, - }); + expect(result).toHaveLength(3); + expect(result).toEqual([ + { login: 'Alice' }, + { login: 'Bob' }, + { login: 'Charlie' }, + ]); + expect(mockGit.raw).toHaveBeenCalledWith(['log', '--format=%aN']); }); test('should return empty array when no commits exist', async () => { @@ -1362,60 +1332,87 @@ pqr678|2023-01-06T12:00:00Z|Bob|bob@example.com|commit 6 mockGit.raw.mockResolvedValue(''); // Act - const result = await gitService.getTopContributors('/test/repo'); + const result = await gitService.getContributors('/test/repo'); // Assert expect(result).toEqual([]); }); - test('should limit results to top 5 contributors', async () => { + test('should apply date filter options', async () => { // Arrange - const manyCommits = Array.from( - { length: 10 }, - (_, i) => - `commit${i}|2023-01-0${i + 1}T12:00:00Z|User${i}|user${i}@example.com|commit ${i}\n10\t5\tfile${i}.ts` - ).join('\n\n'); - mockGit.raw.mockResolvedValue(manyCommits); + mockGit.raw.mockResolvedValue('Alice'); // Act - const result = await gitService.getTopContributors('/test/repo'); + await gitService.getContributors('/test/repo', { + fromDate: '2023-01-01', + toDate: '2023-12-31', + }); // Assert - expect(result.length).toBeLessThanOrEqual(5); + expect(mockGit.raw).toHaveBeenCalledWith( + expect.arrayContaining([ + 'log', + '--format=%aN', + '--since=2023-01-01', + '--until=2023-12-31', + ]) + ); }); - test('should apply filter options to underlying commits', async () => { + test('should apply single author filter', async () => { // Arrange - const commitData = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1 -10\t5\tfile1.ts`; - mockGit.raw.mockResolvedValue(commitData); + mockGit.raw.mockResolvedValue('Alice'); // Act - await gitService.getTopContributors('/test/repo', { - fromDate: '2023-01-01', - toDate: '2023-12-31', + await gitService.getContributors('/test/repo', { + author: 'Alice', }); // Assert expect(mockGit.raw).toHaveBeenCalledWith( - expect.arrayContaining(['--since=2023-01-01', '--until=2023-12-31']) + expect.arrayContaining(['log', '--format=%aN', '--author=Alice']) ); }); - test('should calculate contribution percentage correctly', async () => { + test('should apply multiple authors filter using regex pattern', async () => { // Arrange - const twoCommits = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1 -10\t5\tfile1.ts + mockGit.raw.mockResolvedValue('Alice\nBob'); -def456|2023-01-02T12:00:00Z|Alice|alice@example.com|commit 2 -20\t10\tfile2.ts`; - mockGit.raw.mockResolvedValue(twoCommits); + // Act + await gitService.getContributors('/test/repo', { + authors: ['Alice', 'Bob'], + }); + + // Assert + expect(mockGit.raw).toHaveBeenCalledWith( + expect.arrayContaining(['log', '--format=%aN', '--author=Alice|Bob']) + ); + }); + + test('should deduplicate contributor names correctly', async () => { + // Arrange + const rawOutput = 'Alice\nalice\nAlice\nAlice '; + mockGit.raw.mockResolvedValue(rawOutput); // Act - const result = await gitService.getTopContributors('/test/repo'); + const result = await gitService.getContributors('/test/repo'); // Assert - expect(result[0].contributionPercentage).toBe(1.0); // 100% when only one contributor + expect(result).toHaveLength(2); // "Alice" and "alice" (case-sensitive) + expect(result).toEqual([{ login: 'Alice' }, { login: 'alice' }]); + }); + + test('should handle empty or whitespace-only author names', async () => { + // Arrange + const rawOutput = 'Alice\n\n \nBob'; + mockGit.raw.mockResolvedValue(rawOutput); + + // Act + const result = await gitService.getContributors('/test/repo'); + + // Assert + expect(result).toHaveLength(2); + expect(result).toEqual([{ login: 'Alice' }, { login: 'Bob' }]); }); test('should handle git errors gracefully', async () => { @@ -1423,9 +1420,26 @@ def456|2023-01-02T12:00:00Z|Alice|alice@example.com|commit 2 mockGit.raw.mockRejectedValue(new Error('Git error')); // Act & Assert - await expect(gitService.getTopContributors('/test/repo')).rejects.toThrow( - 'Failed to get top contributors' + await expect(gitService.getContributors('/test/repo')).rejects.toThrow( + 'Failed to get contributors' + ); + }); + + test('should return all contributors without limiting to top 5', async () => { + // Arrange + const rawOutput = Array.from({ length: 10 }, (_, i) => `User${i}`).join( + '\n' ); + mockGit.raw.mockResolvedValue(rawOutput); + + // Act + const result = await gitService.getContributors('/test/repo'); + + // Assert + expect(result).toHaveLength(10); // All 10 contributors returned + expect( + result.every((c) => 'login' in c && Object.keys(c).length === 1) + ).toBe(true); }); }); diff --git a/apps/backend/src/services/gitService.ts b/apps/backend/src/services/gitService.ts index c032158a..e26aa8e4 100644 --- a/apps/backend/src/services/gitService.ts +++ b/apps/backend/src/services/gitService.ts @@ -36,6 +36,7 @@ import { ChurnFilterOptions, ChurnRiskLevel, ChurnRiskThresholds, + Contributor, } from '@gitray/shared-types'; import { shallowClone } from '../utils/gitUtils'; import redis from '../services/cache'; @@ -941,88 +942,71 @@ class GitService { } /** - * NEW: Get top contributors with aggregated statistics - * Returns up to 5 top contributors sorted by commit count + * Get all unique contributors (author names) from the repository + * Returns deduplicated list of author names without statistics or ranking + * GDPR-compliant: returns only author names, no commit counts or percentages */ - async getTopContributors( + async getContributors( localRepoPath: string, options?: CommitFilterOptions - ): Promise< - Array<{ - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - contributionPercentage: number; - }> - > { - logger.info(`Getting top contributors from: ${localRepoPath}`, { options }); + ): Promise { + logger.info(`Getting contributors from: ${localRepoPath}`, { options }); try { - // Fetch commits with line statistics - const commitsWithStats = await this.getCommitsWithStats( - localRepoPath, - options - ); + const git = simpleGit(localRepoPath); - // Aggregate stats by author - const contributorMap = new Map< - string, - { - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - } - >(); - - for (const commit of commitsWithStats) { - // Use author name as the unique identifier for GDPR compliance (pseudonymized) - const login = commit.authorName; - - if (!contributorMap.has(login)) { - contributorMap.set(login, { - login, - commitCount: 0, - linesAdded: 0, - linesDeleted: 0, - }); - } + // Build git log arguments for filtering + const args: string[] = ['log', '--format=%aN']; // Only author name + + // Apply date filters + if (options?.fromDate) { + args.push(`--since=${options.fromDate}`); + } + if (options?.toDate) { + args.push(`--until=${options.toDate}`); + } - const contributor = contributorMap.get(login)!; - contributor.commitCount += 1; - contributor.linesAdded += commit.linesAdded; - contributor.linesDeleted += commit.linesDeleted; + // Apply author filters + if (options?.author) { + args.push(`--author=${options.author}`); + } else if (options?.authors && options.authors.length > 0) { + // Multiple authors: create OR condition using regex + const authorPattern = options.authors.join('|'); + args.push(`--author=${authorPattern}`); } - // Convert to array and sort by commit count - const contributors = Array.from(contributorMap.values()); - contributors.sort((a, b) => b.commitCount - a.commitCount); + // Execute git log - using raw() for custom format + const output = await git.raw(args); + + // Extract unique author names from raw output + const uniqueAuthors = new Set(); + if (output && output.trim()) { + const lines = output.trim().split('\n'); + for (const line of lines) { + const authorName = line.trim(); + if (authorName) { + uniqueAuthors.add(authorName); + } + } + } - // Calculate contribution percentages and take top 5 - const totalCommits = commitsWithStats.length; - const topContributors = contributors.slice(0, 5).map((contributor) => ({ - ...contributor, - contributionPercentage: - totalCommits > 0 ? contributor.commitCount / totalCommits : 0, - })); + // Convert to Contributor array and sort alphabetically for consistency + const contributors: Contributor[] = Array.from(uniqueAuthors) + .sort() + .map((login) => ({ login })); logger.info( - `Successfully aggregated ${topContributors.length} top contributors from ${localRepoPath}`, - { - totalContributors: contributors.length, - totalCommits, - } + `Successfully retrieved ${contributors.length} unique contributors from ${localRepoPath}` ); - return topContributors; + return contributors; } catch (error) { - logger.error(`Error getting top contributors from ${localRepoPath}`, { + logger.error(`Error getting contributors from ${localRepoPath}`, { error, localRepoPath, }); throw new RepositoryError( - `Failed to get top contributors: ${error instanceof Error ? error.message : String(error)}`, + `Failed to get contributors: ${error instanceof Error ? error.message : String(error)}`, localRepoPath ); } diff --git a/apps/backend/src/services/repositoryCache.ts b/apps/backend/src/services/repositoryCache.ts index f3e81814..aa4989c6 100644 --- a/apps/backend/src/services/repositoryCache.ts +++ b/apps/backend/src/services/repositoryCache.ts @@ -51,19 +51,12 @@ import { CodeChurnAnalysis, ChurnFilterOptions, RepositorySummary, + Contributor, } from '@gitray/shared-types'; -type ContributorAggregation = { - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - contributionPercentage: number; -}; - type AggregatedCacheValue = | CommitHeatmapData - | ContributorAggregation[] + | Contributor[] | CodeChurnAnalysis | RepositorySummary; @@ -316,7 +309,7 @@ export class RepositoryCacheManager { * Initialize aggregated data cache with smallest allocation. * Aggregations are computationally expensive but have the lowest reuse rate * since they're often specific to particular visualization requests. - * Now supports both CommitHeatmapData and ContributorStat[] types. + * Now supports CommitHeatmapData, Contributor[], CodeChurnAnalysis, and RepositorySummary types. */ this.aggregatedDataCache = new HybridLRUCache({ maxEntries: Math.floor(baseConfig.maxEntries * 0.2), // 20% of total entries @@ -1311,28 +1304,20 @@ export class RepositoryCacheManager { } /** - * NEW: Retrieves or generates top contributor statistics using the aggregated cache tier. + * NEW: Retrieves or generates list of all unique contributors using the aggregated cache tier. * - * This method processes commit data to generate contributor-level statistics including - * commit counts, lines added/deleted, and contribution percentages. Results are cached - * to avoid expensive recomputation for subsequent requests. + * This method processes commit data to generate a deduplicated list of contributor names + * without statistics or ranking, fully GDPR-compliant. Results are cached to avoid + * expensive recomputation for subsequent requests. * * @param repoUrl - Git repository URL * @param filterOptions - Optional filters for contributor data scope - * @returns Promise resolving to array of top contributor statistics + * @returns Promise resolving to array of unique contributors */ async getOrGenerateContributors( repoUrl: string, filterOptions?: CommitFilterOptions - ): Promise< - Array<{ - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - contributionPercentage: number; - }> - > { + ): Promise { // FIX: Don't use withOrderedLocks for contributors since it needs direct repository access // The repository coordinator manages its own locking via withSharedRepository const startTime = Date.now(); @@ -1345,15 +1330,7 @@ export class RepositoryCacheManager { const cachedData = await this.aggregatedDataCache.get(contributorsKey); // Type guard to ensure we have contributor data - const isContributorArray = ( - data: any - ): data is Array<{ - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - contributionPercentage: number; - }> => { + const isContributorArray = (data: any): data is Contributor[] => { return Array.isArray(data) && (data.length === 0 || 'login' in data[0]); }; @@ -1409,7 +1386,7 @@ export class RepositoryCacheManager { }); } - return gitService.getTopContributors(handle.localPath, filterOptions); + return gitService.getContributors(handle.localPath, filterOptions); } ); @@ -1417,7 +1394,7 @@ export class RepositoryCacheManager { if (!contributors) { contributors = []; logger.warn( - 'gitService.getTopContributors returned null, using empty array', + 'gitService.getContributors returned null, using empty array', { repoUrl } ); } @@ -3086,15 +3063,7 @@ export function getRepositoryCacheStats(): CacheStats { export async function getCachedContributors( repoUrl: string, filterOptions?: CommitFilterOptions -): Promise< - Array<{ - login: string; - commitCount: number; - linesAdded: number; - linesDeleted: number; - contributionPercentage: number; - }> -> { +): Promise { return repositoryCache.getOrGenerateContributors(repoUrl, filterOptions); } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 830aa069..136f5f53 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -371,22 +371,15 @@ export interface PerformanceMetrics { // ============================================================================ /** - * Represents statistics for a repository contributor + * Represents a repository contributor + * GDPR-compliant: contains only the author name, no statistics or ranking */ -export interface ContributorStat { +export interface Contributor { /** * Git author name (pseudonymized identifier for GDPR compliance). * Uses the author's configured git name, not email address. */ login: string; - /** Total number of commits by this contributor */ - commitCount: number; - /** Total lines added by this contributor */ - linesAdded: number; - /** Total lines deleted by this contributor */ - linesDeleted: number; - /** Contribution percentage relative to total commits (0.0 to 1.0) */ - contributionPercentage: number; } // ============================================================================ From a426d5bff5d2a4529e63f1037aaedc623383c43f Mon Sep 17 00:00:00 2001 From: Jonas Weirauch Date: Tue, 2 Dec 2025 13:28:50 +0100 Subject: [PATCH 3/4] refactor(backend): improve contributor sorting to use locale comparison for consistency --- apps/backend/src/services/gitService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/services/gitService.ts b/apps/backend/src/services/gitService.ts index e26aa8e4..561991b1 100644 --- a/apps/backend/src/services/gitService.ts +++ b/apps/backend/src/services/gitService.ts @@ -992,7 +992,7 @@ class GitService { // Convert to Contributor array and sort alphabetically for consistency const contributors: Contributor[] = Array.from(uniqueAuthors) - .sort() + .sort((a, b) => a.localeCompare(b)) .map((login) => ({ login })); logger.info( From 15f48ee298340beac2d930867c1bd27f5e0cac65 Mon Sep 17 00:00:00 2001 From: Jonas Weirauch Date: Tue, 2 Dec 2025 13:35:26 +0100 Subject: [PATCH 4/4] refactor(tests): update contributor sorting in unit tests to reflect locale comparison behavior --- apps/backend/__tests__/unit/services/gitService.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/backend/__tests__/unit/services/gitService.unit.test.ts b/apps/backend/__tests__/unit/services/gitService.unit.test.ts index d3eb994a..4963c399 100644 --- a/apps/backend/__tests__/unit/services/gitService.unit.test.ts +++ b/apps/backend/__tests__/unit/services/gitService.unit.test.ts @@ -1399,7 +1399,8 @@ def456|2023-01-02T12:00:00Z|Bob|bob@example.com|chore: merge commit // Assert expect(result).toHaveLength(2); // "Alice" and "alice" (case-sensitive) - expect(result).toEqual([{ login: 'Alice' }, { login: 'alice' }]); + // localeCompare sorts lowercase before uppercase, so "alice" comes before "Alice" + expect(result).toEqual([{ login: 'alice' }, { login: 'Alice' }]); }); test('should handle empty or whitespace-only author names', async () => {