Skip to content

Commit 0da7b59

Browse files
vn7n24fzkqclaude
andcommitted
perf(api): sum star totals via REST; shrink profile GraphQL documents
Star totals came from GraphQL repo pages (100 nodes per query, plus a follow-up pagination document), spending constrained GraphQL points on data REST hands out on the same repos listing the service already reads elsewhere. Sum stargazer_count over REST repo pages instead — a separate, otherwise-idle hourly pool — running concurrently with whichever GraphQL path (combined or split) fetches the rest of the profile. The combined UserDetails and split UserDetailsCore documents drop their 100-node repositories page down to repositories(first: 1) { totalCount }: the exact repo count survives, and the lighter document scores lower with GitHub's cost estimator, so heavy accounts should hit the resource-limit split path less often. Fork filtering and public-only semantics match the old isFork: false / privacy: PUBLIC exactly; the #164 every-page star accuracy is preserved (REST pagination, same Vercel page/time budgets). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 83541c7 commit 0da7b59

2 files changed

Lines changed: 76 additions & 137 deletions

File tree

src/github-api/profile-details.ts

Lines changed: 53 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import request, {assertNoGraphQLErrors, isTooExpensive} from '../utils/request';
1+
import request, {assertNoGraphQLErrors, isTooExpensive, restRequest} from '../utils/request';
22
import {shouldFetchNextPage} from '../const/pagination';
33
import {withDataCache, kvGetFlag, kvSetFlag, requestStartedAt} from '../utils/data-cache';
44

