diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 5c9f7554..66828960 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -17,7 +17,12 @@ import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; - +interface ReviewMetrics { + totalReviews: number; + approvalRate: string; + avgFirstReviewHours: number | null; + topRepos: { repo: string; count: number }[]; +} interface PRMetricsBase { open: number; merged: number; @@ -233,7 +238,77 @@ function formatPRMetrics(metrics: PRMetricsBase) { : "0%", }; } +async function fetchReviewMetrics(token: string): Promise { + const query = ` + query { + viewer { + contributionsCollection { + pullRequestReviewContributions(first: 100) { + nodes { + occurredAt + pullRequestReview { + state + pullRequest { + repository { + nameWithOwner + } + } + } + } + } + } + } + } + `; + + 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.ok) throw new Error("GitHub GraphQL error"); + + const json = await res.json(); + const nodes = + json?.data?.viewer?.contributionsCollection + ?.pullRequestReviewContributions?.nodes ?? []; + + const totalReviews = nodes.length; + + const approvals = nodes.filter( + (n: { pullRequestReview: { state: string } }) => + n.pullRequestReview?.state === "APPROVED" + ).length; + const approvalRate = + totalReviews > 0 + ? `${Math.round((approvals / totalReviews) * 100)}%` + : "0%"; + + // Count reviews per repo + const repoCounts: Record = {}; + for (const node of nodes) { + const repo = node.pullRequestReview?.pullRequest?.repository?.nameWithOwner; + if (repo) repoCounts[repo] = (repoCounts[repo] ?? 0) + 1; + } + + const topRepos = Object.entries(repoCounts) + .map(([repo, count]) => ({ repo, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + return { + totalReviews, + approvalRate, + avgFirstReviewHours: null, + topRepos, + }; +} export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.accessToken) { @@ -245,11 +320,17 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchCachedPRMetrics(session.accessToken, { - bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + const [prResult, reviewResult] = await Promise.all([ + fetchCachedPRMetrics(session.accessToken, { + bypass, + userId: session.githubId ?? session.githubLogin ?? "primary", + }), + fetchReviewMetrics(session.accessToken), + ]); + return Response.json({ + ...formatPRMetrics(prResult), + reviews: reviewResult, }); - return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } @@ -281,6 +362,10 @@ export async function GET(req: NextRequest) { ) ); + const reviewResults = await Promise.allSettled( + accounts.map((account) => fetchReviewMetrics(account.token)) + ); + const merged = mergeMetrics(results, (a, b) => { const total = a.total + b.total; const mergedCount = a.merged + b.merged; @@ -318,7 +403,58 @@ export async function GET(req: NextRequest) { return Response.json({ error: "GitHub API error" }, { status: 502 }); } - return Response.json(formatPRMetrics(merged)); + // Merge review results across accounts + const allReviewNodes = reviewResults + .filter((r) => r.status === "fulfilled") + .map((r) => (r as PromiseFulfilledResult).value); + + const mergedReviews: ReviewMetrics = + allReviewNodes.length > 0 + ? { + totalReviews: allReviewNodes.reduce( + (sum, r) => sum + r.totalReviews, + 0 + ), + approvalRate: (() => { + const total = allReviewNodes.reduce( + (sum, r) => sum + r.totalReviews, + 0 + ); + const approvals = allReviewNodes.reduce((sum, r) => { + const rate = parseInt(r.approvalRate) / 100; + return sum + Math.round(rate * r.totalReviews); + }, 0); + return total > 0 + ? `${Math.round((approvals / total) * 100)}%` + : "0%"; + })(), + avgFirstReviewHours: null, + topRepos: Object.entries( + allReviewNodes + .flatMap((r) => r.topRepos) + .reduce( + (acc, { repo, count }) => { + acc[repo] = (acc[repo] ?? 0) + count; + return acc; + }, + {} as Record + ) + ) + .map(([repo, count]) => ({ repo, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5), + } + : { + totalReviews: 0, + approvalRate: "0%", + avgFirstReviewHours: null, + topRepos: [], + }; + + return Response.json({ + ...formatPRMetrics(merged), + reviews: mergedReviews, + }); } const token = @@ -331,11 +467,17 @@ export async function GET(req: NextRequest) { } try { - const result = await fetchCachedPRMetrics(token, { - bypass, - userId: accountId === session.githubId ? session.githubId : accountId, + const [result, reviewResult] = await Promise.all([ + fetchCachedPRMetrics(token, { + bypass, + userId: accountId === session.githubId ? session.githubId : accountId, + }), + fetchReviewMetrics(token), + ]); + return Response.json({ + ...formatPRMetrics(result), + reviews: reviewResult, }); - return Response.json(formatPRMetrics(result)); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index f7f92a92..2e10a836 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -13,15 +13,15 @@ interface PRData { mergeRate: string; } -function formatReviewCycle(hours: number | null): string { - if (hours === null) { - return "—"; - } - - if (hours < 24) { - return `${hours}h`; - } +interface ReviewData { + totalReviews: number; + approvalRate: string; + topRepos: Array<{ repo: string; count: number }>; +} +function formatReviewCycle(hours: number | null): string { + if (hours === null) return "—"; + if (hours < 24) return `${hours}h`; return `${Math.round((hours / 24) * 10) / 10}d`; } @@ -30,16 +30,16 @@ export default function PRMetrics() { const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<"authored" | "reviews">("authored"); + const [reviews, setReviews] = useState(null); + const [reviewLoading, setReviewLoading] = useState(true); const fetchMetrics = useCallback(() => { setLoading(true); setError(null); - - const url = - selectedAccount !== null - ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}` - : "/api/metrics/prs"; - + const url = selectedAccount !== null + ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}` + : "/api/metrics/prs"; fetch(url) .then((r) => { if (!r.ok) throw new Error("API error"); @@ -50,9 +50,22 @@ export default function PRMetrics() { .finally(() => setLoading(false)); }, [selectedAccount]); + const fetchReviews = useCallback(() => { + setReviewLoading(true); + const url = selectedAccount !== null + ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}&reviews=true` + : "/api/metrics/prs?reviews=true"; + fetch(url) + .then((r) => r.json()) + .then((data: { reviews: ReviewData }) => setReviews(data.reviews ?? null)) + .catch(() => setReviews(null)) + .finally(() => setReviewLoading(false)); + }, [selectedAccount]); + useEffect(() => { fetchMetrics(); - }, [fetchMetrics]); + fetchReviews(); + }, [fetchMetrics, fetchReviews]); const stats = metrics ? [ @@ -70,70 +83,103 @@ export default function PRMetrics() { return (
-

