diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 17a294e6..5202b7c1 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; export const dynamic = "force-dynamic"; @@ -37,7 +38,7 @@ async function fetchContributionsForAccount( since.setDate(since.getDate() - days); const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, { headers: { @@ -83,7 +84,13 @@ export async function GET(req: NextRequest) { days ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -168,7 +175,13 @@ export async function GET(req: NextRequest) { days ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2b..507e216b 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; export const dynamic = "force-dynamic"; @@ -20,7 +21,7 @@ interface PRMetricsBase { } async function fetchPRMetrics(token: string): Promise { - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, { headers: { Authorization: `Bearer ${token}` }, @@ -86,7 +87,13 @@ export async function GET(req: NextRequest) { try { const result = await fetchPRMetrics(session.accessToken); return Response.json(formatPRMetrics(result)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -156,7 +163,13 @@ export async function GET(req: NextRequest) { try { const result = await fetchPRMetrics(token); return Response.json(formatPRMetrics(result)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index 23f6c06a..f598218e 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; export const dynamic = "force-dynamic"; @@ -43,7 +44,7 @@ async function fetchReposForAccount( since.setDate(since.getDate() - days); const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, { headers: { @@ -96,7 +97,13 @@ export async function GET(req: NextRequest) { days ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -151,7 +158,13 @@ export async function GET(req: NextRequest) { days ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -180,7 +193,13 @@ export async function GET(req: NextRequest) { days ); return Response.json(result); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index ae35df78..edbc363e 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; +import { githubFetch, RateLimitError } from "@/lib/githubFetch"; export const dynamic = "force-dynamic"; @@ -25,7 +26,7 @@ async function fetchActiveDates( since.setDate(since.getDate() - 90); const sinceStr = since.toISOString().slice(0, 10); - const searchRes = await fetch( + const searchRes = await githubFetch( `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=desc`, { headers: { @@ -177,7 +178,13 @@ export async function GET(req: NextRequest) { return Response.json( calculateStreakFromDates(activeDates, freezeDates) ); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } @@ -240,7 +247,13 @@ export async function GET(req: NextRequest) { try { const activeDates = await fetchActiveDates(resolvedLogin, resolvedToken); return Response.json(calculateStreakFromDates(activeDates, freezeDates)); - } catch { + } catch (err) { + if (err instanceof RateLimitError) { + return Response.json( + { error: "rate_limited", resetAt: err.resetAt }, + { status: 429 } + ); + } return Response.json({ error: "GitHub API error" }, { status: 502 }); } } diff --git a/src/components/ContributionGraph.tsx b/src/components/ContributionGraph.tsx index b86cbde9..2f25fea5 100644 --- a/src/components/ContributionGraph.tsx +++ b/src/components/ContributionGraph.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; import { BarChart, Bar, @@ -42,16 +43,23 @@ export default function ContributionGraph() { const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [error, setError] = useState(null); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); useEffect(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const accountParam = selectedAccount !== null ? `&accountId=${encodeURIComponent(selectedAccount)}` : ""; fetch(`/api/metrics/contributions?days=${days}${accountParam}`) .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } if (!r.ok) throw new Error("API error"); return r.json(); }) @@ -135,6 +143,8 @@ export default function ContributionGraph() { {loading ? (
+ ) : rateLimitResetAt ? ( + ) : error ? (

diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 990d13f5..6c8e7d19 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; interface PRData { open: number; @@ -15,10 +16,12 @@ export default function PRMetrics() { const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const fetchMetrics = useCallback(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const url = selectedAccount !== null @@ -27,6 +30,11 @@ export default function PRMetrics() { fetch(url) .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } if (!r.ok) throw new Error("API error"); return r.json(); }) @@ -60,6 +68,8 @@ export default function PRMetrics() { /> ))}

+ ) : rateLimitResetAt ? ( + ) : error ? (

{error}

diff --git a/src/components/RateLimitBanner.tsx b/src/components/RateLimitBanner.tsx new file mode 100644 index 00000000..695f5455 --- /dev/null +++ b/src/components/RateLimitBanner.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +interface RateLimitBannerProps { + resetAt: number; +} + +function toResetDate(resetAt: number): Date { + // GitHub reset timestamps are usually epoch seconds; support ms as well. + const ms = resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt; + return new Date(ms); +} + +function formatRemaining(ms: number): string { + if (ms <= 0) return "a few seconds"; + + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes <= 0) return `${seconds}s`; + if (minutes < 60) return `${minutes}m ${seconds}s`; + + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return `${hours}h ${remMinutes}m`; +} + +export default function RateLimitBanner({ resetAt }: RateLimitBannerProps) { + const resetDate = useMemo(() => toResetDate(resetAt), [resetAt]); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + const remaining = resetDate.getTime() - now; + + return ( +
+

GitHub API rate limit reached.

+

+ Try again in {formatRemaining(remaining)} + {" "}(resets at {resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). +

+
+ ); +} diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index f9ca50fc..bd80db2a 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; interface StreakData { current: number; @@ -34,10 +35,12 @@ export default function StreakTracker() { const [freezeLoading, setFreezeLoading] = useState(true); const [cancelling, setCancelling] = useState(false); const [confirmCancel, setConfirmCancel] = useState(false); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const fetchStreak = useCallback(async () => { setLoading(true); setError(null); + setRateLimitResetAt(null); try { const streakUrl = @@ -53,6 +56,13 @@ export default function StreakTracker() { fetch(contributionUrl), ]); + if (streakRes.status === 429 || contributionRes.status === 429) { + const rateLimitRes = streakRes.status === 429 ? streakRes : contributionRes; + const d = await rateLimitRes.json() as { error: string; resetAt: number }; + setRateLimitResetAt(d.resetAt); + return; + } + if (!streakRes.ok || !contributionRes.ok) { throw new Error("Failed to fetch data"); } @@ -141,6 +151,15 @@ export default function StreakTracker() { ); } + if (rateLimitResetAt) { + return ( +
+

Commit Streaks

+ +
+ ); + } + if (error) { return (
diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index fdd492a4..d26fa44c 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import RateLimitBanner from "@/components/RateLimitBanner"; import type { RepoHealthScore } from "@/types/repo-health"; interface Repo { @@ -20,16 +21,29 @@ export default function TopRepos() { const [minutesAgo, setMinutesAgo] = useState(0); const [healthScores, setHealthScores] = useState>({}); const [healthLoading, setHealthLoading] = useState(true); + const [rateLimitResetAt, setRateLimitResetAt] = useState(null); const fetchRepos = useCallback(() => { setLoading(true); setError(null); + setRateLimitResetAt(null); const accountParam = selectedAccount !== null ? `&accountId=${encodeURIComponent(selectedAccount)}` : ""; fetch(`/api/metrics/repos?days=${days}${accountParam}`) - .then((r) => r.json()) - .then((d: { repos: Repo[] }) => setRepos(d.repos ?? [])) + .then((r) => { + if (r.status === 429) { + return r.json().then((d: { error: string; resetAt: number }) => { + setRateLimitResetAt(d.resetAt); + }); + } + return r.json(); + }) + .then((d: { repos: Repo[] } | undefined) => { + if (d && 'repos' in d) { + setRepos(d.repos ?? []); + } + }) .catch(() => setError("We couldn't load your top repositories right now. Please try again in a moment.")) .finally(() => { setLoading(false); @@ -94,6 +108,8 @@ export default function TopRepos() {
))}
+ ) : rateLimitResetAt ? ( + ) : error ? (

{error}

diff --git a/src/lib/githubFetch.ts b/src/lib/githubFetch.ts new file mode 100644 index 00000000..442eeadc --- /dev/null +++ b/src/lib/githubFetch.ts @@ -0,0 +1,25 @@ +export class RateLimitError extends Error { + resetAt: number; + + constructor(resetAt: number) { + super("GitHub API rate limited"); + this.resetAt = resetAt; + this.name = "RateLimitError"; + } +} + +export async function githubFetch( + url: string, + options?: RequestInit +): Promise { + const response = await fetch(url, options); + + if (response.status === 429) { + const data = (await response.json()) as { message?: string }; + const resetAtHeader = response.headers.get("X-RateLimit-Reset"); + const resetAt = resetAtHeader ? parseInt(resetAtHeader) * 1000 : Date.now() + 3600000; + throw new RateLimitError(resetAt); + } + + return response; +}