Skip to content

Commit 3ed113d

Browse files
authored
fix(cache): cache contributed repositories requests (JhaSourav07#2050)
## Description Fixes JhaSourav07#2048 This PR adds caching support for `fetchContributedRepos()` to reduce unnecessary GitHub GraphQL requests and improve dashboard performance. Previously, `getFullDashboardData()` cached user profiles, repositories, and contribution calendars, but `fetchContributedRepos()` always performed a live API request. As a result, repeated dashboard loads for the same user generated identical GitHub queries and consumed API rate limits unnecessarily. This change introduces caching using a dedicated cache key: ```ts repos:contributed:${username} ``` The implementation now follows the same caching strategy used by the other GitHub data fetchers: * Check L1/L2 cache before making a GitHub request * Return cached data when available * Store fresh responses in cache after successful fetches Benefits: * Faster dashboard response times * Reduced GitHub API usage * Lower risk of rate-limit exhaustion * Consistent caching behavior across dashboard data sources ## Pillar * [ ] 🎨 Pillar 1 — New Theme Design * [ ] 📐 Pillar 2 — Geometric SVG Improvement * [ ] 🕐 Pillar 3 — Timezone Logic Optimization * [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview N/A — backend caching improvement with no visual SVG/UI changes. ## Checklist before requesting a review: * [x] I have read the `CONTRIBUTING.md` file. * [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). * [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). * [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). * [ ] I have updated `README.md` if I added a new theme or URL parameter. (Not applicable) * [x] I have started the repo. * [x] I have made sure that i have only one commit to merge in this PR. * [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). * [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 2abf8c9 + 015c554 commit 3ed113d

2 files changed

Lines changed: 127 additions & 30 deletions

File tree

lib/github.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,83 @@ describe('GitHub API cache behavior', () => {
15271527

15281528
expect(fetch).toHaveBeenCalledTimes(1);
15291529
});
1530+
1531+
it('cache hit: second fetchContributedRepos call uses cached value', async () => {
1532+
const mockNodes = [{ name: 'cached-repo' }];
1533+
vi.mocked(fetch).mockResolvedValue(
1534+
mockResponse({
1535+
data: {
1536+
user: {
1537+
repositoriesContributedTo: {
1538+
nodes: mockNodes,
1539+
},
1540+
},
1541+
},
1542+
})
1543+
);
1544+
1545+
await fetchContributedRepos('octocat');
1546+
await fetchContributedRepos('octocat');
1547+
1548+
expect(fetch).toHaveBeenCalledTimes(1);
1549+
});
1550+
1551+
it('dedupes concurrent fetchContributedRepos requests for the same cold cache key', async () => {
1552+
let resolveFetch!: (response: Response) => void;
1553+
vi.mocked(fetch).mockImplementation(
1554+
() =>
1555+
new Promise<Response>((resolve) => {
1556+
resolveFetch = resolve;
1557+
})
1558+
);
1559+
1560+
const requests = Promise.all([
1561+
fetchContributedRepos('octocat'),
1562+
fetchContributedRepos('octocat'),
1563+
fetchContributedRepos('octocat'),
1564+
]);
1565+
1566+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
1567+
1568+
resolveFetch(
1569+
mockResponse({
1570+
data: {
1571+
user: {
1572+
repositoriesContributedTo: {
1573+
nodes: [{ name: 'deduped-repo' }],
1574+
},
1575+
},
1576+
},
1577+
})
1578+
);
1579+
1580+
const results = await requests;
1581+
expect(results.map((repos) => repos[0]?.name)).toEqual([
1582+
'deduped-repo',
1583+
'deduped-repo',
1584+
'deduped-repo',
1585+
]);
1586+
});
1587+
1588+
it('refresh bypass: bypassCache=true forces fresh fetchContributedRepos fetch', async () => {
1589+
const mockNodes = [{ name: 'repo' }];
1590+
vi.mocked(fetch).mockImplementation(async () =>
1591+
mockResponse({
1592+
data: {
1593+
user: {
1594+
repositoriesContributedTo: {
1595+
nodes: mockNodes,
1596+
},
1597+
},
1598+
},
1599+
})
1600+
);
1601+
1602+
await fetchContributedRepos('octocat');
1603+
await fetchContributedRepos('octocat', { bypassCache: true });
1604+
1605+
expect(fetch).toHaveBeenCalledTimes(2);
1606+
});
15301607
});
15311608

