Skip to content

Commit c6619b1

Browse files
committed
fix(cache): cache contributed repositories requests
1 parent a4dcf09 commit c6619b1

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
@@ -1191,6 +1191,83 @@ describe('GitHub API cache behavior', () => {
11911191

11921192
expect(fetch).toHaveBeenCalledTimes(1);
11931193
});
1194+
1195+
it('cache hit: second fetchContributedRepos call uses cached value', async () => {
1196+
const mockNodes = [{ name: 'cached-repo' }];
1197+
vi.mocked(fetch).mockResolvedValue(
1198+
mockResponse({
1199+
data: {
1200+
user: {
1201+
repositoriesContributedTo: {
1202+
nodes: mockNodes,
1203+
},
1204+
},
1205+
},
1206+
})
1207+
);
1208+
1209+
await fetchContributedRepos('octocat');
1210+
await fetchContributedRepos('octocat');
1211+
1212+
expect(fetch).toHaveBeenCalledTimes(1);
1213+
});
1214+
1215+
it('dedupes concurrent fetchContributedRepos requests for the same cold cache key', async () => {
1216+
let resolveFetch!: (response: Response) => void;
1217+
vi.mocked(fetch).mockImplementation(
1218+
() =>
1219+
new Promise<Response>((resolve) => {
1220+
resolveFetch = resolve;
1221+
})
1222+
);
1223+
1224+
const requests = Promise.all([
1225+
fetchContributedRepos('octocat'),
1226+
fetchContributedRepos('octocat'),
1227+
fetchContributedRepos('octocat'),
1228+
]);
1229+
1230+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
1231+
1232+
resolveFetch(
1233+
mockResponse({
1234+
data: {
1235+
user: {
1236+
repositoriesContributedTo: {
1237+
nodes: [{ name: 'deduped-repo' }],
1238+
},
1239+
},
1240+
},
1241+
})
1242+
);
1243+
1244+
const results = await requests;
1245+
expect(results.map((repos) => repos[0]?.name)).toEqual([
1246+
'deduped-repo',
1247+
'deduped-repo',
1248+
'deduped-repo',
1249+
]);
1250+
});
1251+
1252+
it('refresh bypass: bypassCache=true forces fresh fetchContributedRepos fetch', async () => {
1253+
const mockNodes = [{ name: 'repo' }];
1254+
vi.mocked(fetch).mockImplementation(async () =>
1255+
mockResponse({
1256+
data: {
1257+
user: {
1258+
repositoriesContributedTo: {
1259+
nodes: mockNodes,
1260+
},
1261+
},
1262+
},
1263+
})
1264+
);
1265+
1266+
await fetchContributedRepos('octocat');
1267+
await fetchContributedRepos('octocat', { bypassCache: true });
1268+
1269+
expect(fetch).toHaveBeenCalledTimes(2);
1270+
});
11941271
});
11951272

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

lib/github.ts

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

256258
interface GitHubUserProfile {
257259
login: string;
@@ -268,18 +270,18 @@ interface GitHubUserProfile {
268270
}
269271

270272
export function cacheKey(
271-
kind: 'contributions' | 'profile' | 'repos',
273+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
272274
username: string,
273275
year?: string
274276
): string;
275277
export function cacheKey(
276-
kind: 'contributions' | 'profile' | 'repos',
278+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
277279
username: string,
278280
from?: string,
279281
to?: string
280282
): string;
281283
export function cacheKey(
282-
kind: 'contributions' | 'profile' | 'repos',
284+
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
283285
username: string,
284286
yearOrFrom?: string,
285287
to?: string
@@ -296,9 +298,11 @@ export function clearGitHubApiCacheForTests(): void {
296298
contributionsCache.clear();
297299
profileCache.clear();
298300
reposCache.clear();
301+
contributedReposCache.clear();
299302
pendingContributions.clear();
300303
pendingProfiles.clear();
301304
pendingRepos.clear();
305+
pendingContributedRepos.clear();
302306
}
303307

304308
function dedupeRequest<T>(
@@ -906,38 +910,54 @@ export async function fetchContributedRepos(
906910
username: string,
907911
options: FetchOptions = {}
908912
): Promise<Record<string, unknown>[]> {
909-
const query = `
910-
query($login: String!) {
911-
user(login: $login) {
912-
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
913-
nodes {
914-
name
915-
nameWithOwner
916-
owner { login }
917-
stargazerCount
918-
forkCount
919-
primaryLanguage { name }
920-
updatedAt
913+
const key = cacheKey('repos:contributed', username);
914+
if (!options.bypassCache) {
915+
const cached = await contributedReposCache.get(key);
916+
if (cached) return cached;
917+
}
918+
919+
const load = async () => {
920+
const query = `
921+
query($login: String!) {
922+
user(login: $login) {
923+
repositoriesContributedTo(first: 100, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], orderBy: {field: UPDATED_AT, direction: DESC}) {
924+
nodes {
925+
name
926+
nameWithOwner
927+
owner { login }
928+
stargazerCount
929+
forkCount
930+
primaryLanguage { name }
931+
updatedAt
932+
}
921933
}
922934
}
923935
}
936+
`;
937+
938+
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
939+
method: 'POST',
940+
headers: getHeaders(),
941+
body: JSON.stringify({
942+
query,
943+
variables: { login: username },
944+
}),
945+
cache: 'no-store',
946+
signal: options.signal,
947+
});
948+
949+
if (!res.ok) return [];
950+
const data = await res.json();
951+
const result = data?.data?.user?.repositoriesContributedTo?.nodes || [];
952+
953+
if (!options.bypassCache) {
954+
await contributedReposCache.set(key, result, GITHUB_CACHE_TTL_MS);
924955
}
925-
`;
926-
927-
const res = await fetchWithRetry(GITHUB_GRAPHQL_URL, {
928-
method: 'POST',
929-
headers: getHeaders(),
930-
body: JSON.stringify({
931-
query,
932-
variables: { login: username },
933-
}),
934-
cache: 'no-store',
935-
signal: options.signal,
936-
});
956+
return result;
957+
};
937958

938-
if (!res.ok) return [];
939-
const data = await res.json();
940-
return data?.data?.user?.repositoriesContributedTo?.nodes || [];
959+
if (options.bypassCache) return load();
960+
return dedupeRequest(pendingContributedRepos, key, load);
941961
}
942962

943963
export interface DeveloperScoreInput {

0 commit comments

Comments
 (0)