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
32 changes: 25 additions & 7 deletions src/app/api/metrics/issues/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
metricsCacheKey,
withMetricsCache,
} from "@/lib/metrics-cache";
import { getAccountToken } from "@/lib/github-accounts";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

Expand All @@ -17,19 +19,35 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

// 1. Check if the user is forcing a refresh
const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);

// 2. Generate a unique cache key for this user's issues
const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues");

let token = session.accessToken;
let userId = session.githubId ?? session.githubLogin;

if (accountId && accountId !== session.githubId) {
if (!session.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRow = await resolveAppUser(session.githubId, session.githubLogin);
if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const accountToken = await getAccountToken(userRow.id, accountId);
if (!accountToken) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
token = accountToken;
userId = accountId;
}

const key = metricsCacheKey(userId, "issues");

try {
// 3. Wrap the GitHub fetch in our bulletproof cache!
const metrics = await withMetricsCache(
{ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues },
() => fetchIssuesMetrics(session.accessToken!)
() => fetchIssuesMetrics(token!)
);

return Response.json(metrics);
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
Expand Down
49 changes: 40 additions & 9 deletions src/app/api/metrics/languages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache, METRICS_CACHE_TTL_SECONDS} from "@/lib/metrics-cache";
import { getAccountToken } from "@/lib/github-accounts";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";
const GITHUB_API = "https://api.github.com";
Expand All @@ -10,25 +13,53 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 });

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);

const accountId = req.nextUrl.searchParams.get("accountId");
let token = session.accessToken;
let githubLogin = session.githubLogin;
let userId = session.githubId ?? session.githubLogin;

