Skip to content
Merged
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
87 changes: 59 additions & 28 deletions src/app/api/metrics/contributions/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
githubRateLimitResponse,
throwIfGitHubRateLimited,
} from "@/lib/github-rate-limit";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
Expand Down Expand Up @@ -96,6 +100,16 @@ function sumContributionDays(data: Record<string, number>): number {
return Object.values(data).reduce((total, count) => total + count, 0);
}

function githubApiErrorResponse(error: unknown): Response {
const rateLimitResponse = githubRateLimitResponse(error);

if (rateLimitResponse) {
return rateLimitResponse;
}

return Response.json({ error: "GitHub API error" }, { status: 502 });
}

async function fetchContributionsForAccount(
token: string,
githubLogin: string,
Expand Down Expand Up @@ -164,19 +178,18 @@ async function fetchContributionsForAccount(
);

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;
}
throwIfGitHubRateLimited(searchRes);

throw new Error("GitHub API error");
}
if (searchRes.status === 429 || searchRes.status === 403) {
if (allItems.length === 0) {
throw new Error(`GitHub API error: ${searchRes.status}`);
}

break;
}

throw new Error(`GitHub API error: ${searchRes.status}`);
}

const data = (await searchRes.json()) as {
total_count: number;
Expand Down Expand Up @@ -387,9 +400,9 @@ export async function GET(req: NextRequest) {
repoParam
);
return Response.json(result);
} catch (e) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
} catch (error) {
return githubApiErrorResponse(error);
}
}

if (!accountId) {
Expand All @@ -414,8 +427,8 @@ export async function GET(req: NextRequest) {
});

return Response.json(merged);
} catch (e) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
} catch (error) {
return githubApiErrorResponse(error);
}
}

Expand All @@ -440,14 +453,32 @@ export async function GET(req: NextRequest) {
);

const results = await Promise.allSettled(
accounts.map((account) =>
fetchContributionsForAccount(account.token, account.githubLogin, days, {
bypass,
userId: account.githubId,
accounts.map((account) =>
fetchContributionsForAccount(
account.token,
account.githubLogin,
days,
{
bypass,
userId: account.githubId,
},
timezone,
fromDate,
repoParam
)
)
);

}, timezone, fromDate, repoParam)
)
);

const rateLimitedResult = results.find(
(result): result is PromiseRejectedResult =>
result.status === "rejected" &&
githubRateLimitResponse(result.reason) !== null
);

if (rateLimitedResult) {
return githubApiErrorResponse(rateLimitedResult.reason);
}

const merged = mergeMetrics(results, (a, b) => ({
days: a.days,
Expand Down Expand Up @@ -501,8 +532,8 @@ export async function GET(req: NextRequest) {
});

return Response.json(merged);
} catch (e) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
} catch (error) {
return githubApiErrorResponse(error);
}
}

