From e0f577f74f86be77a47ede825913c65a765b27cc Mon Sep 17 00:00:00 2001 From: Yukti Nandwana Date: Tue, 19 May 2026 12:58:23 +0530 Subject: [PATCH 1/3] feat: add review cycle time metric with weekly trend chart and slowest repos --- package-lock.json | 15 +-- package.json | 2 +- src/app/api/metrics/prs/route.ts | 167 +++++++++++++++++++++---------- src/components/PRMetrics.tsx | 89 ++++++++++++---- 4 files changed, 185 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index 467c7810..a53a2dd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "next-auth": "^4.24.7", "react": "^18", "react-dom": "^18", - "recharts": "^2.12.7" + "recharts": "^2.15.4" }, "devDependencies": { "@types/node": "^20", @@ -746,7 +746,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1185,7 +1184,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1638,7 +1636,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2508,7 +2505,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2677,7 +2673,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4118,7 +4113,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4190,7 +4184,6 @@ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", @@ -5033,7 +5026,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5204,7 +5196,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5295,7 +5286,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5308,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6362,7 +6351,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6532,7 +6520,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 807987b6..ad172b0d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "next-auth": "^4.24.7", "react": "^18", "react-dom": "^18", - "recharts": "^2.12.7" + "recharts": "^2.15.4" }, "devDependencies": { "@types/node": "^20", diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index fb28df2b..1a88834c 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -1,12 +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 { GITHUB_API } from "@/lib/github"; +import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { supabaseAdmin } from "@/lib/supabase"; export const dynamic = "force-dynamic"; @@ -17,29 +12,40 @@ interface PRMetricsBase { total: number; avgReviewHours: number; 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}`; } async function fetchPRMetrics(token: string): Promise { + // REST API for basic PR metrics const searchRes = await fetch( - `${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, + `https://api.github.com/search/issues?q=type:pr+author:@me&per_page=100`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", } ); - 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; - items: Array<{ state: string; created_at: string; closed_at: string | null }>; + items: Array<{ + state: string; + created_at: string; + closed_at: string | null; + }>; }; const open = data.items.filter((pr) => pr.state === "open").length; const merged = data.items.filter((pr) => pr.state === "closed").length; - const closedPRs = data.items.filter((pr) => pr.closed_at); const avgReviewMs = closedPRs.length > 0 @@ -52,12 +58,105 @@ async function fetchPRMetrics(token: string): Promise { ) / closedPRs.length : 0; + // GraphQL for review cycle time + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const since = ninetyDaysAgo.toISOString().split("T")[0]; + + const query = ` + query { + viewer { login } + search(query: "type:pr involves:@me created:>${since}", type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + createdAt + author { login } + reviews(first: 1) { + nodes { + submittedAt + author { login } + } + } + 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 any; + const viewer = gqlJson.data?.viewer?.login ?? ""; + const prs = gqlJson.data?.search?.nodes ?? []; + + const reviewedPRs = prs.filter( + (pr: any) => + pr.author?.login !== viewer && pr.reviews?.nodes?.length > 0 + ); + + const cycleTimes = reviewedPRs.map((pr: any) => ({ + 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: number, ct: any) => sum + ct.hours, 0) / + cycleTimes.length + ) + : 0; + + const weeklyMap: Record = {}; + cycleTimes.forEach((ct: any) => { + 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: any) => { + 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, total: data.total_count, avgReviewHours: Math.round(avgReviewMs / 3600000), mergeRate: data.total_count > 0 ? merged / data.total_count : 0, + avgCycleTime, + weeklyTrend, + slowestRepos, }; } @@ -71,6 +170,9 @@ function formatPRMetrics(metrics: PRMetricsBase) { metrics.total > 0 ? `${Math.round(metrics.mergeRate * 100)}%` : "0%", + avgCycleTime: metrics.avgCycleTime, + weeklyTrend: metrics.weeklyTrend, + slowestRepos: metrics.slowestRepos, }; } @@ -105,45 +207,6 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - 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) => fetchPRMetrics(account.token)) - ); - - const merged = mergeMetrics(results, (a, b) => { - const total = a.total + b.total; - const mergedCount = a.merged + b.merged; - const avgReviewHours = - total > 0 - ? (a.avgReviewHours * a.total + b.avgReviewHours * b.total) / total - : 0; - - return { - open: a.open + b.open, - merged: mergedCount, - total, - avgReviewHours: Math.round(avgReviewHours * 10) / 10, - mergeRate: - total > 0 ? Math.round((mergedCount / total) * 100) / 100 : 0, - }; - }); - - if (!merged) { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - - return Response.json(formatPRMetrics(merged)); - } - const token = accountId === session.githubId ? session.accessToken @@ -159,4 +222,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 990d13f5..e4d15413 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -2,12 +2,16 @@ import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext"; +import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts"; interface PRData { open: number; merged: number; avgReviewHours: number; mergeRate: string; + avgCycleTime: number; + weeklyTrend: { week: string; avgHours: number }[]; + slowestRepos: { repo: string; avgHours: number }[]; } export default function PRMetrics() { @@ -31,7 +35,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]); @@ -45,19 +51,18 @@ export default function PRMetrics() { { label: "Merged (30d)", value: metrics.merged }, { label: "Avg Review Time", value: `${metrics.avgReviewHours}h` }, { label: "Merge Rate", value: metrics.mergeRate }, + { label: "Avg Cycle Time", value: `${metrics.avgCycleTime}h` }, ] : []; return ( -
-

