Skip to content
Open
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
8 changes: 6 additions & 2 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
} from '@/types';
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';

Check warning on line 54 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'escapeXML' is defined but never used. Allowed unused vars must match /^_/u
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
Expand Down Expand Up @@ -379,7 +379,7 @@
font,
autoTheme: isAutoTheme,
hide_title,
custom_title,
custom_title: custom_title || (repo ? `CommitPulse for ${repo}` : undefined),
custom_subtitle: autoSubtitle,
hideBackground: hide_background,
hide_stats,
Expand All @@ -396,7 +396,6 @@
),

mode,
repo,
org,
labels,
labelColor,
Expand Down Expand Up @@ -476,6 +475,7 @@
bypassCache: shouldBypassCache,
from,
to,
repo,
signal: controller.signal,
});
if (userData.isOfflineFallback) {
Expand Down Expand Up @@ -519,12 +519,14 @@
bypassCache: shouldBypassCache,
from: from1,
to: to1,
repo,
signal: controller.signal,
}),
fetchGitHubContributions(user, {
bypassCache: shouldBypassCache,
from: from2,
to: to2,
repo,
signal: controller.signal,
}),
]);
Expand All @@ -541,6 +543,7 @@
bypassCache: shouldBypassCache,
from,
to,
repo,
signal: controller.signal,
});

