diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 3a3f885e..ce3f07c2 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -77,30 +77,64 @@ async function fetchContributionsForAccount( since.setDate(since.getDate() - days); const sinceStr = toLocalDateStr(since); - const searchRes = await fetch( - `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - }, - cache: "no-store", + let allItems: Array<{ commit: { author: { date: string } } }> = []; + let totalCount = 0; + let page = 1; + + // Note: this may issue up to 10 sequential GitHub Search API calls (max 1000 results). + // Authenticated GitHub Search rate limits are low (~30 req/min). We handle 429/403 + // responses gracefully by returning partial results rather than failing the endpoint. + while (page <= 10) { + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + if (!searchRes.ok) { + // If we're being rate limited or hit a secondary rate limit/permission error, + // return partial results collected so far instead of failing the whole request. + if (searchRes.status === 429 || searchRes.status === 403) { + if (allItems.length === 0) { + // If no items were retrieved at all, surface the error so callers know + // the request could not be fulfilled. + throw new Error(`GitHub API error: ${searchRes.status}`); + } + break; + } + + throw new Error("GitHub API error"); } - ); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } + const data = (await searchRes.json()) as { + total_count: number; + items: Array<{ commit: { author: { date: string } } }>; + }; - const searchData = (await searchRes.json()) as { - total_count: number; - items: GitHubCommitSearchItem[]; - }; + if (page === 1) { + totalCount = data.total_count; + } - const commitsByDay: Record = {}; - const commitItems: CommitItem[] = []; + allItems = allItems.concat(data.items); + + if (data.items.length < 100) { + break; + } + + if (allItems.length >= 1000 || allItems.length >= totalCount) { + break; + } - for (const item of searchData.items) { + page += 1; + } + + const commitsByDay: Record = {}; + for (const item of allItems) { const date = item.commit.author.date.slice(0, 10); commitsByDay[date] = (commitsByDay[date] ?? 0) + 1; commitItems.push({ @@ -112,12 +146,7 @@ async function fetchContributionsForAccount( }); } - return { - days, - total: searchData.total_count, - data: commitsByDay, - commits: commitItems, - }; + return { days, total: totalCount, data: commitsByDay }; } ); }