diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index b5f734133..70af542ec 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -28,6 +28,36 @@ interface PRMetricsBase { avgCycleTime: number; weeklyTrend: { week: string; avgHours: number }[]; slowestRepos: { repo: string; avgHours: number }[]; + reviewTimeBuckets: { range: string; count: number }[]; +} + +function computeReviewTimeBuckets(durationsHours: number[]): { range: string; count: number }[] { + const buckets = { + "<1h": 0, + "1–4h": 0, + "4–24h": 0, + "1–3d": 0, + "3–7d": 0, + "7d+": 0, + }; + + for (const h of durationsHours) { + if (h < 1) buckets["<1h"]++; + else if (h < 4) buckets["1–4h"]++; + else if (h < 24) buckets["4–24h"]++; + else if (h < 72) buckets["1–3d"]++; + else if (h < 168) buckets["3–7d"]++; + else buckets["7d+"]++; + } + + return [ + { range: "<1h", count: buckets["<1h"] }, + { range: "1–4h", count: buckets["1–4h"] }, + { range: "4–24h", count: buckets["4–24h"] }, + { range: "1–3d", count: buckets["1–3d"] }, + { range: "3–7d", count: buckets["3–7d"] }, + { range: "7d+", count: buckets["7d+"] }, + ]; } interface ReviewMetrics { @@ -297,7 +327,14 @@ async function fetchPRMetrics( 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); + const prReviewDurations = mergedPRs + .map((pr) => { + if (!pr.closed_at || !pr.created_at) return null; + return (new Date(pr.closed_at).getTime() - new Date(pr.created_at).getTime()) / 3600000; + }) + .filter((h): h is number => typeof h === "number" && !Number.isNaN(h) && h >= 0); + + const reviewTimeBuckets = computeReviewTimeBuckets(prReviewDurations); return { open, @@ -312,6 +349,7 @@ async function fetchPRMetrics( avgCycleTime, weeklyTrend, slowestRepos, + reviewTimeBuckets, }; } @@ -392,6 +430,9 @@ async function fetchGitLabMRMetrics(token: string): Promise { const sampleTotal = items.length; + const gitlabHours = reviewDurations.map((d) => d / 3600000); + const reviewTimeBuckets = computeReviewTimeBuckets(gitlabHours); + return { open, merged, @@ -405,6 +446,7 @@ async function fetchGitLabMRMetrics(token: string): Promise { avgCycleTime: 0, weeklyTrend: [], slowestRepos: [], + reviewTimeBuckets, }; } @@ -456,6 +498,7 @@ function formatPRMetrics(metrics: PRMetricsBase) { slowestRepos: metrics.slowestRepos, totalAdditions: metrics.totalAdditions, totalDeletions: metrics.totalDeletions, + reviewTimeBuckets: metrics.reviewTimeBuckets, }; } @@ -678,6 +721,28 @@ export async function GET(req: NextRequest) { .sort((a, b) => b.avgHours - a.avgHours) .slice(0, 3); + const combinedBucketsMap: Record = { + "<1h": 0, + "1–4h": 0, + "4–24h": 0, + "1–3d": 0, + "3–7d": 0, + "7d+": 0, + }; + results.forEach(r => { + r.reviewTimeBuckets?.forEach(b => { + combinedBucketsMap[b.range] = (combinedBucketsMap[b.range] ?? 0) + b.count; + }); + }); + const combinedReviewTimeBuckets = [ + { range: "<1h", count: combinedBucketsMap["<1h"] }, + { range: "1–4h", count: combinedBucketsMap["1–4h"] }, + { range: "4–24h", count: combinedBucketsMap["4–24h"] }, + { range: "1–3d", count: combinedBucketsMap["1–3d"] }, + { range: "3–7d", count: combinedBucketsMap["3–7d"] }, + { range: "7d+", count: combinedBucketsMap["7d+"] }, + ]; + const combinedMetrics: PRMetricsBase = { totalAdditions: results.reduce( (sum, r) => sum + r.totalAdditions, @@ -697,7 +762,8 @@ export async function GET(req: NextRequest) { mergeRate: combinedTotal > 0 ? combinedMerged / combinedTotal : 0, avgCycleTime: combinedCycleTime, weeklyTrend: combinedWeeklyTrend, - slowestRepos: combinedSlowest + slowestRepos: combinedSlowest, + reviewTimeBuckets: combinedReviewTimeBuckets, }; const [gitlab, reviews] = await Promise.all([ diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index 523e6c00a..273e3aa1a 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -5,11 +5,20 @@ import { useCallback, useEffect, useState } from "react"; import { usePersistentState } from "@/hooks/usePersistentState"; import { useAccount } from "@/components/AccountContext"; import { useDashboardWidgetA11y } from "@/components/dashboard/DashboardWidgetA11yContext"; -import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"; +import { LineChart, Line, BarChart, Bar, Cell, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"; import PRStatusDonutChart from "./PRStatusDonutChart"; import MiniPRTrendChart from "./MiniPRTrendChart"; import { SkeletonBlock } from "./WidgetSkeleton"; +const BUCKET_COLORS: Record = { + "<1h": "#10b981", + "1–4h": "#10b981", + "4–24h": "#f59e0b", + "1–3d": "#f59e0b", + "3–7d": "#ef4444", + "7d+": "#ef4444", +}; + interface PRMetricsSummary { open: number; merged: number; @@ -23,6 +32,7 @@ interface PRMetricsSummary { avgCycleTime?: number; weeklyTrend?: { week: string; avgHours: number }[]; slowestRepos?: { repo: string; avgHours: number }[]; + reviewTimeBuckets?: { range: string; count: number }[]; } interface PRData extends PRMetricsSummary { @@ -311,6 +321,59 @@ export default function PRMetrics() { )} + {/* Review Time Histogram */} + {metrics?.reviewTimeBuckets && metrics.reviewTimeBuckets.length > 0 && ( +
+
+
+

+ Review Time Distribution +

+

+ Histogram of PR review turnaround time buckets +

+
+
+ + Fast (<4h) + + + Moderate (4h–3d) + + + Slow (3d+) + +
+
+ + + + + + [`${value} PRs`, "Reviewed"]} + labelFormatter={(label) => `Turnaround: ${label}`} + contentStyle={{ + backgroundColor: "var(--card)", + borderColor: "var(--border)", + borderRadius: "8px", + color: "var(--card-foreground)", + fontSize: "12px", + }} + /> + + {metrics.reviewTimeBuckets.map((entry) => ( + + ))} + + + +
+ )} + {/* Cycle Time Features */}
{metrics?.weeklyTrend && metrics.weeklyTrend.length > 0 && (