@@ -54,15 +54,8 @@ const fetcher = (token: string, variables: any) => {
5454
company
5555
location
5656
websiteUrl
57-
repositories(first: 100,privacy:PUBLIC, isFork: false, ownerAffiliations: OWNER) {
57+
repositories(first: 1, privacy:PUBLIC, isFork: false, ownerAffiliations: OWNER) {
5858
totalCount
59-
nodes {
60-
stargazerCount
61-
}
62-
pageInfo {
63-
endCursor
64-
hasNextPage
65-
}
6659
}
6760
contributionsCollection {
6861
contributionCalendar {
@@ -117,15 +110,8 @@ const coreFetcher = (token: string, variables: any) => {
117110
company
118111
location
119112
websiteUrl
120-
repositories(first: 100,privacy:PUBLIC, isFork: false, ownerAffiliations: OWNER) {
113+
repositories(first: 1, privacy:PUBLIC, isFork: false, ownerAffiliations: OWNER) {
121114
totalCount
122-
nodes {
123-
stargazerCount
124-
}
125-
pageInfo {
126-
endCursor
127-
hasNextPage
128-
}
129115
}
130116
}
131117
}
@@ -251,77 +237,33 @@ async function fetchCalendarWeeks(username: string, token: string): Promise<Cale
251237

252238
// Rebuilds the exact `user` object shape of the combined UserDetails query
253239
// from the three split queries, so the code after the cache boundary doesn't
254-
// care which path produced it. Star pagination starts as soon as the core
255-
// query (which owns the first page's cursor) resolves and runs CONCURRENTLY
256-
// with the calendar/years/counts queries — the split path only exists for
257-
// very active accounts, exactly the ones with many star pages, and running
258-
// the two serially is what pushed sindresorhus-class renders past Vercel's
259-
// 30s kill (killed functions cache nothing, so they never converged).
260-
async function fetchUserDetailsSplit(
261-
username: string,
262-
token: string,
263-
startedAt: number
264-
): Promise<{user: any; totalStars: number}> {
265-
const corePromise = coreFetcher(token, {login: username});
266-
const starsPromise = corePromise.then(coreRes => {
267-
assertNoGraphQLErrors(coreRes, 'GetProfileDetails (core) failed');
268-
return paginateStars(coreRes.data.data.user.repositories, username, token, startedAt);
269-
});
270-
const [coreRes, totalStars, weeks, yearsRes, countsRes] = await Promise.all([
271-
corePromise,
272-
starsPromise,
240+
// care which path produced it. Star totals are NOT fetched here — they come
241+
// from the REST pagination that getProfileDetails runs concurrently with
242+
// whichever GraphQL path is taken.
243+
async function fetchUserDetailsSplit(username: string, token: string): Promise<any> {
244+
const [coreRes, weeks, yearsRes, countsRes] = await Promise.all([
245+
coreFetcher(token, {login: username}),
273246
fetchCalendarWeeks(username, token),
274247
contributionYearsFetcher(token, {login: username}),
275248
countsFetcher(token, {login: username})
276249
]);
250+
assertNoGraphQLErrors(coreRes, 'GetProfileDetails (core) failed');
277251
assertNoGraphQLErrors(yearsRes, 'GetProfileDetails (years) failed');
278252
assertNoGraphQLErrors(countsRes, 'GetProfileDetails (counts) failed');
279253
const core = coreRes.data.data.user;
280254
const counts = countsRes.data.data.user;
281255
return {
282-
user: {
283-
...core,
284-
contributionsCollection: {
285-
contributionCalendar: {weeks},
286-
contributionYears: yearsRes.data.data.user.contributionsCollection.contributionYears
287-
},
288-
repositoriesContributedTo: counts.repositoriesContributedTo,
289-
pullRequests: counts.pullRequests,
290-
issues: counts.issues
256+
...core,
257+
contributionsCollection: {
258+
contributionCalendar: {weeks},
259+
contributionYears: yearsRes.data.data.user.contributionsCollection.contributionYears
291260
},
292-
totalStars
261+
repositoriesContributedTo: counts.repositoriesContributedTo,
262+
pullRequests: counts.pullRequests,
263+
issues: counts.issues
293264
};
294265
}
295266

296-
// Lightweight follow-up query used only to finish the star count for accounts
297-
// with more than 100 repos — the heavy fields (contribution calendar etc.) all
298-
// come from the first page.
299-
const starsFetcher = (token: string, variables: any) => {
300-
return request(
301-
{
302-
Authorization: `bearer ${token}`
303-
},
304-
{
305-
query: `
306-
query UserStars($login: String!, $endCursor: String!) {
307-
user(login: $login) {
308-
repositories(first: 100, after: $endCursor, privacy:PUBLIC, isFork: false, ownerAffiliations: OWNER) {
309-
nodes {
310-
stargazerCount
311-
}
312-
pageInfo {
313-
endCursor
314-
hasNextPage
315-
}
316-
}
317-
}
318-
}
319-
`,
320-
variables
321-
}
322-
);
323-
};
324-
325267
// ---- compact cache payload ----
326268
// The raw user object is dominated by the contribution calendar: ~365 verbose
327269
// day objects pushed the cached profile to ~19KB, and profile keys were the
@@ -411,38 +353,31 @@ function splitFlagKey(username: string): string {
411353
return `v1:pdx:${username.toLowerCase()}`;
412354
}
413355

414-
// The main query only covers the first 100 repos; accounts with more were
415-
// undercounting stars (#164). Keep summing with the lightweight star-only
416-
// query — unbounded off Vercel, bounded on it. The budget is measured from
417-
// the profile fetch's start, not the pagination's, so the phases can't stack.
418-
async function paginateStars(firstPage: any, username: string, token: string, startedAt: number): Promise<number> {
419-
let stars: number = firstPage.nodes.reduce(
420-
(acc: number, curr: {stargazerCount: number}) => acc + curr.stargazerCount,
421-
0
422-
);
423-
let starsCursor: string | null = firstPage.pageInfo?.endCursor ?? null;
424-
let starsPages = 1;
425-
let starsHasNextPage = shouldFetchNextPage(
426-
!!firstPage.pageInfo?.hasNextPage,
427-
starsPages,
428-
undefined,
429-
startedAt,
430-
PD_FETCH_BUDGET_MS
431-
);
432-
while (starsHasNextPage && starsCursor) {
433-
const starsRes: any = await starsFetcher(token, {login: username, endCursor: starsCursor});
434-
assertNoGraphQLErrors(starsRes, 'GetProfileDetails failed');
435-
const repos = starsRes.data.data.user.repositories;
436-
stars += repos.nodes.reduce((acc: number, curr: {stargazerCount: number}) => acc + curr.stargazerCount, 0);
437-
starsCursor = repos.pageInfo?.endCursor ?? null;
438-
starsPages += 1;
439-
starsHasNextPage = shouldFetchNextPage(
440-
!!repos.pageInfo?.hasNextPage,
441-
starsPages,
442-
undefined,
443-
startedAt,
444-
PD_FETCH_BUDGET_MS
445-
);
356+
// Star totals come from REST repo pagination (stargazer_count rides along on
357+
// GET /users/:login/repos) instead of GraphQL pages: REST draws on a separate,
358+
// otherwise-idle hourly quota, and dropping the 100-node repos page from the
359+
// GraphQL documents also lowers their cost-estimator score — fewer split
360+
// rejections for heavy accounts. Fork filtering matches the old isFork: false;
361+
// REST only lists public repos, matching privacy: PUBLIC. Pagination is
362+
// unbounded off Vercel and bounded on it (#164 semantics unchanged); the
363+
// budget is measured from the profile fetch's start so the phases can't stack.
364+
async function fetchTotalStars(username: string, token: string, startedAt: number): Promise<number> {
365+
let stars = 0;
366+
let pages = 0;
367+
let hasNextPage = true;
368+
while (hasNextPage) {
369+
const res = await restRequest(token, `/users/${encodeURIComponent(username)}/repos`, {
370+
per_page: 100,
371+
page: pages + 1,
372+
type: 'owner'
373+
});
374+
const repos: any[] = Array.isArray(res.data) ? res.data : [];
375+
for (const repo of repos) {
376+
if (repo.fork) continue;
377+
stars += repo.stargazer_count ?? 0;
378+
}
379+
pages += 1;
380+
hasNextPage = shouldFetchNextPage(repos.length === 100, pages, undefined, startedAt, PD_FETCH_BUDGET_MS);
446381
}
447382
return stars;
448383
}
@@ -461,8 +396,15 @@ export async function getProfileDetails(username: string, token: string): Promis
461396
// instead of a 30s FUNCTION_INVOCATION_TIMEOUT that caches nothing.
462397
throw new Error(`Profile fetch for ${username} timed out before completion`);
463398
}
399+
// REST star pagination runs concurrently with whichever GraphQL path
400+
// is taken — separate quota pools, so they don't contend. The no-op
401+
// catch keeps a stars failure from surfacing as an unhandled rejection
402+
// while the GraphQL path is still the one that throws first; the real
403+
// rejection still propagates through the await below.
404+
const starsPromise = fetchTotalStars(username, token, startedAt);
405+
starsPromise.catch(() => undefined);
406+
464407
let fetchedUser: any = null;
465-
let totalStars: number | null = null;
466408
const useSplit = await kvGetFlag(splitFlagKey(username));
467409
if (!useSplit) {
468410
try {
@@ -480,14 +422,10 @@ export async function getProfileDetails(username: string, token: string): Promis
480422
}
481423
if (fetchedUser === null) {
482424
// Rejected now or flagged earlier — same fields via three smaller
483-
// queries, with star pagination running concurrently.
484-
const split = await fetchUserDetailsSplit(username, token, startedAt);
485-
fetchedUser = split.user;
486-
totalStars = split.totalStars;
487-
}
488-
if (totalStars === null) {
489-
totalStars = await paginateStars(fetchedUser.repositories, username, token, startedAt);
425+
// queries.
426+
fetchedUser = await fetchUserDetailsSplit(username, token);
490427
}
428+
const totalStars = await starsPromise;
491429

492430
return compressProfile(fetchedUser, totalStars);
493431
});

tests/github-api/profile-details.test.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ const data = {
1515
location: 'Taiwan',
1616
websiteUrl: null,
1717
repositories: {
18-
totalCount: 30,
19-
nodes: [{stargazerCount: 110}, {stargazerCount: 20}]
18+
totalCount: 30
2019
},
2120
issues: {totalCount: 10},
2221
repositoriesContributedTo: {totalCount: 30},
@@ -67,9 +66,19 @@ afterEach(() => {
6766
mock.reset();
6867
});
6968

69+
// Star totals now come from REST repo pagination (stargazer_count), which runs
70+
// concurrently with the GraphQL profile fetch — every test mocks both.
71+
const restRepo = (stars: number, fork = false) => ({stargazer_count: stars, fork});
72+
const mockRestStars = (username: string, pages: any[][] = [[restRepo(110), restRepo(20)]]) =>
73+
mock.onGet(`https://api.github.com/users/${username}/repos`).reply(config => {
74+
const page = config.params?.page ?? 1;
75+
return [200, pages[page - 1] ?? []];
76+
});
77+
7078
describe('github api for profile details', () => {
7179
it('should get correct profile data', async () => {
7280
mock.onPost('https://api.github.com/graphql').reply(200, data);
81+
mockRestStars('vn7n24fzkq');
7382
const profileDetails = await getProfileDetails('vn7n24fzkq', 'token');
7483
expect(profileDetails).toEqual({
7584
id: 'userID',
@@ -168,6 +177,7 @@ describe('github api for profile details', () => {
168177
return [500, {}];
169178
});
170179

180+
mockRestStars('antroll');
171181
const profileDetails = await getProfileDetails('antroll', 'token');
172182
// identical result to the combined-query path
173183
expect(profileDetails.totalStars).toBe(130);
@@ -238,6 +248,7 @@ describe('github api for profile details', () => {
238248
return [500, {}];
239249
});
240250

251+
mockRestStars('antroll');
241252
const profileDetails = await getProfileDetails('antroll', 'token');
242253
expect(profileDetails.totalStars).toBe(130);
243254
expect(profileDetails.totalPullRequestContributions).toBe(40);
@@ -315,6 +326,7 @@ describe('github api for profile details', () => {
315326
return [500, {}];
316327
});
317328

329+
mockRestStars('antfu');
318330
const profileDetails = await getProfileDetails('antfu', 'token');
319331
// both half-window days present, in order
320332
expect(profileDetails.contributions.map(c => c.contributionCount)).toEqual([4, 6]);
@@ -335,27 +347,14 @@ describe('github api for profile details', () => {
335347
expect(new Set(dates).size).toBe(dates.length);
336348
});
337349

338-
it('sums stars across every repo page, not just the first 100', async () => {
339-
const page1 = JSON.parse(JSON.stringify(data));
340-
page1.data.user.repositories.pageInfo = {endCursor: 'C1', hasNextPage: true};
341-
const starsPage2 = {
342-
data: {
343-
user: {
344-
repositories: {
345-
nodes: [{stargazerCount: 7}, {stargazerCount: 3}],
346-
pageInfo: {endCursor: null, hasNextPage: false}
347-
}
348-
}
349-
}
350-
};
351-
mock.onPost('https://api.github.com/graphql')
352-
.replyOnce(200, page1)
353-
.onPost('https://api.github.com/graphql')
354-
.replyOnce(200, starsPage2)
355-
.onAny();
350+
it('sums stars across every REST repo page, not just the first 100', async () => {
351+
mock.onPost('https://api.github.com/graphql').reply(200, data);
352+
// page 1 is full (100 repos), so pagination continues to page 2;
353+
// the fork's 999 stars must be excluded (GraphQL used isFork: false)
354+
const fullPage = Array.from({length: 100}, () => restRepo(1));
355+
mockRestStars('vn7n24fzkq', [fullPage, [restRepo(7), restRepo(3), restRepo(999, true)]]);
356356
const profileDetails = await getProfileDetails('vn7n24fzkq', 'token');
357-
// 110 + 20 from page 1, 7 + 3 from the follow-up star query
358-
expect(profileDetails.totalStars).toBe(140);
357+
expect(profileDetails.totalStars).toBe(110);
359358
});
360359
});
361360

@@ -373,6 +372,7 @@ describe('compact profile cache payload', () => {
373372
}
374373
];
375374
mock.onPost('https://api.github.com/graphql').reply(200, consecutive);
375+
mockRestStars('someone');
376376
const pd = await getProfileDetails('someone', 'token');
377377
expect(pd.contributions.map(c => c.date.toISOString().slice(0, 10))).toEqual([
378378
'2025-12-30',
@@ -386,6 +386,7 @@ describe('compact profile cache payload', () => {
386386
it('keeps non-consecutive days intact via the explicit fallback', async () => {
387387
// the base fixture has gaps (2019-09-06/07 then 2020-01-12)
388388
mock.onPost('https://api.github.com/graphql').reply(200, data);
389+
mockRestStars('someone');
389390
const pd = await getProfileDetails('someone', 'token');
390391
expect(pd.contributions.map(c => c.date.toISOString().slice(0, 10))).toEqual([
391392
'2019-09-06',

0 commit comments

Comments
 (0)