diff --git a/src/lib/github-fetch.ts b/src/lib/github-fetch.ts new file mode 100644 index 00000000..fa0dadd4 --- /dev/null +++ b/src/lib/github-fetch.ts @@ -0,0 +1,87 @@ +/** + * Typed GitHub API fetch helper. + * Centralises Authorization headers, Accept header, ok-check, + * and 403/429 rate-limit error handling so metric routes don't + * repeat the same ~10-line pattern. + */ + +import { GITHUB_API } from "@/lib/github"; + +export { GITHUB_API }; + +export class GitHubRateLimitError extends Error { + constructor(public resetAt: Date | null) { + super("GitHub API rate limit exceeded"); + this.name = "GitHubRateLimitError"; + } +} + +export class GitHubApiError extends Error { + constructor(public status: number) { + super(`GitHub API error: ${status}`); + this.name = "GitHubApiError"; + } +} + +/** + * Fetch a GitHub API endpoint with standard headers. + * Throws GitHubRateLimitError on 403/429, GitHubApiError on other non-ok responses. + */ +export async function githubFetch( + url: string, + token: string, + options: RequestInit = {} +): Promise { + const res = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + ...((options.headers as Record) ?? {}), + }, + cache: (options.cache as RequestCache) ?? "no-store", + }); + + if (res.status === 403 || res.status === 429) { + const resetHeader = res.headers.get("X-RateLimit-Reset"); + const resetAt = resetHeader ? new Date(Number(resetHeader) * 1000) : null; + throw new GitHubRateLimitError(resetAt); + } + + if (!res.ok) { + throw new GitHubApiError(res.status); + } + + return res.json() as Promise; +} + +/** + * POST to GitHub GraphQL API. + */ +export async function githubGraphQL( + query: string, + token: string +): Promise { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + cache: "no-store", + }); + + if (res.status === 403 || res.status === 429) { + const resetHeader = res.headers.get("X-RateLimit-Reset"); + const resetAt = resetHeader ? new Date(Number(resetHeader) * 1000) : null; + throw new GitHubRateLimitError(resetAt); + } + + if (!res.ok) { + throw new GitHubApiError(res.status); + } + + const json = await res.json(); + return json.data as T; +} \ No newline at end of file