Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -342,4 +343,6 @@ apps/backend/logs/
!.serena/project.yml
!.serena/memories/
!.serena/memories/*.md

# GitHub instructions
.github/instructions/sonarqube_mcp.instructions.md
36 changes: 13 additions & 23 deletions FRONTEND_API_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand All @@ -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)
}>
}
```
Expand All @@ -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" }
]
}
```
Expand All @@ -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
Expand Down
24 changes: 15 additions & 9 deletions apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
}

const result = await processor({ req, logger, repoUrl, userType });
const metrics = buildMetrics ? buildMetrics(result) : {};

Check warning on line 111 in apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

'metrics' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 111 in apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

'metrics' is assigned a value but never used

mockMetrics.recordFeatureUsage(featureName, userType, true, 'api_call');
res.status(200).json(result);
Expand All @@ -130,7 +130,7 @@
class ValidationError extends Error {
constructor(
message: string,
public readonly errors?: any[]

Check warning on line 133 in apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

'errors' is defined but never used
) {
super(message);
this.name = 'ValidationError';
Expand Down Expand Up @@ -179,7 +179,7 @@
app.use('/api/repositories', repositoryRoutes);

// Add error handler
app.use((err: any, req: any, res: any, next: any) => {

Check warning on line 182 in apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

'next' is defined but never used. Allowed unused args must match /^_/u

Check warning on line 182 in apps/backend/__tests__/unit/routes/repositoryRoutes.unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

'next' is defined but never used
if (!res.headersSent) {
res.status(err.statusCode || 500).json({
error: err.message || 'Internal server error',
Expand Down Expand Up @@ -326,17 +326,13 @@
});
});

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(
Expand All @@ -352,6 +348,16 @@
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',
Expand Down
159 changes: 87 additions & 72 deletions apps/backend/__tests__/unit/services/gitService.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -1307,125 +1308,139 @@ 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 () => {
// Arrange
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)
// 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 () => {
// 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 () => {
// Arrange
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);
});
});

Expand Down
Loading
Loading