Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/app/api/metrics/contributions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
19 changes: 16 additions & 3 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -20,7 +21,7 @@ interface PRMetricsBase {
}

async function fetchPRMetrics(token: string): Promise<PRMetricsBase> {
const searchRes = await fetch(
const searchRes = await githubFetch(
`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`,
{
headers: { Authorization: `Bearer ${token}` },
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
27 changes: 23 additions & 4 deletions src/app/api/metrics/repos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
19 changes: 16 additions & 3 deletions src/app/api/metrics/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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: {
Expand Down Expand Up @@ -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 });
}
}
Expand Down Expand Up @@ -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 });
}
}
10 changes: 10 additions & 0 deletions src/components/ContributionGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import RateLimitBanner from "@/components/RateLimitBanner";
import {
BarChart,
Bar,
Expand Down Expand Up @@ -42,16 +43,23 @@ export default function ContributionGraph() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [minutesAgo, setMinutesAgo] = useState(0);
const [error, setError] = useState<string | null>(null);
const [rateLimitResetAt, setRateLimitResetAt] = useState<number | null>(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();
})
Expand Down Expand Up @@ -135,6 +143,8 @@ export default function ContributionGraph() {

{loading ? (
<div className="h-[200px] rounded bg-[var(--card-muted)] animate-pulse" />
) : rateLimitResetAt ? (
<RateLimitBanner resetAt={rateLimitResetAt} />
) : error ? (
<div className="flex h-[200px] items-center rounded-lg border border-red-500/30 bg-red-500/10 px-4">
<p className="text-sm text-red-400">
Expand Down
10 changes: 10 additions & 0 deletions src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import RateLimitBanner from "@/components/RateLimitBanner";

interface PRData {
open: number;
Expand All @@ -15,10 +16,12 @@ export default function PRMetrics() {
const [metrics, setMetrics] = useState<PRData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rateLimitResetAt, setRateLimitResetAt] = useState<number | null>(null);

const fetchMetrics = useCallback(() => {
setLoading(true);
setError(null);
setRateLimitResetAt(null);

const url =
selectedAccount !== null
Expand All @@ -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();
})
Expand Down Expand Up @@ -60,6 +68,8 @@ export default function PRMetrics() {
/>
))}
</div>
) : rateLimitResetAt ? (
<RateLimitBanner resetAt={rateLimitResetAt} />
) : error ? (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-400">
<p>{error}</p>
Expand Down
50 changes: 50 additions & 0 deletions src/components/RateLimitBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border border-amber-500/25 bg-amber-50 p-4 text-sm text-amber-700">
<p className="font-medium">GitHub API rate limit reached.</p>
<p className="mt-1 text-amber-700">
Try again in <span className="font-semibold">{formatRemaining(remaining)}</span>
{" "}(resets at {resetDate.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}).
</p>
</div>
);
}
19 changes: 19 additions & 0 deletions src/components/StreakTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useCallback, useEffect, useState } from "react";
import { useAccount } from "@/components/AccountContext";
import RateLimitBanner from "@/components/RateLimitBanner";

interface StreakData {
current: number;
Expand Down Expand Up @@ -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<number | null>(null);

const fetchStreak = useCallback(async () => {
setLoading(true);
setError(null);
setRateLimitResetAt(null);

try {
const streakUrl =
Expand All @@ -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");
}
Expand Down Expand Up @@ -141,6 +151,15 @@ export default function StreakTracker() {
);
}

if (rateLimitResetAt) {
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-[var(--card-foreground)]">Commit Streaks</h2>
<RateLimitBanner resetAt={rateLimitResetAt} />
</div>
);
}

if (error) {
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
Expand Down
Loading