From 4c74e0ef62e0b68f836dbff80ed5bafac05dc547 Mon Sep 17 00:00:00 2001 From: John Costa Date: Wed, 6 May 2026 13:48:29 -1000 Subject: [PATCH] perf: fetch merged PRs via GraphQL (different rate-limit bucket) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merged-PR pagination was the rate-limit bottleneck — up to 10 search calls per cold-start, multiplied by 4 widget endpoints = 40 search calls in a cold-start cluster, blowing through GitHub's 30/min REST search bucket. GraphQL search runs against the 5000/hour points bucket instead. At ~1 point per page, 4 widgets × 10 pages = 40 points. Plenty of headroom. Star counts also come back inline via repository.stargazerCount, so the card/recent/ top-repos transforms no longer need separate getStarsForRepos roundtrips. Open and closed-unmerged counts still use REST search (1 call each, well within the 30/min budget). Touches: - github-data.ts: paginateMergedPRsGraphQL replaces paginateSearch - ContributionData adds repoStars: Record populated inline - card/recent/top-repos endpoints filter by data.repoStars instead of fetching stars separately --- api/card/[username].ts | 12 +- api/recent/[username].ts | 11 +- api/top-repos/[username].ts | 10 +- lib/contribution-cache.test.ts | 1 + lib/endpoint-handler.test.ts | 1 + lib/github-data.test.ts | 206 +++++++++++++++++---------------- lib/github-data.ts | 125 +++++++++++++------- lib/svg-activity.test.ts | 1 + lib/svg-card.test.ts | 1 + lib/svg-recent.test.ts | 1 + lib/svg-top-repos.test.ts | 1 + 11 files changed, 206 insertions(+), 164 deletions(-) diff --git a/api/card/[username].ts b/api/card/[username].ts index 649bb98..7380cb6 100644 --- a/api/card/[username].ts +++ b/api/card/[username].ts @@ -1,6 +1,4 @@ -import { Octokit } from '@octokit/rest'; import { createWidgetHandler } from '../../lib/endpoint-handler.js'; -import { getStarsForRepos } from '../../lib/github-stars.js'; import { renderStatsCard } from '../../lib/svg-card.js'; const DEFAULT_MIN_STARS = 50; @@ -12,17 +10,11 @@ export default createWidgetHandler({ errorTextY: 100, render: renderStatsCard, cacheKeyParams: ['minStars'], - transform: async (data, query) => { + transform: (data, query) => { const parsed = typeof query.minStars === 'string' ? parseInt(query.minStars, 10) : NaN; const minStars = Number.isNaN(parsed) ? DEFAULT_MIN_STARS : Math.max(0, parsed); - // Fetch star counts for repos in topRepos (already excludes own repos) - const repoNames = data.topRepos.map((r) => r.repo); - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - const starsMap = await getStarsForRepos(octokit, repoNames); - - // Filter topRepos by minStars and recalculate aggregates - const qualifying = data.topRepos.filter((r) => (starsMap.get(r.repo) ?? 0) >= minStars); + const qualifying = data.topRepos.filter((r) => (data.repoStars[r.repo] ?? 0) >= minStars); const filteredMerged = qualifying.reduce((sum, r) => sum + r.count, 0); return { diff --git a/api/recent/[username].ts b/api/recent/[username].ts index 580fbba..bfd7fbf 100644 --- a/api/recent/[username].ts +++ b/api/recent/[username].ts @@ -1,6 +1,4 @@ -import { Octokit } from '@octokit/rest'; import { createWidgetHandler } from '../../lib/endpoint-handler.js'; -import { getStarsForRepos } from '../../lib/github-stars.js'; import { renderRecentCard } from '../../lib/svg-recent.js'; const DEFAULT_MIN_STARS = 50; @@ -12,16 +10,11 @@ export default createWidgetHandler({ errorTextY: 45, render: renderRecentCard, cacheKeyParams: ['minStars'], - transform: async (data, query) => { + transform: (data, query) => { const parsed = typeof query.minStars === 'string' ? parseInt(query.minStars, 10) : NaN; const minStars = Number.isNaN(parsed) ? DEFAULT_MIN_STARS : Math.max(0, parsed); - const repoNames = [...new Set(data.recentPRs.map((pr) => pr.repo))]; - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - const starsMap = await getStarsForRepos(octokit, repoNames); - - const filtered = data.recentPRs.filter((pr) => (starsMap.get(pr.repo) ?? 0) >= minStars); - + const filtered = data.recentPRs.filter((pr) => (data.repoStars[pr.repo] ?? 0) >= minStars); return { ...data, recentPRs: filtered }; }, }); diff --git a/api/top-repos/[username].ts b/api/top-repos/[username].ts index 7db4819..470ca73 100644 --- a/api/top-repos/[username].ts +++ b/api/top-repos/[username].ts @@ -1,6 +1,4 @@ -import { Octokit } from '@octokit/rest'; import { createWidgetHandler } from '../../lib/endpoint-handler.js'; -import { getStarsForRepos } from '../../lib/github-stars.js'; import { renderTopReposCard } from '../../lib/svg-top-repos.js'; const DEFAULT_MIN_STARS = 50; @@ -12,16 +10,12 @@ export default createWidgetHandler({ errorTextY: 45, render: renderTopReposCard, cacheKeyParams: ['minStars'], - transform: async (data, query) => { + transform: (data, query) => { const parsed = typeof query.minStars === 'string' ? parseInt(query.minStars, 10) : NaN; const minStars = Number.isNaN(parsed) ? DEFAULT_MIN_STARS : Math.max(0, parsed); - const repoNames = data.topRepos.map((r) => r.repo); - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - const starsMap = await getStarsForRepos(octokit, repoNames); - const filtered = data.topRepos - .filter((r) => (starsMap.get(r.repo) ?? 0) >= minStars) + .filter((r) => (data.repoStars[r.repo] ?? 0) >= minStars) .slice(0, 8); return { ...data, topRepos: filtered }; diff --git a/lib/contribution-cache.test.ts b/lib/contribution-cache.test.ts index 191fb5f..303e6bd 100644 --- a/lib/contribution-cache.test.ts +++ b/lib/contribution-cache.test.ts @@ -20,6 +20,7 @@ const okData = { dailyActivity: {}, streak: 0, topRepos: [], + repoStars: {}, }; describe('getContributionData', () => { diff --git a/lib/endpoint-handler.test.ts b/lib/endpoint-handler.test.ts index aa31bf7..8b6f954 100644 --- a/lib/endpoint-handler.test.ts +++ b/lib/endpoint-handler.test.ts @@ -30,6 +30,7 @@ function makeData(overrides: Partial = {}): ContributionData { dailyActivity: {}, streak: 0, topRepos: [], + repoStars: {}, ...overrides, }; } diff --git a/lib/github-data.test.ts b/lib/github-data.test.ts index 488154f..837c092 100644 --- a/lib/github-data.test.ts +++ b/lib/github-data.test.ts @@ -1,58 +1,87 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchContributionData, computeStreak } from './github-data.js'; -// Hoist shared mock references so they're available inside vi.mock factory -const { searchMock, pullsMock } = vi.hoisted(() => { +const { searchMock, graphqlMock } = vi.hoisted(() => { const searchMock = vi.fn(); - const pullsMock = vi.fn(); - return { searchMock, pullsMock }; + const graphqlMock = vi.fn(); + return { searchMock, graphqlMock }; }); -// Mock @octokit/rest using a regular function (not arrow) so `new Octokit()` works vi.mock('@octokit/rest', () => { return { Octokit: vi.fn(function (this: any) { this.rest = { search: { issuesAndPullRequests: searchMock }, - pulls: { get: pullsMock }, }; + this.graphql = graphqlMock; }), }; }); +interface RestLikeItem { + number: number; + title: string; + html_url: string; + repository_url: string; + closed_at: string; + pull_request: { merged_at: string }; +} + +/** Build a single GraphQL search page from a list of REST-shaped items. */ +function gqlPage( + items: RestLikeItem[], + totalCount: number, + hasNextPage: boolean = false, + endCursor: string | null = null, + starsByRepo: Record = {}, +) { + return { + search: { + issueCount: totalCount, + pageInfo: { hasNextPage, endCursor }, + nodes: items.map((i) => { + const repo = i.repository_url.replace('https://api.github.com/repos/', ''); + return { + number: i.number, + title: i.title, + url: i.html_url, + mergedAt: i.pull_request?.merged_at ?? i.closed_at ?? '', + repository: { + nameWithOwner: repo, + stargazerCount: starsByRepo[repo] ?? 0, + }, + }; + }), + }, + }; +} + describe('fetchContributionData', () => { beforeEach(() => { vi.clearAllMocks(); }); it('returns contribution stats for a valid user', async () => { - // Mock merged PRs query - searchMock.mockResolvedValueOnce({ - data: { - total_count: 42, - items: [ - { - number: 1, - title: 'Fix bug', - html_url: 'https://github.com/org/repo/pull/1', - repository_url: 'https://api.github.com/repos/org/repo', - closed_at: '2026-03-01T00:00:00Z', - pull_request: { merged_at: '2026-03-01T00:00:00Z' }, - }, - { - number: 2, - title: 'Add feature', - html_url: 'https://github.com/org/repo2/pull/2', - repository_url: 'https://api.github.com/repos/org/repo2', - closed_at: '2026-02-15T00:00:00Z', - pull_request: { merged_at: '2026-02-15T00:00:00Z' }, - }, - ], + const items: RestLikeItem[] = [ + { + number: 1, + title: 'Fix bug', + html_url: 'https://github.com/org/repo/pull/1', + repository_url: 'https://api.github.com/repos/org/repo', + closed_at: '2026-03-01T00:00:00Z', + pull_request: { merged_at: '2026-03-01T00:00:00Z' }, + }, + { + number: 2, + title: 'Add feature', + html_url: 'https://github.com/org/repo2/pull/2', + repository_url: 'https://api.github.com/repos/org/repo2', + closed_at: '2026-02-15T00:00:00Z', + pull_request: { merged_at: '2026-02-15T00:00:00Z' }, }, - }); - // Mock open PRs query + ]; + graphqlMock.mockResolvedValueOnce(gqlPage(items, 42, false, null, { 'org/repo': 100, 'org/repo2': 50 })); searchMock.mockResolvedValueOnce({ data: { total_count: 3, items: [] } }); - // Mock closed-unmerged PRs query searchMock.mockResolvedValueOnce({ data: { total_count: 5, items: [] } }); const result = await fetchContributionData('testuser', 'fake-token'); @@ -62,21 +91,20 @@ describe('fetchContributionData', () => { expect(result.merged).toBe(42); expect(result.open).toBe(3); expect(result.closedUnmerged).toBe(5); - expect(result.mergeRate).toBeCloseTo(89.4, 0); // 42 / (42 + 5), open PRs excluded from denominator + expect(result.mergeRate).toBeCloseTo(89.4, 0); expect(result.repoCount).toBe(2); expect(result.recentPRs).toHaveLength(2); expect(result.recentPRs[0].title).toBe('Fix bug'); expect(result.cappedMerged).toBe(false); - // topRepos should exclude user's own repos and sort by count expect(result.topRepos).toEqual([ { repo: 'org/repo', count: 1 }, { repo: 'org/repo2', count: 1 }, ]); + expect(result.repoStars).toEqual({ 'org/repo': 100, 'org/repo2': 50 }); }); - it('flags capped results when total_count exceeds 1000 and still paginates to the 1000-item cap', async () => { - // Page 1: 100 items in external repos - const page1Items = Array.from({ length: 100 }, (_, i) => ({ + it('flags capped results when totalCount >= 1000 and paginates up to the cap', async () => { + const page1Items: RestLikeItem[] = Array.from({ length: 100 }, (_, i) => ({ number: i + 1, title: `PR ${i + 1}`, html_url: `https://github.com/external/popular/pull/${i + 1}`, @@ -84,16 +112,17 @@ describe('fetchContributionData', () => { closed_at: '2026-03-01T00:00:00Z', pull_request: { merged_at: '2026-03-01T00:00:00Z' }, })); - searchMock.mockResolvedValueOnce({ - data: { total_count: 1500, items: page1Items }, - }); - // Pages 2–10: empty (still must be requested, proving pagination continues past the cap guard) - for (let p = 2; p <= 10; p++) { - searchMock.mockResolvedValueOnce({ - data: { total_count: 1500, items: [] }, - }); + // Page 1: 100 items, hasNextPage true + graphqlMock.mockResolvedValueOnce( + gqlPage(page1Items, 1500, true, 'cursor1', { 'external/popular': 800 }), + ); + // Pages 2..9: empty, hasNextPage true (still requested while items < cap) + for (let p = 2; p <= 9; p++) { + graphqlMock.mockResolvedValueOnce(gqlPage([], 1500, true, `cursor${p}`)); } - // Open + closed-unmerged + // Page 10: empty, hasNextPage true (loop exits at page === 10) + graphqlMock.mockResolvedValueOnce(gqlPage([], 1500, true, 'cursor10')); + // Open + closed-unmerged via REST search searchMock.mockResolvedValueOnce({ data: { total_count: 10, items: [] } }); searchMock.mockResolvedValueOnce({ data: { total_count: 20, items: [] } }); @@ -103,36 +132,15 @@ describe('fetchContributionData', () => { if ('error' in result && result.error) throw new Error('unexpected error'); expect(result.merged).toBe(1500); expect(result.cappedMerged).toBe(true); - // 10 merged pages + open + closed = 12 search calls. Pre-fix behavior stopped at 3. - expect(searchMock).toHaveBeenCalledTimes(12); - // topRepos must reflect the paginated items, not just page 1's snapshot + // 10 GraphQL pages for merged + 2 REST searches for open/closed + expect(graphqlMock).toHaveBeenCalledTimes(10); + expect(searchMock).toHaveBeenCalledTimes(2); expect(result.topRepos).toEqual([{ repo: 'external/popular', count: 100 }]); + expect(result.repoStars).toEqual({ 'external/popular': 800 }); }); - it('returns error state for non-existent user', async () => { - searchMock.mockRejectedValueOnce({ status: 422 }); - - const result = await fetchContributionData('nonexistent', 'fake-token'); - - expect(result.error).toBe('user_not_found'); - }); - - it('returns error state on rate limit', async () => { - searchMock.mockRejectedValueOnce({ status: 403 }); - - const result = await fetchContributionData('anyuser', 'fake-token'); - - expect(result.error).toBe('rate_limited'); - }); - - it('returns api_error for unexpected errors', async () => { - searchMock.mockRejectedValueOnce({ status: 500 }); - const result = await fetchContributionData('anyuser', 'fake-token'); - expect(result.error).toBe('api_error'); - }); - - it('paginates when user has more than 100 merged PRs', async () => { - const items100 = Array.from({ length: 100 }, (_, i) => ({ + it('stops paginating when GraphQL reports hasNextPage=false', async () => { + const items: RestLikeItem[] = Array.from({ length: 100 }, (_, i) => ({ number: i + 1, title: `PR ${i + 1}`, html_url: `https://github.com/org/repo/pull/${i + 1}`, @@ -140,13 +148,7 @@ describe('fetchContributionData', () => { closed_at: '2026-03-01T00:00:00Z', pull_request: { merged_at: '2026-03-01T00:00:00Z' }, })); - - // First call: merged PRs page 1 - searchMock.mockResolvedValueOnce({ - data: { total_count: 150, items: items100 }, - }); - // Second call: merged PRs page 2 (50 more items with different repo) - const items50 = Array.from({ length: 50 }, (_, i) => ({ + const items50: RestLikeItem[] = Array.from({ length: 50 }, (_, i) => ({ number: 100 + i + 1, title: `PR ${100 + i + 1}`, html_url: `https://github.com/org2/repo2/pull/${100 + i + 1}`, @@ -154,12 +156,9 @@ describe('fetchContributionData', () => { closed_at: '2026-02-15T00:00:00Z', pull_request: { merged_at: '2026-02-15T00:00:00Z' }, })); - searchMock.mockResolvedValueOnce({ - data: { total_count: 150, items: items50 }, - }); - // Third call: open PRs + graphqlMock.mockResolvedValueOnce(gqlPage(items, 150, true, 'cursor1')); + graphqlMock.mockResolvedValueOnce(gqlPage(items50, 150, false, null)); searchMock.mockResolvedValueOnce({ data: { total_count: 5, items: [] } }); - // Fourth call: closed-unmerged PRs searchMock.mockResolvedValueOnce({ data: { total_count: 10, items: [] } }); const result = await fetchContributionData('testuser', 'fake-token'); @@ -167,13 +166,35 @@ describe('fetchContributionData', () => { expect(result.error).toBeUndefined(); if ('error' in result && result.error) throw new Error('unexpected error'); expect(result.merged).toBe(150); - expect(result.repoCount).toBe(2); // org/repo + org2/repo2 + expect(result.repoCount).toBe(2); expect(result.cappedMerged).toBe(false); + expect(graphqlMock).toHaveBeenCalledTimes(2); + }); + + it('returns error state for non-existent user', async () => { + graphqlMock.mockRejectedValueOnce({ status: 422 }); + + const result = await fetchContributionData('nonexistent', 'fake-token'); + + expect(result.error).toBe('user_not_found'); + }); + + it('returns error state on rate limit', async () => { + graphqlMock.mockRejectedValueOnce({ status: 403 }); + + const result = await fetchContributionData('anyuser', 'fake-token'); + + expect(result.error).toBe('rate_limited'); + }); + + it('returns api_error for unexpected errors', async () => { + graphqlMock.mockRejectedValueOnce({ status: 500 }); + const result = await fetchContributionData('anyuser', 'fake-token'); + expect(result.error).toBe('api_error'); }); it('excludes own repos from topRepos and sorts by count', async () => { - const items = [ - // 3 PRs to external repo + const items: RestLikeItem[] = [ ...Array.from({ length: 3 }, (_, i) => ({ number: i + 1, title: `PR ${i + 1}`, @@ -182,7 +203,6 @@ describe('fetchContributionData', () => { closed_at: '2026-03-01T00:00:00Z', pull_request: { merged_at: '2026-03-01T00:00:00Z' }, })), - // 5 PRs to own repo (should be excluded) ...Array.from({ length: 5 }, (_, i) => ({ number: 10 + i, title: `Own PR ${i + 1}`, @@ -191,7 +211,6 @@ describe('fetchContributionData', () => { closed_at: '2026-03-01T00:00:00Z', pull_request: { merged_at: '2026-03-01T00:00:00Z' }, })), - // 1 PR to another external repo { number: 20, title: 'Small fix', @@ -202,7 +221,7 @@ describe('fetchContributionData', () => { }, ]; - searchMock.mockResolvedValueOnce({ data: { total_count: items.length, items } }); + graphqlMock.mockResolvedValueOnce(gqlPage(items, items.length, false, null)); searchMock.mockResolvedValueOnce({ data: { total_count: 0, items: [] } }); searchMock.mockResolvedValueOnce({ data: { total_count: 0, items: [] } }); @@ -210,12 +229,10 @@ describe('fetchContributionData', () => { expect(result.error).toBeUndefined(); if ('error' in result && result.error) throw new Error('unexpected error'); - // topRepos excludes myuser/myrepo and sorts by count descending expect(result.topRepos).toEqual([ { repo: 'external/popular', count: 3 }, { repo: 'other/lib', count: 1 }, ]); - // repoCount includes all repos (including own) expect(result.repoCount).toBe(3); }); }); @@ -261,7 +278,6 @@ describe('computeStreak', () => { it('breaks the streak when there is a gap between weeks', () => { const thisMonday = mondayOf(new Date()); const lastMonday = addDays(thisMonday, -7); - // Skip two weeks ago — gap const threeWeeksAgoMonday = addDays(thisMonday, -21); const dailyActivity: Record = { @@ -270,7 +286,6 @@ describe('computeStreak', () => { [dateKey(threeWeeksAgoMonday)]: 1, }; - // Only the two most recent consecutive weeks count; the gap stops the streak expect(computeStreak(dailyActivity)).toBe(2); }); @@ -279,14 +294,11 @@ describe('computeStreak', () => { const lastMonday = addDays(thisMonday, -7); const twoWeeksAgoMonday = addDays(thisMonday, -14); - // Activity in last week and the week before, but nothing in the current week const dailyActivity: Record = { [dateKey(lastMonday)]: 3, [dateKey(twoWeeksAgoMonday)]: 1, }; - // Current week is skipped (w === 0 with no activity is allowed), - // then last week and two weeks ago both count → streak of 2 expect(computeStreak(dailyActivity)).toBe(2); }); }); diff --git a/lib/github-data.ts b/lib/github-data.ts index 7a98173..241c2b2 100644 --- a/lib/github-data.ts +++ b/lib/github-data.ts @@ -32,6 +32,8 @@ export interface ContributionData { readonly streak: number; /** Top external repos by merged PR count (excludes user's own repos), sorted descending. */ readonly topRepos: readonly { repo: string; count: number }[]; + /** Star count per repo seen in merged PRs. Populated inline by the GraphQL fetch. */ + readonly repoStars: Readonly>; readonly error?: undefined; } @@ -87,36 +89,80 @@ export function computeStreak(dailyActivity: Record): number { return streak; } -async function paginateSearch( +interface MergedPRItem { + number: number; + title: string; + url: string; + /** owner/repo */ + repo: string; + mergedAt: string; + stargazerCount: number; +} + +interface GraphQLSearchResponse { + search: { + issueCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array<{ + number: number; + title: string; + url: string; + mergedAt: string; + repository: { nameWithOwner: string; stargazerCount: number }; + }>; + }; +} + +const MERGED_PR_QUERY = ` + query($q: String!, $after: String) { + search(query: $q, type: ISSUE, first: 100, after: $after) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + number + title + url + mergedAt + repository { nameWithOwner stargazerCount } + } + } + } + } +`; + +/** + * Paginate merged PRs via GraphQL search. Uses the 5000/hour GraphQL bucket + * instead of the 30/min REST search bucket, and gets star counts inline. + * + * GitHub's search results cap is still 1000 — paginate up to that. + */ +async function paginateMergedPRsGraphQL( octokit: InstanceType, query: string, maxItems: number = 1000, -): Promise<{ totalCount: number; items: Array<{ repository_url: string; number: number; title: string; html_url: string; closed_at: string | null; pull_request?: { merged_at?: string | null } }> }> { - const perPage = 100; - const firstPage = await octokit.rest.search.issuesAndPullRequests({ - q: query, - sort: 'updated', - order: 'desc', - per_page: perPage, - page: 1, - }); - - const totalCount = firstPage.data.total_count; - const items = [...firstPage.data.items]; - - // GitHub Search API caps results at 1000 (10 pages × 100). Paginate up to that cap. - const effectiveCount = Math.min(totalCount, maxItems); - const totalPages = Math.min(Math.ceil(effectiveCount / perPage), 10); - - for (let page = 2; page <= totalPages; page++) { - const res = await octokit.rest.search.issuesAndPullRequests({ - q: query, - sort: 'updated', - order: 'desc', - per_page: perPage, - page, - }); - items.push(...res.data.items); +): Promise<{ totalCount: number; items: MergedPRItem[] }> { + const items: MergedPRItem[] = []; + let after: string | null = null; + let totalCount = 0; + + for (let page = 0; page < 10; page++) { + const res = (await octokit.graphql(MERGED_PR_QUERY, { q: query, after })) as GraphQLSearchResponse; + if (page === 0) totalCount = res.search.issueCount; + + for (const n of res.search.nodes) { + items.push({ + number: n.number, + title: n.title, + url: n.url, + repo: n.repository.nameWithOwner, + mergedAt: n.mergedAt, + stargazerCount: n.repository.stargazerCount, + }); + } + + if (!res.search.pageInfo.hasNextPage || items.length >= maxItems) break; + after = res.search.pageInfo.endCursor; } return { totalCount, items }; @@ -127,10 +173,10 @@ export async function fetchContributionData(username: string, token: string): Pr const since = twelveMonthsAgo(); try { - // Run merged pagination first (may make multiple requests), then open/closed concurrently - const mergedResult = await paginateSearch(octokit, `is:pr author:${username} is:merged merged:>=${since}`); - - const [openRes, closedRes] = await Promise.all([ + // Merged PRs via GraphQL (uses 5000/hour GraphQL bucket, not 30/min REST search bucket). + // Open/closed-unmerged counts via REST (1 search call each, fits in budget). + const [mergedResult, openRes, closedRes] = await Promise.all([ + paginateMergedPRsGraphQL(octokit, `is:pr author:${username} is:merged merged:>=${since} sort:updated-desc`), octokit.rest.search.issuesAndPullRequests({ q: `is:pr author:${username} is:open`, sort: 'updated', @@ -152,34 +198,32 @@ export async function fetchContributionData(username: string, token: string): Pr const mergeRate = mergeTotal > 0 ? (merged / mergeTotal) * 100 : 0; const repoCounts = new Map(); + const repoStars: Record = {}; const dailyActivity: Record = {}; const recentPRs: RecentPR[] = []; for (const item of mergedResult.items) { - const repo = extractRepo(item.repository_url); + const repo = item.repo; repoCounts.set(repo, (repoCounts.get(repo) ?? 0) + 1); + repoStars[repo] = item.stargazerCount; - const mergedAt = item.pull_request?.merged_at ?? item.closed_at ?? ''; - if (mergedAt) { - const day = mergedAt.slice(0, 10); + if (item.mergedAt) { + const day = item.mergedAt.slice(0, 10); dailyActivity[day] = (dailyActivity[day] ?? 0) + 1; } - // Only include external repos in recentPRs (exclude user's own repos) const isOwnRepo = repo.split('/')[0].toLowerCase() === username.toLowerCase(); if (!isOwnRepo && recentPRs.length < 5) { recentPRs.push({ number: item.number, title: item.title, - url: item.html_url, + url: item.url, repo, - mergedAt, + mergedAt: item.mergedAt, }); } } - // Derive all external repos (exclude user's own repos), sorted by PR count. - // Not sliced here — renderers and transforms handle their own display limits. const topRepos = [...repoCounts.entries()] .filter(([repo]) => repo.split('/')[0].toLowerCase() !== username.toLowerCase()) .map(([repo, count]) => ({ repo, count })) @@ -197,6 +241,7 @@ export async function fetchContributionData(username: string, token: string): Pr dailyActivity, streak: computeStreak(dailyActivity), topRepos, + repoStars, }; } catch (err: unknown) { const status = err instanceof Object && 'status' in err ? (err as { status: number }).status : undefined; diff --git a/lib/svg-activity.test.ts b/lib/svg-activity.test.ts index 9602be0..f16e3d2 100644 --- a/lib/svg-activity.test.ts +++ b/lib/svg-activity.test.ts @@ -27,6 +27,7 @@ const sampleData: ContributionData = { }, streak: 3, topRepos: [], + repoStars: {}, }; const emptyData: ContributionData = { diff --git a/lib/svg-card.test.ts b/lib/svg-card.test.ts index 1bd0339..9424af0 100644 --- a/lib/svg-card.test.ts +++ b/lib/svg-card.test.ts @@ -14,6 +14,7 @@ const sampleData: ContributionData = { dailyActivity: { '2026-03-01': 2, '2026-03-08': 1, '2026-03-15': 3 }, streak: 3, topRepos: [], + repoStars: {}, }; describe('renderStatsCard', () => { diff --git a/lib/svg-recent.test.ts b/lib/svg-recent.test.ts index 8a2c48b..6e8e7ea 100644 --- a/lib/svg-recent.test.ts +++ b/lib/svg-recent.test.ts @@ -36,6 +36,7 @@ const sampleData: ContributionData = { dailyActivity: { '2026-03-20': 1, '2026-03-19': 1, '2026-03-18': 1 }, streak: 3, topRepos: [], + repoStars: {}, }; const emptyData: ContributionData = { diff --git a/lib/svg-top-repos.test.ts b/lib/svg-top-repos.test.ts index 4d4936b..9b9d7f5 100644 --- a/lib/svg-top-repos.test.ts +++ b/lib/svg-top-repos.test.ts @@ -20,6 +20,7 @@ const baseData: ContributionData = { { repo: 'Homebrew/brew', count: 6 }, { repo: 'sindresorhus/eslint-plugin-unicorn', count: 6 }, ], + repoStars: {}, }; describe('renderTopReposCard', () => {