diff --git a/package-lock.json b/package-lock.json index b9dff971..1e722bd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "next-auth": "^4.24.7", "react": "^18", "react-dom": "^18", - "recharts": "^2.12.7" + "recharts": "^2.15.4" }, "devDependencies": { "@playwright/test": "1.49.1", diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 5c9f7554..f1904242 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -1,11 +1,7 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; -import { - getAccountToken, - getAllAccounts, - mergeMetrics, -} from "@/lib/github-accounts"; +import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; import { isMetricsCacheBypassed, @@ -13,7 +9,6 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; -import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; @@ -26,6 +21,15 @@ interface PRMetricsBase { avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: number; + avgCycleTime: number; + weeklyTrend: { week: string; avgHours: number }[]; + slowestRepos: { repo: string; avgHours: number }[]; +} + +function getWeekLabel(dateStr: string): string { + const date = new Date(dateStr); + const week = Math.floor(date.getDate() / 7) + 1; + return `${date.toLocaleString("default", { month: "short" })} W${week}`; } interface PullRequestSearchItem { @@ -45,6 +49,22 @@ interface ReviewCommentEvent { created_at?: string | null; } +interface GraphQLPullRequestNode { + createdAt: string; + reviews: { + nodes: { submittedAt: string }[]; + }; + repository: { nameWithOwner: string }; +} + +interface GraphQLSearchResponse { + data?: { + search?: { + nodes?: GraphQLPullRequestNode[]; + }; + }; +} + function getRepoFullName(repositoryUrl: string): string | null { const marker = "/repos/"; const index = repositoryUrl.indexOf(marker); @@ -142,9 +162,7 @@ async function fetchPRMetrics(token: string): Promise { } ); - if (!searchRes.ok) { - throw new Error("GitHub API error"); - } + if (!searchRes.ok) throw new Error("GitHub API error"); const data = (await searchRes.json()) as { total_count: number; @@ -153,45 +171,119 @@ async function fetchPRMetrics(token: string): Promise { const open = data.items.filter((pr) => pr.state === "open").length; - // A PR with state "closed" may have been merged OR closed without merging - // (e.g. rejected, abandoned). Only count those with a non-null merged_at - // as truly merged so the dashboard does not inflate the merged count. const merged = data.items.filter( (pr) => pr.pull_request?.merged_at != null ).length; - // Closed without merging (rejected / abandoned) const closed = data.items.filter( (pr) => pr.state === "closed" && pr.pull_request?.merged_at == null ).length; - // Average review time: use only actually merged PRs so we measure the time - // from open to merge, not open to close-without-merge. const mergedPRs = data.items.filter( (pr) => pr.pull_request?.merged_at != null ); + const avgReviewMs = mergedPRs.length > 0 ? mergedPRs.reduce( (sum, pr) => sum + - (new Date(pr.pull_request!.merged_at!).getTime() - + (new Date(pr.closed_at!).getTime() - new Date(pr.created_at).getTime()), 0 ) / mergedPRs.length : 0; - // Use the number of fetched items as the denominator for mergeRate. - // data.total_count is the all-time GitHub total (potentially thousands) - // while data.items is capped at 100, so dividing merged/total_count - // produces a near-zero rate for any active user. The fetched sample - // (open + merged + closed-without-merge) is the correct base. const sampleTotal = data.items.length; - const avgFirstReviewHours = await getAverageFirstReviewHours( - token, - data.items + const avgFirstReviewHours = await getAverageFirstReviewHours(token, data.items); + + // GraphQL for review cycle time + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const since = ninetyDaysAgo.toISOString().split("T")[0]; + + const query = ` + query { + search(query: "type:pr reviewed-by:@me created:>${since}", type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + createdAt + reviews(first: 1) { + nodes { + submittedAt + } + } + repository { nameWithOwner } + } + } + } + } + `; + + const gqlRes = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query }), + cache: "no-store", + }); + + const gqlJson = (await gqlRes.json()) as GraphQLSearchResponse; + const prs = gqlJson.data?.search?.nodes ?? []; + + const reviewedPRs = prs.filter( + (pr) => pr.reviews?.nodes && pr.reviews.nodes.length > 0 ); + const cycleTimes = reviewedPRs.map((pr) => ({ + hours: Math.round( + (new Date(pr.reviews.nodes[0].submittedAt).getTime() - + new Date(pr.createdAt).getTime()) / + 3600000 + ), + week: getWeekLabel(pr.createdAt), + repo: pr.repository.nameWithOwner, + })); + + const avgCycleTime = + cycleTimes.length > 0 + ? Math.round( + cycleTimes.reduce((sum, ct) => sum + ct.hours, 0) / + cycleTimes.length + ) + : 0; + + const weeklyMap: Record = {}; + cycleTimes.forEach((ct) => { + if (!weeklyMap[ct.week]) weeklyMap[ct.week] = []; + weeklyMap[ct.week].push(ct.hours); + }); + + const weeklyTrend = Object.entries(weeklyMap).map(([week, times]) => ({ + week, + avgHours: Math.round( + times.reduce((a, b) => a + b, 0) / times.length + ), + })); + + const repoMap: Record = {}; + cycleTimes.forEach((ct) => { + if (!repoMap[ct.repo]) repoMap[ct.repo] = []; + repoMap[ct.repo].push(ct.hours); + }); + + const slowestRepos = Object.entries(repoMap) + .map(([repo, times]) => ({ + repo, + avgHours: Math.round( + times.reduce((a, b) => a + b, 0) / times.length + ), + })) + .sort((a, b) => b.avgHours - a.avgHours) + .slice(0, 3); + return { open, merged, @@ -200,6 +292,9 @@ async function fetchPRMetrics(token: string): Promise { avgReviewHours: Math.round(avgReviewMs / 3600000), avgFirstReviewHours, mergeRate: sampleTotal > 0 ? merged / sampleTotal : 0, + avgCycleTime, + weeklyTrend, + slowestRepos, }; } @@ -231,6 +326,9 @@ function formatPRMetrics(metrics: PRMetricsBase) { metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + avgCycleTime: metrics.avgCycleTime, + weeklyTrend: metrics.weeklyTrend, + slowestRepos: metrics.slowestRepos, }; } @@ -266,63 +364,82 @@ export async function GET(req: NextRequest) { } if (accountId === "combined") { - const accounts = await getAllAccounts( - { - token: session.accessToken, - githubId: session.githubId, - githubLogin: session.githubLogin, - }, - userRow.id - ); - - const results = await Promise.allSettled( - accounts.map((account) => - fetchCachedPRMetrics(account.token, { bypass, userId: account.githubId }) - ) - ); - - const merged = mergeMetrics(results, (a, b) => { - const total = a.total + b.total; - const mergedCount = a.merged + b.merged; - const closedCount = a.closed + b.closed; - const avgReviewHours = - total > 0 - ? (a.avgReviewHours * a.total + b.avgReviewHours * b.total) / total - : 0; - const reviewedTotal = - (a.avgFirstReviewHours === null ? 0 : a.total) + - (b.avgFirstReviewHours === null ? 0 : b.total); - const avgFirstReviewHours = - reviewedTotal > 0 - ? ((a.avgFirstReviewHours ?? 0) * a.total + - (b.avgFirstReviewHours ?? 0) * b.total) / - reviewedTotal - : null; - - return { - open: a.open + b.open, - merged: mergedCount, - closed: closedCount, - total, + try { + const allAccounts = await getAllAccounts(userRow.id); + + const metricsPromises = allAccounts.map(async (acc) => { + const token = acc.github_id === session.githubId + ? session.accessToken + : await getAccountToken(userRow.id, acc.github_id); + if (!token) return null; + return fetchCachedPRMetrics(token, { bypass, userId: acc.github_id }); + }); + + const resultsRaw = await Promise.allSettled(metricsPromises); + const results = resultsRaw + .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled" && r.value !== null) + .map(r => r.value); + + if (results.length === 0) { + return Response.json({ error: "No accounts found" }, { status: 404 }); + } + + const combinedTotal = results.reduce((sum, r) => sum + r.total, 0); + const combinedMerged = results.reduce((sum, r) => sum + r.merged, 0); + const combinedClosed = results.reduce((sum, r) => sum + r.closed, 0); + const combinedOpen = results.reduce((sum, r) => sum + r.open, 0); + + const avgReviewHours = combinedTotal > 0 + ? results.reduce((sum, r) => sum + (r.avgReviewHours * r.total), 0) / combinedTotal + : 0; + + const reviewedTotal = results.reduce((sum, r) => sum + (r.avgFirstReviewHours === null ? 0 : r.total), 0); + const avgFirstReviewHours = reviewedTotal > 0 + ? results.reduce((sum, r) => sum + ((r.avgFirstReviewHours ?? 0) * r.total), 0) / reviewedTotal + : null; + + const combinedCycleTime = results.length > 0 + ? Math.round(results.reduce((sum, r) => sum + r.avgCycleTime, 0) / results.length) + : 0; + + const weeklyTrendsMap: Record = {}; + results.forEach(r => { + r.weeklyTrend.forEach(wt => { + if (!weeklyTrendsMap[wt.week]) weeklyTrendsMap[wt.week] = []; + weeklyTrendsMap[wt.week].push(wt.avgHours); + }); + }); + const combinedWeeklyTrend = Object.entries(weeklyTrendsMap).map(([week, hoursArray]) => ({ + week, + avgHours: Math.round(hoursArray.reduce((a, b) => a + b, 0) / hoursArray.length) + })); + + const combinedSlowest = results + .flatMap(r => r.slowestRepos) + .sort((a, b) => b.avgHours - a.avgHours) + .slice(0, 3); + + const combinedMetrics: PRMetricsBase = { + open: combinedOpen, + merged: combinedMerged, + closed: combinedClosed, + total: combinedTotal, avgReviewHours: Math.round(avgReviewHours * 10) / 10, - avgFirstReviewHours: - avgFirstReviewHours === null - ? null - : Math.round(avgFirstReviewHours * 10) / 10, - mergeRate: - total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, + avgFirstReviewHours: avgFirstReviewHours === null ? null : Math.round(avgFirstReviewHours * 10) / 10, + mergeRate: combinedTotal > 0 ? combinedMerged / combinedTotal : 0, + avgCycleTime: combinedCycleTime, + weeklyTrend: combinedWeeklyTrend, + slowestRepos: combinedSlowest }; - }); - if (!merged) { - return Response.json({ error: "GitHub API error" }, { status: 502 }); + return Response.json(formatPRMetrics(combinedMetrics)); + } catch { + return Response.json({ error: "Failed to compile combined profile metrics" }, { status: 502 }); } - - return Response.json(formatPRMetrics(merged)); } const token = - accountId === session.githubId + !accountId || accountId === session.githubId ? session.accessToken : await getAccountToken(userRow.id, accountId); @@ -339,4 +456,4 @@ export async function GET(req: NextRequest) { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} +} \ No newline at end of file diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 3a7e4a3c..6c49fad7 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -2,15 +2,20 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"; import PRStatusDonutChart from "./PRStatusDonutChart"; interface PRData { open: number; merged: number; closed: number; + total: number; avgReviewHours: number; avgFirstReviewHours: number | null; mergeRate: string; + avgCycleTime: number; + weeklyTrend: { week: string; avgHours: number }[]; + slowestRepos: { repo: string; avgHours: number }[]; } function formatReviewCycle(hours: number | null): string { @@ -46,7 +51,9 @@ export default function PRMetrics() { return r.json(); }) .then((data: PRData) => setMetrics(data)) - .catch(() => setError("We couldn't load your PR analytics right now. Please try again in a moment.")) + .catch(() => + setError("We couldn't load your PR analytics right now. Please try again in a moment.") + ) .finally(() => setLoading(false)); }, [selectedAccount]); @@ -65,12 +72,14 @@ export default function PRMetrics() { title: "Average time from PR open to first review comment or approval", }, { label: "Merge Rate", value: metrics.mergeRate }, + { label: "Avg Cycle Time", value: `${metrics.avgCycleTime}h` }, ] : []; return ( -
-

PR Analytics

+
+

PR Analytics

+ {loading ? (
)} + + {/* Weekly Trend Chart */} + {metrics?.weeklyTrend && metrics.weeklyTrend.length > 0 && ( +
+

+ Review Cycle Time Trend (by week) +

+ + + + + [`${val}h`, "Avg Cycle Time"]} /> + + + +
+ )} + + {/* Slowest Repos */} + {metrics?.slowestRepos && metrics.slowestRepos.length > 0 && ( +
+

+ Repos with Longest Review Cycle +

+
+ {metrics.slowestRepos.map((r) => ( +
+ + {r.repo} + + + {r.avgHours}h + +
+ ))} +
+
+ )}
)}
); -} +} \ No newline at end of file