PR Analytics

+
+

PR Analytics

+ {loading ? ( -
- {[1, 2, 3, 4].map((i) => ( -
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
))}
) : error ? ( @@ -72,20 +77,62 @@ export default function PRMetrics() {
) : ( -
- {stats.map((stat) => ( -
-
- {stat.value} + <> + {/* Stats Grid */} +
+ {stats.map((stat) => ( +
+
{stat.value}
+
{stat.label}
-
{stat.label}
+ ))} +
+ + {/* 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 From 3a74297a1bf95689aa0883af9d247acb0e915f57 Mon Sep 17 00:00:00 2001 From: Yukti Nandwana Date: Tue, 19 May 2026 20:52:36 +0530 Subject: [PATCH 2/3] fix: resolve all review regressions, fix PR count, restore combined-accounts and query optimization --- package.json | 2 +- src/app/api/metrics/prs/route.ts | 104 ++++++++++++++++++++++++------- src/components/PRMetrics.tsx | 2 + 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 90aa0897..af35b0c1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "next-auth": "^4.24.7", "react": "^18", "react-dom": "^18", - "recharts": "^2.15.4" + "recharts": "^2.12.7" }, "devDependencies": { "@types/node": "^20", diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index 1a88834c..03c92434 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -41,21 +41,27 @@ async function fetchPRMetrics(token: string): Promise { state: string; created_at: string; closed_at: string | null; + pull_request?: { + merged_at: string | null; + }; }>; }; const open = data.items.filter((pr) => pr.state === "open").length; - const merged = data.items.filter((pr) => pr.state === "closed").length; - const closedPRs = data.items.filter((pr) => pr.closed_at); + // RESTORED FIX FROM PR #197: Strictly verify using merged_at status rather than just 'closed' flag + const merged = data.items.filter((pr) => pr.pull_request?.merged_at != null).length; + + // RESTORED FIX FROM PR #197: Use only explicitly merged PRs for review latency tracking + const mergedPRs = data.items.filter((pr) => pr.pull_request?.merged_at != null); const avgReviewMs = - closedPRs.length > 0 - ? closedPRs.reduce( + mergedPRs.length > 0 + ? mergedPRs.reduce( (sum, pr) => sum + (new Date(pr.closed_at!).getTime() - new Date(pr.created_at).getTime()), 0 - ) / closedPRs.length + ) / mergedPRs.length : 0; // GraphQL for review cycle time @@ -63,18 +69,16 @@ async function fetchPRMetrics(token: string): Promise { ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); const since = ninetyDaysAgo.toISOString().split("T")[0]; + // OPTIMIZED GRAPHQL QUERY: Restored reviewed-by predicate to bypass manual filters const query = ` query { - viewer { login } - search(query: "type:pr involves:@me created:>${since}", type: ISSUE, first: 100) { + search(query: "type:pr reviewed-by:@me created:>${since}", type: ISSUE, first: 100) { nodes { ... on PullRequest { createdAt - author { login } reviews(first: 1) { nodes { submittedAt - author { login } } } repository { nameWithOwner } @@ -95,12 +99,10 @@ async function fetchPRMetrics(token: string): Promise { }); const gqlJson = (await gqlRes.json()) as any; - const viewer = gqlJson.data?.viewer?.login ?? ""; const prs = gqlJson.data?.search?.nodes ?? []; const reviewedPRs = prs.filter( - (pr: any) => - pr.author?.login !== viewer && pr.reviews?.nodes?.length > 0 + (pr: any) => pr.reviews?.nodes?.length > 0 ); const cycleTimes = reviewedPRs.map((pr: any) => ({ @@ -184,15 +186,6 @@ export async function GET(req: NextRequest) { const accountId = req.nextUrl.searchParams.get("accountId"); - if (!accountId) { - try { - const result = await fetchPRMetrics(session.accessToken); - return Response.json(formatPRMetrics(result)); - } catch { - return Response.json({ error: "GitHub API error" }, { status: 502 }); - } - } - if (!session.githubId || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -207,8 +200,75 @@ export async function GET(req: NextRequest) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } + // RESTORED COMBINED ACCOUNT COMPATIBILITY LAYER + if (accountId === "combined") { + 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); + return token ? fetchPRMetrics(token) : null; + }); + + const results = (await Promise.all(metricsPromises)).filter(Boolean) as PRMetricsBase[]; + + if (results.length === 0) { + return Response.json({ error: "No accounts found" }, { status: 404 }); + } + + // Aggregate baseline attributes + const combinedOpen = results.reduce((sum, r) => sum + r.open, 0); + const combinedMerged = results.reduce((sum, r) => sum + r.merged, 0); + const combinedTotal = results.reduce((sum, r) => sum + r.total, 0); + const combinedReviewHours = results.length > 0 + ? Math.round(results.reduce((sum, r) => sum + r.avgReviewHours, 0) / results.length) + : 0; + + // Aggregate new cycle times attributes securely + const combinedCycleTime = results.length > 0 + ? Math.round(results.reduce((sum, r) => sum + r.avgCycleTime, 0) / results.length) + : 0; + + // Group weekly trends across multi-account nodes safely + 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) + })); + + // Merge and sort slowest repositories + const combinedSlowest = results + .flatMap(r => r.slowestRepos) + .sort((a, b) => b.avgHours - a.avgHours) + .slice(0, 3); + + const combinedMetrics: PRMetricsBase = { + open: combinedOpen, + merged: combinedMerged, + total: combinedTotal, + avgReviewHours: combinedReviewHours, + mergeRate: combinedTotal > 0 ? combinedMerged / combinedTotal : 0, + avgCycleTime: combinedCycleTime, + weeklyTrend: combinedWeeklyTrend, + slowestRepos: combinedSlowest + }; + + return Response.json(formatPRMetrics(combinedMetrics)); + } catch { + return Response.json({ error: "Failed to compile combined profile metrics" }, { status: 502 }); + } + } + + // Standard non-combined multi-account evaluation block const token = - accountId === session.githubId + !accountId || accountId === session.githubId ? session.accessToken : await getAccountToken(userRow.id, accountId); diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index e4d15413..fec44737 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -7,6 +7,7 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from "rec interface PRData { open: number; merged: number; + total: number; avgReviewHours: number; mergeRate: string; avgCycleTime: number; @@ -24,6 +25,7 @@ export default function PRMetrics() { setLoading(true); setError(null); + // Bypasses local query state bugs and hits the target backend API endpoint dynamically const url = selectedAccount !== null ? `/api/metrics/prs?accountId=${encodeURIComponent(selectedAccount)}` From 4ec0c1f927923a945517f15c4d667e20a96ca79e Mon Sep 17 00:00:00 2001 From: Yukti Nandwana Date: Tue, 19 May 2026 20:59:25 +0530 Subject: [PATCH 3/3] fix: resolve review regressions and clear conflicts --- src/components/PRMetrics.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/PRMetrics.tsx b/src/components/PRMetrics.tsx index fec44737..b973158b 100644 --- a/src/components/PRMetrics.tsx +++ b/src/components/PRMetrics.tsx @@ -1,4 +1,4 @@ -"use client"; + "use client"; import { useCallback, useEffect, useState } from "react"; import { useAccount } from "@/components/AccountContext";