Expand All @@ -549,6 +552,7 @@
bypassCache: shouldBypassCache,
from,
to,
repo,
signal: controller.signal,
})
: Promise.resolve(null);
Expand Down
79 changes: 74 additions & 5 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,7 @@ type FetchOptions = {
rangeLabel?: string;
signal?: AbortSignal;
org?: string;
repo?: string;
excludeBots?: boolean;
// Authenticated user's OAuth token. When set, GitHub calls use THIS token
// (the user's personal rate-limit quota) instead of the global PAT pool.
Expand Down Expand Up @@ -693,7 +694,8 @@ export function cacheKey(
username: string,
year?: string,
to?: string,
org?: string
org?: string,
repo?: string
): string;
export function cacheKey(
kind:
Expand All @@ -708,7 +710,8 @@ export function cacheKey(
username: string,
from?: string,
to?: string,
org?: string
org?: string,
repo?: string
): string;
export function cacheKey(
kind:
Expand All @@ -723,7 +726,8 @@ export function cacheKey(
username: string,
yearOrFrom?: string,
to?: string,
org?: string
org?: string,
repo?: string
): string {
let keyStr = '';
if (yearOrFrom && to) {
Expand All @@ -736,6 +740,9 @@ export function cacheKey(
if (org) {
keyStr += `:org:${org.toLowerCase()}`;
}
if (repo) {
keyStr += `:repo:${repo.toLowerCase()}`;
}
return keyStr;
}

Expand Down Expand Up @@ -944,7 +951,14 @@ export async function fetchGitHubContributions(
username: string,
options: FetchOptions = {}
): Promise<ExtendedContributionData> {
const key = cacheKey('contributions', username, options.from, options.to, options.org);
const key = cacheKey(
'contributions',
username,
options.from,
options.to,
options.org,
options.repo
);
const LONG_CACHE_TTL = Number(
process.env.GITHUB_LONG_CACHE_TTL_MS ?? String(7 * 24 * 60 * 60 * 1000)
);
Expand Down Expand Up @@ -1150,8 +1164,12 @@ async function fetchContributionsUncached(
name
}
}
contributions {
contributions(first: 100) {
totalCount
nodes {
occurredAt
commitCount
}
}
}
}
Expand Down Expand Up @@ -1233,6 +1251,57 @@ async function fetchContributionsUncached(
};
}

if (options.repo && calendar && calendar.weeks) {
const targetRepoName = options.repo.toLowerCase();
const matchingRepoItem = repoContributions.find(
(c) => c.repository?.nameWithOwner?.toLowerCase() === targetRepoName
);

if (matchingRepoItem) {
repoContributions = [matchingRepoItem];
const repoDateMap = new Map<string, number>();

const nodes =
(
matchingRepoItem.contributions as {
nodes?: { occurredAt?: string; commitCount?: number }[];
}
)?.nodes || [];
for (const node of nodes) {
if (node?.occurredAt) {
const dateStr = node.occurredAt.split('T')[0];
const count = node.commitCount || 1;
repoDateMap.set(dateStr, (repoDateMap.get(dateStr) || 0) + count);
}
}

let repoTotalContributions = 0;
calendar.weeks = calendar.weeks.map((week) => ({
...week,
contributionDays: (week.contributionDays || []).map((day) => {
const repoCount = repoDateMap.get(day.date) || 0;
repoTotalContributions += repoCount;
return {
...day,
contributionCount: repoCount,
};
}),
}));
calendar.totalContributions =
matchingRepoItem.contributions.totalCount || repoTotalContributions;
} else {
repoContributions = [];
calendar.weeks = calendar.weeks.map((week) => ({
...week,
contributionDays: (week.contributionDays || []).map((day) => ({
...day,
contributionCount: 0,
})),
}));
calendar.totalContributions = 0;
}
}

const totalPRs = data.data.user.contributionsCollection?.totalPullRequestContributions || 0;
const totalIssues = data.data.user.contributionsCollection?.totalIssueContributions || 0;
const totalReviews =
Expand Down
2 changes: 1 addition & 1 deletion lib/svg/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,9 @@ function renderHeader(
safeId: string
): string {
const unit = params.mode === 'loc' ? 'est. lines of code' : 'total contributions';
const entityPrefix = params.org ? 'Organization ' : params.repo ? 'Repository ' : '';
const streakText = `${stats.currentStreak} ${stats.currentStreak === 1 ? 'day' : 'days'}`;
const longestStreakText = `${stats.longestStreak} ${stats.longestStreak === 1 ? 'day' : 'days'}`;
const entityPrefix = params.org ? 'Organization ' : params.repo ? 'Repository ' : '';

return `
<title id="cp-title-${safeId}">GitHub ${entityPrefix}streak for ${safeUser} is ${streakText}</title>
Expand Down
11 changes: 10 additions & 1 deletion lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,16 @@ const baseStreakParamsSchema = z.object({
.default(1),

mode: z.enum(['commits', 'loc']).catch('commits').default('commits'),
repo: z.string().optional(),
repo: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(val.trim());
},
{ message: 'Invalid repo parameter. Expected format: owner/repository-name' }
),
org: z
.string()
.max(39, { message: 'Organization name cannot exceed 39 characters' })
Expand Down
175 changes: 175 additions & 0 deletions tests/repo-contribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { streakParamsSchema } from '@/lib/validations';
import { cacheKey, fetchGitHubContributions } from '@/lib/github';

describe('Repository-Specific Contribution Monolith', () => {
describe('Streak Parameter Schema Validation for &repo', () => {
it('accepts valid owner/repo format', () => {
const result = streakParamsSchema.safeParse({ user: 'octocat', repo: 'facebook/react' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.repo).toBe('facebook/react');
}
});

it('accepts hyphens, dots, and underscores in owner/repo', () => {
const result = streakParamsSchema.safeParse({
user: 'octocat',
repo: 'Aditya8369/commitpulse.app_v2-demo',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.repo).toBe('Aditya8369/commitpulse.app_v2-demo');
}
});

it('rejects invalid repo formats', () => {
const invalidRepos = ['just-repo-name', 'owner/repo/extra', 'owner/', '/repo', 'owner@/repo'];
for (const invalidRepo of invalidRepos) {
const result = streakParamsSchema.safeParse({ user: 'octocat', repo: invalidRepo });
expect(result.success).toBe(false);
}
});
});

describe('Cache Key Generation with Repo Parameter', () => {
it('includes repo in contribution cache keys', () => {
const keyWithoutRepo = cacheKey('contributions', 'octocat');
const keyWithRepo = cacheKey(
'contributions',
'octocat',
undefined,
undefined,
undefined,
'facebook/react'
);

expect(keyWithRepo).toContain(':repo:facebook/react');
expect(keyWithRepo).not.toBe(keyWithoutRepo);
});
});

describe('fetchGitHubContributions with repo filtering', () => {
beforeEach(() => {
vi.restoreAllMocks();
});

it('scopes contribution calendar and totals to specified repo', async () => {
const mockGraphQLResponse = {
data: {
user: {
contributionsCollection: {
totalPullRequestContributions: 5,
totalIssueContributions: 2,
totalPullRequestReviewContributions: 1,
contributionCalendar: {
totalContributions: 100,
weeks: [
{
contributionDays: [
{ date: '2025-01-01', contributionCount: 10, color: '#000' },
{ date: '2025-01-02', contributionCount: 5, color: '#000' },
],
},
],
},
commitContributionsByRepository: [
{
repository: {
name: 'react',
nameWithOwner: 'facebook/react',
primaryLanguage: { name: 'JavaScript' },
},
contributions: {
totalCount: 5,
nodes: [{ occurredAt: '2025-01-01T10:00:00Z', commitCount: 5 }],
},
},
{
repository: {
name: 'next.js',
nameWithOwner: 'vercel/next.js',
primaryLanguage: { name: 'TypeScript' },
},
contributions: {
totalCount: 95,
nodes: [
{ occurredAt: '2025-01-01T12:00:00Z', commitCount: 5 },
{ occurredAt: '2025-01-02T14:00:00Z', commitCount: 5 },
],
},
},
],
},
},
},
};

vi.spyOn(global, 'fetch').mockImplementation(async () => {
return new Response(JSON.stringify(mockGraphQLResponse), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});

const result = await fetchGitHubContributions('octocat', {
bypassCache: true,
repo: 'facebook/react',
});

expect(result.calendar.totalContributions).toBe(5);
expect(result.calendar.weeks[0].contributionDays[0].contributionCount).toBe(5);
expect(result.calendar.weeks[0].contributionDays[1].contributionCount).toBe(0);
expect(result.repoContributions.length).toBe(1);
expect(result.repoContributions[0].repository.nameWithOwner).toBe('facebook/react');
});

it('returns 0 total contributions when user has no commits in specified repo', async () => {
const mockGraphQLResponse = {
data: {
user: {
contributionsCollection: {
contributionCalendar: {
totalContributions: 50,
weeks: [
{
contributionDays: [{ date: '2025-01-01', contributionCount: 5, color: '#000' }],
},
],
},
commitContributionsByRepository: [
{
repository: {
name: 'next.js',
nameWithOwner: 'vercel/next.js',
primaryLanguage: { name: 'TypeScript' },
},
contributions: {
totalCount: 50,
nodes: [{ occurredAt: '2025-01-01T12:00:00Z', commitCount: 5 }],
},
},
],
},
},
},
};

vi.spyOn(global, 'fetch').mockImplementation(async () => {
return new Response(JSON.stringify(mockGraphQLResponse), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
});

const result = await fetchGitHubContributions('octocat', {
bypassCache: true,
repo: 'facebook/react',
});

expect(result.calendar.totalContributions).toBe(0);
expect(result.calendar.weeks[0].contributionDays[0].contributionCount).toBe(0);
expect(result.repoContributions).toEqual([]);
});
});
});
Loading
Loading