PR Analytics

- {loading ? ( -
- Loading PR analytics -
- {[1, 2, 3, 4, 5].map((i) => ( - - - ) : error ? ( -
-

{error}

+
+

PR Analytics

+
+
+
+ + {activeTab === "authored" ? ( + <> + {loading ? ( +
+ Loading PR analytics +
+ {[1, 2, 3, 4, 5].map((i) => ( + + + ) : error ? ( +
+

{error}

+ +
+ ) : ( +
+
+ {stats.map((stat) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ {metrics && ( +
+

PR Status Distribution

+ +
+ )} +
+ )} + ) : ( -
- {/* Stat grid */} -
- {stats.map((stat) => ( -
-
- {stat.value} + <> + {reviewLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : !reviews ? ( +

No review data available.

+ ) : ( +
+
+
+
{reviews.totalReviews}
+
Total Reviews
+
+
+
{reviews.approvalRate}
+
Approval Rate
-
{stat.label}
- ))} -
- - {/* PR status donut chart */} - {metrics && ( -
-

- PR Status Distribution -

- + {reviews.topRepos.length > 0 && ( +
+

Most Reviewed Repos

+
    + {reviews.topRepos.map((r) => ( +
  • + {r.repo.split("/")[1] ?? r.repo} + {r.count} reviews +
  • + ))} +
+
+ )}
)} -
+ )}
); -} +} \ No newline at end of file diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 00000000..fa9154c3 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const content: Record; + export default content; +}