Skip to content

Commit 08f6d69

Browse files
authored
Merge pull request #123 from jonasyr/121-chorebackend-repurpose-contributors-endpoint-to-return-all-contributors-without-ranking
refactor(backend)!: Simplify contributors endpoint to return all unique names without ranking
2 parents f296b4d + 15f48ee commit 08f6d69

7 files changed

Lines changed: 181 additions & 221 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ apps/backend/logs/
334334

335335
.claude*
336336
.mcp.json
337+
337338
# Serena MCP - ignore cache but track memories and config
338339
.serena/cache/
339340
# Track Serena memories and project configuration
@@ -342,4 +343,6 @@ apps/backend/logs/
342343
!.serena/project.yml
343344
!.serena/memories/
344345
!.serena/memories/*.md
346+
347+
# GitHub instructions
345348
.github/instructions/sonarqube_mcp.instructions.md

FRONTEND_API_MIGRATION.md

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ const { heatmapData } = await response.json();
224224

225225
### 3. GET /api/repositories/contributors
226226

227-
**Purpose**: Retrieve top contributors with statistics and optional filters.
227+
**Purpose**: Retrieve all unique contributors without statistics or ranking (GDPR-compliant).
228228

229229
**Query Parameters**:
230230

@@ -249,12 +249,7 @@ GET /api/repositories/contributors?repoUrl=https://github.com/user/repo.git&from
249249
```typescript
250250
{
251251
contributors: Array<{
252-
name: string;
253-
email: string;
254-
commits: number;
255-
additions: number;
256-
deletions: number;
257-
percentage: number; // Contribution percentage
252+
login: string; // Author name (GDPR-compliant pseudonymized identifier)
258253
}>
259254
}
260255
```
@@ -264,22 +259,9 @@ GET /api/repositories/contributors?repoUrl=https://github.com/user/repo.git&from
264259
```json
265260
{
266261
"contributors": [
267-
{
268-
"name": "Jonas",
269-
"email": "jonas@example.com",
270-
"commits": 280,
271-
"additions": 15420,
272-
"deletions": 3210,
273-
"percentage": 58.3
274-
},
275-
{
276-
"name": "Contributor2",
277-
"email": "contrib@example.com",
278-
"commits": 200,
279-
"additions": 8500,
280-
"deletions": 1200,
281-
"percentage": 41.7
282-
}
262+
{ "login": "Alice" },
263+
{ "login": "Bob" },
264+
{ "login": "Charlie" }
283265
]
284266
}
285267
```
@@ -300,8 +282,16 @@ if (toDate) params.append('toDate', toDate);
300282

301283
const response = await fetch(`/api/repositories/contributors?${params}`);
302284
const { contributors } = await response.json();
285+
// Note: Contributors now contain only { login: string }, no statistics
303286
```
304287

288+
**IMPORTANT CHANGES (Issue #121)**:
289+
290+
- Returns **all unique contributors**, not just top 5
291+
- No commit counts, line statistics, or contribution percentages
292+
- Alphabetically sorted for consistency
293+
- Fully GDPR-compliant (only author names, no tracking metrics)
294+
305295
---
306296

307297
### 4. GET /api/repositories/churn

apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,17 +326,13 @@ describe('RepositoryRoutes Unit Tests (Refactored with Unified Cache)', () => {
326326
});
327327
});
328328

329-
describe('GET /contributors - Top Contributors with Unified Cache', () => {
330-
test('should return contributors using unified cache service', async () => {
329+
describe('GET /contributors - All Unique Contributors with Unified Cache', () => {
330+
test('should return all unique contributors using unified cache service', async () => {
331331
// ARRANGE
332332
const mockContributors = [
333-
{
334-
login: 'user1',
335-
commitCount: 50,
336-
linesAdded: 1000,
337-
linesDeleted: 200,
338-
contributionPercentage: 60,
339-
},
333+
{ login: 'Alice' },
334+
{ login: 'Bob' },
335+
{ login: 'Charlie' },
340336
];
341337

342338
mockRepositoryCache.getCachedContributors.mockResolvedValue(
@@ -352,6 +348,16 @@ describe('RepositoryRoutes Unit Tests (Refactored with Unified Cache)', () => {
352348
expect(response.status).toBe(200);
353349
expect(response.body).toHaveProperty('contributors');
354350
expect(response.body.contributors).toEqual(mockContributors);
351+
expect(response.body.contributors).toHaveLength(3);
352+
353+
// Verify no statistics in response
354+
response.body.contributors.forEach((contributor: any) => {
355+
expect(contributor).toHaveProperty('login');
356+
expect(contributor).not.toHaveProperty('commitCount');
357+
expect(contributor).not.toHaveProperty('linesAdded');
358+
expect(contributor).not.toHaveProperty('linesDeleted');
359+
expect(contributor).not.toHaveProperty('contributionPercentage');
360+
});
355361

356362
expect(mockRepositoryCache.getCachedContributors).toHaveBeenCalledWith(
357363
'https://github.com/test/repo',

apps/backend/__tests__/unit/services/gitService.unit.test.ts

Lines changed: 87 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ vi.mock('../../../src/services/metrics', () => ({
3131
}));
3232

3333
describe('GitService Optimized Unit Tests', () => {
34-
const mockGit = { clone: vi.fn(), raw: vi.fn() };
34+
const mockGit = { clone: vi.fn(), raw: vi.fn(), log: vi.fn() };
3535
const mockMemoryStats = {
3636
system: {
3737
free: 1024 * 1024 * 1024,
@@ -50,6 +50,7 @@ describe('GitService Optimized Unit Tests', () => {
5050
const createMockContext = () => {
5151
vi.clearAllMocks();
5252
mockGit.raw.mockReset();
53+
mockGit.log.mockReset();
5354
(simpleGit as any).mockImplementation(() => mockGit);
5455
(mkdtemp as any).mockResolvedValue('/tmp/test-repo');
5556
(rm as any).mockResolvedValue(undefined);
@@ -1307,125 +1308,139 @@ def456|2023-01-02T12:00:00Z|Bob|bob@example.com|chore: merge commit
13071308
});
13081309
});
13091310

1310-
describe('getTopContributors', () => {
1311-
test('should aggregate and return top 5 contributors sorted by commit count', async () => {
1311+
describe('getContributors', () => {
1312+
test('should return all unique contributors sorted alphabetically', async () => {
13121313
// Arrange
1313-
const commitsWithStats = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1
1314-
10\t5\tfile1.ts
1315-
1316-
def456|2023-01-02T12:00:00Z|Bob|bob@example.com|commit 2
1317-
15\t3\tfile2.ts
1318-
1319-
ghi789|2023-01-03T12:00:00Z|Alice|alice@example.com|commit 3
1320-
20\t10\tfile3.ts
1321-
1322-
jkl012|2023-01-04T12:00:00Z|Charlie|charlie@example.com|commit 4
1323-
5\t2\tfile4.ts
1324-
1325-
mno345|2023-01-05T12:00:00Z|Alice|alice@example.com|commit 5
1326-
8\t4\tfile5.ts
1327-
1328-
pqr678|2023-01-06T12:00:00Z|Bob|bob@example.com|commit 6
1329-
12\t6\tfile6.ts`;
1330-
mockGit.raw.mockResolvedValue(commitsWithStats);
1314+
const rawOutput = 'Alice\nBob\nAlice\nCharlie\nBob';
1315+
mockGit.raw.mockResolvedValue(rawOutput);
13311316

13321317
// Act
1333-
const result = await gitService.getTopContributors('/test/repo');
1318+
const result = await gitService.getContributors('/test/repo');
13341319

13351320
// Assert
1336-
expect(result).toHaveLength(3); // Alice, Bob, Charlie
1337-
expect(result[0]).toEqual({
1338-
login: 'Alice',
1339-
commitCount: 3,
1340-
linesAdded: 38,
1341-
linesDeleted: 19,
1342-
contributionPercentage: 0.5, // 3 out of 6 commits
1343-
});
1344-
expect(result[1]).toEqual({
1345-
login: 'Bob',
1346-
commitCount: 2,
1347-
linesAdded: 27,
1348-
linesDeleted: 9,
1349-
contributionPercentage: 2 / 6,
1350-
});
1351-
expect(result[2]).toEqual({
1352-
login: 'Charlie',
1353-
commitCount: 1,
1354-
linesAdded: 5,
1355-
linesDeleted: 2,
1356-
contributionPercentage: 1 / 6,
1357-
});
1321+
expect(result).toHaveLength(3);
1322+
expect(result).toEqual([
1323+
{ login: 'Alice' },
1324+
{ login: 'Bob' },
1325+
{ login: 'Charlie' },
1326+
]);
1327+
expect(mockGit.raw).toHaveBeenCalledWith(['log', '--format=%aN']);
13581328
});
13591329

13601330
test('should return empty array when no commits exist', async () => {
13611331
// Arrange
13621332
mockGit.raw.mockResolvedValue('');
13631333

13641334
// Act
1365-
const result = await gitService.getTopContributors('/test/repo');
1335+
const result = await gitService.getContributors('/test/repo');
13661336

13671337
// Assert
13681338
expect(result).toEqual([]);
13691339
});
13701340

1371-
test('should limit results to top 5 contributors', async () => {
1341+
test('should apply date filter options', async () => {
13721342
// Arrange
1373-
const manyCommits = Array.from(
1374-
{ length: 10 },
1375-
(_, i) =>
1376-
`commit${i}|2023-01-0${i + 1}T12:00:00Z|User${i}|user${i}@example.com|commit ${i}\n10\t5\tfile${i}.ts`
1377-
).join('\n\n');
1378-
mockGit.raw.mockResolvedValue(manyCommits);
1343+
mockGit.raw.mockResolvedValue('Alice');
13791344

13801345
// Act
1381-
const result = await gitService.getTopContributors('/test/repo');
1346+
await gitService.getContributors('/test/repo', {
1347+
fromDate: '2023-01-01',
1348+
toDate: '2023-12-31',
1349+
});
13821350

13831351
// Assert
1384-
expect(result.length).toBeLessThanOrEqual(5);
1352+
expect(mockGit.raw).toHaveBeenCalledWith(
1353+
expect.arrayContaining([
1354+
'log',
1355+
'--format=%aN',
1356+
'--since=2023-01-01',
1357+
'--until=2023-12-31',
1358+
])
1359+
);
13851360
});
13861361

1387-
test('should apply filter options to underlying commits', async () => {
1362+
test('should apply single author filter', async () => {
13881363
// Arrange
1389-
const commitData = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1
1390-
10\t5\tfile1.ts`;
1391-
mockGit.raw.mockResolvedValue(commitData);
1364+
mockGit.raw.mockResolvedValue('Alice');
13921365

13931366
// Act
1394-
await gitService.getTopContributors('/test/repo', {
1395-
fromDate: '2023-01-01',
1396-
toDate: '2023-12-31',
1367+
await gitService.getContributors('/test/repo', {
1368+
author: 'Alice',
13971369
});
13981370

13991371
// Assert
14001372
expect(mockGit.raw).toHaveBeenCalledWith(
1401-
expect.arrayContaining(['--since=2023-01-01', '--until=2023-12-31'])
1373+
expect.arrayContaining(['log', '--format=%aN', '--author=Alice'])
14021374
);
14031375
});
14041376

1405-
test('should calculate contribution percentage correctly', async () => {
1377+
test('should apply multiple authors filter using regex pattern', async () => {
14061378
// Arrange
1407-
const twoCommits = `abc123|2023-01-01T12:00:00Z|Alice|alice@example.com|commit 1
1408-
10\t5\tfile1.ts
1379+
mockGit.raw.mockResolvedValue('Alice\nBob');
14091380

1410-
def456|2023-01-02T12:00:00Z|Alice|alice@example.com|commit 2
1411-
20\t10\tfile2.ts`;
1412-
mockGit.raw.mockResolvedValue(twoCommits);
1381+
// Act
1382+
await gitService.getContributors('/test/repo', {
1383+
authors: ['Alice', 'Bob'],
1384+
});
1385+
1386+
// Assert
1387+
expect(mockGit.raw).toHaveBeenCalledWith(
1388+
expect.arrayContaining(['log', '--format=%aN', '--author=Alice|Bob'])
1389+
);
1390+
});
1391+
1392+
test('should deduplicate contributor names correctly', async () => {
1393+
// Arrange
1394+
const rawOutput = 'Alice\nalice\nAlice\nAlice ';
1395+
mockGit.raw.mockResolvedValue(rawOutput);
14131396

14141397
// Act
1415-
const result = await gitService.getTopContributors('/test/repo');
1398+
const result = await gitService.getContributors('/test/repo');
14161399

14171400
// Assert
1418-
expect(result[0].contributionPercentage).toBe(1.0); // 100% when only one contributor
1401+
expect(result).toHaveLength(2); // "Alice" and "alice" (case-sensitive)
1402+
// localeCompare sorts lowercase before uppercase, so "alice" comes before "Alice"
1403+
expect(result).toEqual([{ login: 'alice' }, { login: 'Alice' }]);
1404+
});
1405+
1406+
test('should handle empty or whitespace-only author names', async () => {
1407+
// Arrange
1408+
const rawOutput = 'Alice\n\n \nBob';
1409+
mockGit.raw.mockResolvedValue(rawOutput);
1410+
1411+
// Act
1412+
const result = await gitService.getContributors('/test/repo');
1413+
1414+
// Assert
1415+
expect(result).toHaveLength(2);
1416+
expect(result).toEqual([{ login: 'Alice' }, { login: 'Bob' }]);
14191417
});
14201418

14211419
test('should handle git errors gracefully', async () => {
14221420
// Arrange
14231421
mockGit.raw.mockRejectedValue(new Error('Git error'));
14241422

14251423
// Act & Assert
1426-
await expect(gitService.getTopContributors('/test/repo')).rejects.toThrow(
1427-
'Failed to get top contributors'
1424+
await expect(gitService.getContributors('/test/repo')).rejects.toThrow(
1425+
'Failed to get contributors'
1426+
);
1427+
});
1428+
1429+
test('should return all contributors without limiting to top 5', async () => {
1430+
// Arrange
1431+
const rawOutput = Array.from({ length: 10 }, (_, i) => `User${i}`).join(
1432+
'\n'
14281433
);
1434+
mockGit.raw.mockResolvedValue(rawOutput);
1435+
1436+
// Act
1437+
const result = await gitService.getContributors('/test/repo');
1438+
1439+
// Assert
1440+
expect(result).toHaveLength(10); // All 10 contributors returned
1441+
expect(
1442+
result.every((c) => 'login' in c && Object.keys(c).length === 1)
1443+
).toBe(true);
14291444
});
14301445
});
14311446

0 commit comments

Comments
 (0)