15321609
describe('generateAchievements', () => {

lib/github.ts

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,11 @@ export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
250250
const contributionsCache = new DistributedCache<ExtendedContributionData>(1000);
251251
const profileCache = new DistributedCache<GitHubUserProfile>(1000);
252252
const reposCache = new DistributedCache<GitHubRepo[]>(500);
253+
const contributedReposCache = new DistributedCache<Record<string, unknown>[]>(500);
253254
const pendingContributions = new Map<string, Promise<ExtendedContributionData>>();
254255
const pendingProfiles = new Map<string, Promise<GitHubUserProfile>>();
255256
const pendingRepos = new Map<string, Promise<GitHubRepo[]>>();
257+
const pendingContributedRepos = new Map<string, Promise<Record<string, unknown>[]>>();
256258

257259
interface GitHubUserProfile {
258260
login: string;
@@ -269,18 +271,18 @@ interface GitHubUserProfile {
269271
}
270272

271273
export function cacheKey(
272-
kind: 'contributions' | 'profile' | 'repos',
274+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
273275
username: string,
274276
year?: string
275277
): string;
276278
export function cacheKey(
277-
kind: 'contributions' | 'profile' | 'repos',
279+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
278280
username: string,
279281
from?: string,
280282
to?: string
281283
): string;
282284
export function cacheKey(
283-
kind: 'contributions' | 'profile' | 'repos',
285+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
284286
username: string,
285287
yearOrFrom?: string,
286288
to?: string
@@ -297,9 +299,11 @@ export function clearGitHubApiCacheForTests(): void {
297299
contributionsCache.clear();
298300
profileCache.clear();
299301
reposCache.clear();
302+
contributedReposCache.clear();
300303
pendingContributions.clear();
301304
pendingProfiles.clear();
302305
pendingRepos.clear();
306+
pendingContributedRepos.clear();
303307
}
304308

305309
function dedupeRequest<T>(
@@ -940,38 +944,54 @@ export async function fetchContributedRepos(
940944
username: string,
941945
options: FetchOptions = {}
942946
): Promise<Record<string, unknown>[]> {
943-
const query = `
944-
query($login: String!) {
945-
user(login: $login) {
946-
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
947-
nodes {
948-
name
949-
nameWithOwner
950-
owner { login }
951-
stargazerCount
952-
forkCount
953-
primaryLanguage { name }
954-
updatedAt
947+
const key = cacheKey('repos:contributed', username);
948+
if (!options.bypassCache) {
949+
const cached = await contributedReposCache.get(key);
950+
if (cached) return cached;
951+
}
952+
953+
const load = async () => {
954+
const query = `
955+
query($login: String!) {
956+
user(login: $login) {
957+
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
958+
nodes {
959+
name
960+
nameWithOwner
961+
owner { login }
962+
stargazerCount
963+
forkCount
964+
primaryLanguage { name }
965+
updatedAt
966+
}
955967
}
956968
}
957969
}
958-
}
959-
`;
970+
`;
960971

961-
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
962-
method: 'POST',
963-
headers: getHeaders(),
964-
body: JSON.stringify({
965-
query,
966-
variables: { login: username },
967-
}),
968-
cache: 'no-store',
969-
signal: options.signal,
970-
});
972+
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
973+
method: 'POST',
974+
headers: getHeaders(),
975+
body: JSON.stringify({
976+
query,
977+
variables: { login: username },
978+
}),
979+
cache: 'no-store',
980+
signal: options.signal,
981+
});
982+
983+
if (!res.ok) return [];
984+
const data = await res.json();
985+
const result = data?.data?.user?.repositoriesContributedTo?.nodes || [];
986+
987+
if (!options.bypassCache) {
988+
await contributedReposCache.set(key, result, GITHUB_CACHE_TTL_MS);
989+
}
990+
return result;
991+
};
971992

972-
if (!res.ok) return [];
973-
const data = await res.json();
974-
return data?.data?.user?.repositoriesContributedTo?.nodes || [];
993+
if (options.bypassCache) return load();
994+
return dedupeRequest(pendingContributedRepos, key, load);
975995
}
976996

977997
export interface DeveloperScoreInput {

0 commit comments

Comments
 (0)