diff --git a/src/app/api/metrics/repo-explorer/route.ts b/src/app/api/metrics/repo-explorer/route.ts index 7d5810158..63e265fae 100644 --- a/src/app/api/metrics/repo-explorer/route.ts +++ b/src/app/api/metrics/repo-explorer/route.ts @@ -1,5 +1,5 @@ import { getSessionWithToken } from "@/lib/get-session-token"; -import { fetchUserRepos } from "@/lib/github"; +import { fetchUserReposPaginated } from "@/lib/github"; import { NextRequest } from "next/server"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import { ExplorerRepoCardData } from "@/lib/repo-analytics-types"; @@ -16,14 +16,15 @@ export async function GET(req: NextRequest) { const session = sessionData.session; const accessToken = sessionData.accessToken; + const page = parseInt(req.nextUrl.searchParams.get("page") ?? "1", 10); + const perPage = parseInt(req.nextUrl.searchParams.get("per_page") ?? "20", 10); + const bypass = isMetricsCacheBypassed(req); - const key = metricsCacheKey(session.githubId ?? session.githubLogin!, "repo-explorer-v2" as any, { days: 7 }); + const key = metricsCacheKey(session.githubId ?? session.githubLogin!, `repo-explorer-v2-p${page}-pp${perPage}` as any, { days: 7 }); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 30 * 60 }, async () => { - // Paginate through all pages (up to 1000 repos) so users with more - // than 100 repositories see their complete list — fixes #2843. - const repos = await fetchUserRepos(accessToken, { perPage: 100, maxPages: 10 }); + const { repos, hasNextPage } = await fetchUserReposPaginated(accessToken, page, perPage); const since = new Date(); since.setDate(since.getDate() - 30); const sinceStr = since.toISOString().slice(0, 10); @@ -88,7 +89,7 @@ export async function GET(req: NextRequest) { result.sort((a, b) => b.commitCount - a.commitCount || new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - return { repos: result }; + return { repos: result, hasNextPage }; }); return Response.json(data); } catch (error) { diff --git a/src/lib/github.ts b/src/lib/github.ts index bf55d8bfb..4bb0ce823 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -93,6 +93,48 @@ export async function fetchUserRepos( return repos; } +export interface PaginatedReposResult { + repos: GitHubRepo[]; + hasNextPage: boolean; +} + +/** + * Fetches a single page of repositories for the authenticated user. + * @param token - The user's GitHub personal access token. + * @param page - The page number to fetch. + * @param perPage - The number of items per page. + * @returns A promise that resolves to the paginated result. + */ +export async function fetchUserReposPaginated( + token: string, + page: number = 1, + perPage: number = 20 +): Promise { + const res = await githubFetch( + `${GITHUB_API}/user/repos?visibility=all&sort=pushed&direction=desc&per_page=${perPage}&page=${page}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + } + ); + + if (!res.ok) { + if (res.status === 401) throw new GitHubAuthError(); + throwIfGitHubRateLimited(res); + throw new Error(`GitHub API error: ${res.status}`); + } + + const repos = (await res.json()) as GitHubRepo[]; + + // Check if there's a next page by looking at the Link header + const linkHeader = res.headers.get("Link") || ""; + const hasNextPage = linkHeader.includes('rel="next"'); + + return { repos, hasNextPage }; +} + export interface GitHubEvent { id: string; type: string; diff --git a/test/github.test.ts b/test/github.test.ts index a51ea10db..91208f4fe 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -6,6 +6,7 @@ import type { CommitItem, GitHubIssueItem, IssuesMetrics, + PaginatedReposResult, } from "../src/lib/github"; describe("github types and interfaces", () => { @@ -141,4 +142,15 @@ describe("github types and interfaces", () => { expect(metrics.mostActiveRepo).toBeNull(); }); }); + + describe("PaginatedReposResult", () => { + it("accepts valid paginated result structure", () => { + const result: PaginatedReposResult = { + repos: [], + hasNextPage: true, + }; + expect(result.hasNextPage).toBe(true); + expect(Array.isArray(result.repos)).toBe(true); + }); + }); });