if (accountId && accountId !== session.githubId) {
if (!session.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRow = await resolveAppUser(session.githubId, session.githubLogin);
if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const accountToken = await getAccountToken(userRow.id, accountId);
if (!accountToken) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
const { data: accountRow } = await supabaseAdmin
.from("user_github_accounts")
.select("github_login")
.eq("user_id", userRow.id)
.eq("github_id", accountId)
.single();
if (!accountRow?.github_login) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
token = accountToken;
githubLogin = accountRow.github_login;
userId = accountId;
}

const key = metricsCacheKey(
session.githubId ?? session.githubLogin,
userId,
"languages" as any,
{
accountId: accountId || undefined,
}
{ accountId: accountId || undefined }
);

try {
const data = await withMetricsCache({ bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.languages }, async () => {
const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" };
const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
const since = new Date();
since.setDate(since.getDate() - 90);

const searchRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`,
`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`,
{ headers, cache: "no-store" }
);
if (!searchRes.ok) throw new Error("API Error");
Expand All @@ -41,7 +72,7 @@ export async function GET(req: NextRequest) {
repoNames.map(async (repoName) => {
try {
const repoCacheKey = metricsCacheKey(
session.githubId || session.githubLogin || "unknown",
userId,
"repo_languages" as any,
{ repoName }
);
Expand Down Expand Up @@ -82,4 +113,4 @@ export async function GET(req: NextRequest) {
} catch {
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
}
27 changes: 25 additions & 2 deletions src/app/api/metrics/pr-breakdown/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
import { getAccountToken } from "@/lib/github-accounts";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";
const GITHUB_API = "https://api.github.com";
Expand All @@ -12,13 +14,34 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 });

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any);

let token = session.accessToken;
let userId = session.githubId ?? "unknown";

if (accountId && accountId !== session.githubId) {
if (!session.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRow = await resolveAppUser(session.githubId, session.githubLogin);
if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const accountToken = await getAccountToken(userRow.id, accountId);
if (!accountToken) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
token = accountToken;
userId = accountId;
}

const key = metricsCacheKey(userId, "pr-breakdown" as any);

try {
const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => {
const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, {
headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" },
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" },
cache: "no-store",
});
if (!res.ok) throw new Error("API Error");
Expand Down
45 changes: 40 additions & 5 deletions src/app/api/metrics/weekly-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { authOptions } from "@/lib/auth";
import { GITHUB_API } from "@/lib/github";
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
import { getAccountToken } from "@/lib/github-accounts";
import { supabaseAdmin } from "@/lib/supabase";
import { resolveAppUser } from "@/lib/resolve-user";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -108,8 +111,40 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const accountId = req.nextUrl.searchParams.get("accountId");
const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(session.githubId ?? session.githubLogin, "weekly-summary" as any);

let token = session.accessToken;
let githubLogin = session.githubLogin;
let userId = session.githubId ?? session.githubLogin;

if (accountId && accountId !== session.githubId) {
if (!session.githubId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRow = await resolveAppUser(session.githubId, session.githubLogin);
if (!userRow) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const accountToken = await getAccountToken(userRow.id, accountId);
if (!accountToken) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
const { data: accountRow } = await supabaseAdmin
.from("user_github_accounts")
.select("github_login")
.eq("user_id", userRow.id)
.eq("github_id", accountId)
.single();
if (!accountRow?.github_login) {
return Response.json({ error: "Account not found" }, { status: 404 });
}
token = accountToken;
githubLogin = accountRow.github_login;
userId = accountId;
}

const key = metricsCacheKey(userId, "weekly-summary" as any);

try {
// Cache TTL of 5 minutes (300 seconds).
Expand All @@ -129,11 +164,11 @@ export async function GET(req: NextRequest) {
// per_page=100 covers most users in a single request; heavy committers
// (>100 commits in 14 days) will see a capped but still representative count.
const commitsRes = await fetch(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`,
`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${fourteenDaysAgoStr}&per_page=100`,
{
headers: {
// OAuth token / PAT: required for the authenticated 30 req/min tier.
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${token}`,
// Mandatory Accept header for the Commit Search endpoint.
Accept: "application/vnd.github+json",
},
Expand Down Expand Up @@ -191,7 +226,7 @@ export async function GET(req: NextRequest) {
`${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
Expand Down Expand Up @@ -238,7 +273,7 @@ export async function GET(req: NextRequest) {
// requests to build the full 90-day commit date set for streak calculation.
// This is the most expensive part of this handler in terms of API quota usage.
// The 5-minute cache TTL above ensures these calls only happen on cache misses.
const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!);
const streakDates = await fetchActiveDates(githubLogin!, token!);
const commitDelta = commitsThisWeek - commitsPrevWeek;

return {
Expand Down
16 changes: 11 additions & 5 deletions src/components/IssueMetrics.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

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

interface IssueData {
opened: number;
Expand All @@ -12,15 +13,20 @@ interface IssueData {
}

export default function IssueMetrics() {
const { selectedAccount } = useAccount();
const [metrics, setMetrics] = useState<IssueData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

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

fetch("/api/metrics/issues")
const url = selectedAccount !== null
? `/api/metrics/issues?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/issues";

fetch(url)
.then((r) => r.json())
.then((data: IssueData) => setMetrics(data))
.catch(() =>
Expand All @@ -29,11 +35,11 @@ export default function IssueMetrics() {
)
)
.finally(() => setLoading(false));
};
}, [selectedAccount]);

useEffect(() => {
fetchMetrics();
}, []);
}, [fetchMetrics]);

const stats = metrics
? [
Expand Down
9 changes: 7 additions & 2 deletions src/components/LanguageBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

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

interface Language {
name: string;
Expand Down Expand Up @@ -28,17 +29,21 @@ function getColor(name: string): string {
}

export default function LanguageBreakdown() {
const { selectedAccount } = useAccount();
const [languages, setLanguages] = useState<Language[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
setLoading(true);
fetch("/api/metrics/languages")
const url = selectedAccount !== null
? `/api/metrics/languages?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/languages";
fetch(url)
.then((r) => r.json())
.then((d: { languages: Language[] }) => setLanguages(d.languages ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
}, [selectedAccount]);

const totalPercentage = languages.reduce((sum, lang) => sum + lang.percentage, 0);
const roundedTotal = Math.round(totalPercentage * 10) / 10;
Expand Down
16 changes: 11 additions & 5 deletions src/components/PRBreakdownChart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
import { useAccount } from "@/components/AccountContext";

interface PRBreakdown {
draft: number;
Expand All @@ -18,6 +19,7 @@ const SLICES: { key: keyof PRBreakdown; label: string; color: string }[] = [
];

export default function PRBreakdownChart() {
const { selectedAccount } = useAccount();
const [breakdown, setBreakdown] = useState<PRBreakdown | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand All @@ -29,22 +31,26 @@ export default function PRBreakdownChart() {
.trim();
};

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

fetch("/api/metrics/pr-breakdown")
const url = selectedAccount !== null
? `/api/metrics/pr-breakdown?accountId=${encodeURIComponent(selectedAccount)}`
: "/api/metrics/pr-breakdown";

fetch(url)
.then((r) => r.json())
.then((d: PRBreakdown) => setBreakdown(d))
.catch(() =>
setError("We couldn't load your PR breakdown right now. Please try again in a moment.")
)
.finally(() => setLoading(false));
};
}, [selectedAccount]);

useEffect(() => {
fetchBreakdown();
}, []);
}, [fetchBreakdown]);

if (loading) {
return (
Expand Down
Loading
Loading