Expand Down Expand Up @@ -533,7 +564,7 @@ export async function GET(req: NextRequest) {
fromDate
);
return Response.json(result);
} catch (e) {
return Response.json({ error: "GitHub API error" }, { status: 502 });
} catch (error) {
return githubApiErrorResponse(error);
}
}
35 changes: 34 additions & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,39 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {

useEffect(() => {
const originalFetch = window.fetch;

window.fetch = async (...args) => {
const response = await originalFetch(...args);

if (response.status === 429) {
const clonedResponse = response.clone();

try {
const data = await clonedResponse.json();

if (data?.error === "GITHUB_RATE_LIMITED") {
const resetAt = data.rateLimit?.resetAt
? new Date(data.rateLimit.resetAt).toLocaleString(undefined, {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
})
: null;

toast.error("GitHub API rate limit reached", {
description: resetAt
? `Data will refresh at ${resetAt}.`
: "Please try again later.",
});
}
} catch {
// Ignore non-JSON 429 responses.
}

return response;
}

if (response.status === 401) {
const cloned = response.clone();
const sessionStillActive = await hasActiveSession(originalFetch);
Expand All @@ -43,8 +74,10 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {

return cloned;
}

return response;
};

return () => {
window.fetch = originalFetch;
};
Expand All @@ -53,4 +86,4 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
if (status === "loading") return null;

return <>{children}</>;
}
}
74 changes: 74 additions & 0 deletions src/lib/github-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export interface GitHubRateLimitDetails {
code: "GITHUB_RATE_LIMITED";
message: string;
resetAt: string | null;
resetAtEpoch: number | null;
}

export class GitHubRateLimitError extends Error {
details: GitHubRateLimitDetails;

constructor(details: GitHubRateLimitDetails) {
super(details.message);
this.name = "GitHubRateLimitError";
this.details = details;
}
}

export function getGitHubRateLimitDetails(
response: Pick<Response, "status" | "headers">
): GitHubRateLimitDetails | null {
const remaining = response.headers.get("x-ratelimit-remaining");
const resetHeader = response.headers.get("x-ratelimit-reset");

const isRateLimitedStatus = response.status === 403 || response.status === 429;
const isQuotaExhausted = remaining === "0";

if (!isRateLimitedStatus || !isQuotaExhausted) {
return null;
}

const parsedResetEpoch = resetHeader ? Number(resetHeader) : Number.NaN;
const resetAtEpoch = Number.isFinite(parsedResetEpoch)
? parsedResetEpoch
: null;

const resetAt = resetAtEpoch
? new Date(resetAtEpoch * 1000).toISOString()
: null;

return {
code: "GITHUB_RATE_LIMITED",
message: resetAt
? `GitHub API rate limit reached. Data will refresh at ${resetAt}.`
: "GitHub API rate limit reached. Please try again later.",
resetAt,
resetAtEpoch,
};
}

export function throwIfGitHubRateLimited(response: Response): void {
const details = getGitHubRateLimitDetails(response);

if (details) {
throw new GitHubRateLimitError(details);
}
}

export function githubRateLimitResponse(error: unknown): Response | null {
if (!(error instanceof GitHubRateLimitError)) {
return null;
}

return Response.json(
{
error: error.details.code,
message: error.details.message,
rateLimit: {
resetAt: error.details.resetAt,
resetAtEpoch: error.details.resetAtEpoch,
},
},
{ status: 429 }
);
}
57 changes: 47 additions & 10 deletions src/lib/github.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { throwIfGitHubRateLimited } from "@/lib/github-rate-limit";
export const GITHUB_API = "https://api.github.com";

/**
Expand Down Expand Up @@ -26,7 +27,10 @@ export async function fetchUserEvents(token: string): Promise<GitHubEvent[]> {
Accept: "application/vnd.github+json",
},
});
if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
if (!res.ok) {
throwIfGitHubRateLimited(res);
throw new Error(`GitHub API error: ${res.status}`);
}
return res.json();
}

Expand Down Expand Up @@ -54,7 +58,10 @@ export async function fetchUserRepos(
}
);

if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
if (!res.ok) {
throwIfGitHubRateLimited(res);
throw new Error(`GitHub API error: ${res.status}`);
}

const pageRepos = (await res.json()) as GitHubRepo[];
repos.push(...pageRepos);
Expand Down Expand Up @@ -137,10 +144,16 @@ export async function fetchIssuesMetrics(
const since30d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);

const searchRes = await githubFetch(
`https://api.github.com/search/issues?q=type:issue+author:@me+created:>=${since30d.toISOString().slice(0, 10)}&per_page=100`,
`${GITHUB_API}/search/issues?q=type:issue+author:@me+created:>=${since30d
.toISOString()
.slice(0, 10)}&per_page=100`,
{ headers, cache: "no-store" }
);
if (!searchRes.ok) throw new Error(`GitHub API error: ${searchRes.status}`);

if (!searchRes.ok) {
throwIfGitHubRateLimited(searchRes);
throw new Error(`GitHub API error: ${searchRes.status}`);
}

const searchData = (await searchRes.json()) as { items: GitHubIssueItem[] };
const items = searchData.items;
Expand All @@ -154,30 +167,54 @@ export async function fetchIssuesMetrics(
closedItems.length > 0
? Math.round(
closedItems.reduce((sum, i) => {
return sum + (new Date(i.closed_at!).getTime() - new Date(i.created_at).getTime());
return (
sum +
(new Date(i.closed_at!).getTime() -
new Date(i.created_at).getTime())
);
}, 0) /
closedItems.length /
86400000
)
: 0;

const thisMonthRes = await githubFetch(
`https://api.github.com/search/issues?q=type:issue+author:@me+created:>=${thisMonthStart.toISOString().slice(0, 10)}&per_page=1`,
`${GITHUB_API}/search/issues?q=type:issue+author:@me+created:>=${thisMonthStart
.toISOString()
.slice(0, 10)}&per_page=1`,
{ headers, cache: "no-store" }
);

const lastMonthRes = await githubFetch(
`https://api.github.com/search/issues?q=type:issue+author:@me+created:${lastMonthStart.toISOString().slice(0, 10)}..${lastMonthEnd.toISOString().slice(0, 10)}&per_page=1`,
`${GITHUB_API}/search/issues?q=type:issue+author:@me+created:${lastMonthStart
.toISOString()
.slice(0, 10)}..${lastMonthEnd.toISOString().slice(0, 10)}&per_page=1`,
{ headers, cache: "no-store" }
);

const thisMonthCount = thisMonthRes.ok ? ((await thisMonthRes.json()) as { total_count: number }).total_count : 0;
const lastMonthCount = lastMonthRes.ok ? ((await lastMonthRes.json()) as { total_count: number }).total_count : 0;
if (!thisMonthRes.ok) {
throwIfGitHubRateLimited(thisMonthRes);
}

if (!lastMonthRes.ok) {
throwIfGitHubRateLimited(lastMonthRes);
}

const thisMonthCount = thisMonthRes.ok
? ((await thisMonthRes.json()) as { total_count: number }).total_count
: 0;

const lastMonthCount = lastMonthRes.ok
? ((await lastMonthRes.json()) as { total_count: number }).total_count
: 0;

const repoCounts: Record<string, number> = {};

for (const item of items) {
const repo = item.repository_url.split("/").pop() ?? "";
repoCounts[repo] = (repoCounts[repo] ?? 0) + 1;
}

const mostActiveRepo =
Object.keys(repoCounts).length > 0
? Object.entries(repoCounts).sort((a, b) => b[1] - a[1])[0][0]
Expand All @@ -191,4 +228,4 @@ export async function fetchIssuesMetrics(
trend: thisMonthCount - lastMonthCount,
mostActiveRepo,
};
}
}