Skip to content
Open
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
70 changes: 68 additions & 2 deletions src/app/api/metrics/prs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -312,6 +349,7 @@ async function fetchPRMetrics(
avgCycleTime,
weeklyTrend,
slowestRepos,
reviewTimeBuckets,
};
}

Expand Down Expand Up @@ -392,6 +430,9 @@ async function fetchGitLabMRMetrics(token: string): Promise<PRMetricsBase> {

const sampleTotal = items.length;

const gitlabHours = reviewDurations.map((d) => d / 3600000);
const reviewTimeBuckets = computeReviewTimeBuckets(gitlabHours);

return {
open,
merged,
Expand All @@ -405,6 +446,7 @@ async function fetchGitLabMRMetrics(token: string): Promise<PRMetricsBase> {
avgCycleTime: 0,
weeklyTrend: [],
slowestRepos: [],
reviewTimeBuckets,
};
}

Expand Down Expand Up @@ -456,6 +498,7 @@ function formatPRMetrics(metrics: PRMetricsBase) {
slowestRepos: metrics.slowestRepos,
totalAdditions: metrics.totalAdditions,
totalDeletions: metrics.totalDeletions,
reviewTimeBuckets: metrics.reviewTimeBuckets,
};
}

Expand Down Expand Up @@ -678,6 +721,28 @@ export async function GET(req: NextRequest) {
.sort((a, b) => b.avgHours - a.avgHours)
.slice(0, 3);

const combinedBucketsMap: Record<string, number> = {
"<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,
Expand All @@ -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([
Expand Down
65 changes: 64 additions & 1 deletion src/components/PRMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
"<1h": "#10b981",
"1–4h": "#10b981",
"4–24h": "#f59e0b",
"1–3d": "#f59e0b",
"3–7d": "#ef4444",
"7d+": "#ef4444",
};

interface PRMetricsSummary {
open: number;
merged: number;
Expand All @@ -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 {
Expand Down Expand Up @@ -311,6 +321,59 @@ export default function PRMetrics() {
</div>
)}

{/* Review Time Histogram */}
{metrics?.reviewTimeBuckets && metrics.reviewTimeBuckets.length > 0 && (
<div className="rounded-lg bg-[var(--control)] p-4 border border-[var(--border)]">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<div>
<h3 className="text-sm font-semibold text-[var(--card-foreground)]">
Review Time Distribution
</h3>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
Histogram of PR review turnaround time buckets
</p>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1 text-[var(--muted-foreground)]">
<span className="w-2.5 h-2.5 rounded-full bg-[#10b981]" /> Fast (&lt;4h)
</span>
<span className="flex items-center gap-1 text-[var(--muted-foreground)]">
<span className="w-2.5 h-2.5 rounded-full bg-[#f59e0b]" /> Moderate (4h–3d)
</span>
<span className="flex items-center gap-1 text-[var(--muted-foreground)]">
<span className="w-2.5 h-2.5 rounded-full bg-[#ef4444]" /> Slow (3d+)
</span>
</div>
</div>

<ResponsiveContainer width="100%" height={220}>
<BarChart data={metrics.reviewTimeBuckets} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<XAxis dataKey="range" tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} />
<YAxis allowDecimals={false} tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} />
<Tooltip
formatter={(value: any) => [`${value} PRs`, "Reviewed"]}
labelFormatter={(label) => `Turnaround: ${label}`}
contentStyle={{
backgroundColor: "var(--card)",
borderColor: "var(--border)",
borderRadius: "8px",
color: "var(--card-foreground)",
fontSize: "12px",
}}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{metrics.reviewTimeBuckets.map((entry) => (
<Cell
key={entry.range}
fill={BUCKET_COLORS[entry.range] || "var(--accent)"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}

{/* Cycle Time Features */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{metrics?.weeklyTrend && metrics.weeklyTrend.length > 0 && (
Expand Down
Loading