From 4521a349b6373d5583056c36294d8b13b69e63a8 Mon Sep 17 00:00:00 2001 From: surjeetkumar800 Date: Sun, 24 May 2026 10:46:50 +0530 Subject: [PATCH] feat: Add Pull Request Review Intelligence & Bottleneck Analytics System --- src/Routes/Router.tsx | 2 + src/components/Features.tsx | 11 +- src/components/Navbar.tsx | 12 + src/pages/PRAnalytics/PRAnalytics.tsx | 722 ++++++++++++++++++++++++++ 4 files changed, 746 insertions(+), 1 deletion(-) create mode 100644 src/pages/PRAnalytics/PRAnalytics.tsx diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index 51eebc32..d40f2812 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -8,12 +8,14 @@ import Login from "../pages/Login/Login.tsx"; import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx"; import Home from "../pages/Home/Home.tsx"; import Activity from "../pages/Activity.tsx"; +import PRAnalytics from "../pages/PRAnalytics/PRAnalytics.tsx"; const Router = () => { return ( } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Features.tsx b/src/components/Features.tsx index 8125cb42..8eedadec 100644 --- a/src/components/Features.tsx +++ b/src/components/Features.tsx @@ -1,4 +1,4 @@ -import { BarChart3, Users, Search, Zap, Shield, Globe } from 'lucide-react'; +import { BarChart3, Users, Search, Zap, Shield, Globe, GitPullRequest } from 'lucide-react'; const Features = () => { const features = [ @@ -47,6 +47,15 @@ const Features = () => { hoverColor: 'hover:bg-red-400/50 dark:hover:bg-red-900/30', borderColor: 'hover:border-red-200 dark:hover:border-red-700' }, + { + icon: GitPullRequest, + title: 'PR Review Intelligence', + description: 'Advanced metrics on review turnaround, bottleneck detection, overloaded reviewer lists, and repository health index.', + bgColor: 'bg-teal-100', + iconColor: 'text-teal-600', + hoverColor: 'hover:bg-teal-400/50 dark:hover:bg-teal-900/30', + borderColor: 'hover:border-teal-200 dark:hover:border-teal-700' + }, { icon: Globe, title: 'Export & Share', diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 596a3244..aa1d91d8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -49,6 +49,10 @@ const Navbar: React.FC = () => { Tracker + + PR Analytics + + Contributors @@ -123,6 +127,14 @@ const Navbar: React.FC = () => { Tracker + + PR Analytics + + { + const theme = useTheme(); + const { username, getOctokit } = useGitHubAuth(); + + const [repoInput, setRepoInput] = useState("GitMetricsLab/github_tracker"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [prDetailsList, setPrDetailsList] = useState([]); + + const handleFetchAnalytics = async (e: React.FormEvent) => { + e.preventDefault(); + if (!repoInput.includes("/")) { + setError("Please specify repo in owner/repo format."); + return; + } + + const octokit = getOctokit(); + if (!octokit) { + setError("GitHub credentials missing. Please log in using the inputs in the Tracker tab first."); + return; + } + + const [owner, repo] = repoInput.split("/"); + setLoading(true); + setError(""); + setPrDetailsList([]); + + try { + // 1. Fetch recent PRs (both open and closed) + const prsResponse = await octokit.request("GET /repos/{owner}/{repo}/pulls", { + owner, + repo, + state: "all", + per_page: 20, + }); + + const prs = prsResponse.data; + + if (!prs || prs.length === 0) { + setError("No Pull Requests found in this repository."); + setLoading(false); + return; + } + + // 2. Fetch reviews in parallel for all fetched PRs + const details: PRDetail[] = await Promise.all( + prs.map(async (pr: any) => { + let reviews: any[] = []; + try { + const reviewsResponse = await octokit.request("GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews", { + owner, + repo, + pull_number: pr.number, + }); + reviews = reviewsResponse.data; + } catch (reviewErr) { + console.warn(`Failed to fetch reviews for PR #${pr.number}`, reviewErr); + } + + const createdTime = new Date(pr.created_at).getTime(); + const closedTime = pr.closed_at ? new Date(pr.closed_at).getTime() : null; + const mergedTime = pr.merged_at ? new Date(pr.merged_at).getTime() : null; + + // Find first review + const submittedReviews = reviews + .filter((r: any) => r.submitted_at) + .sort((a: any, b: any) => new Date(a.submitted_at).getTime() - new Date(b.submitted_at).getTime()); + + let timeToFirstReview: number | null = null; + let reviewCompletionDuration: number | null = null; + + if (submittedReviews.length > 0) { + const firstReviewTime = new Date(submittedReviews[0].submitted_at).getTime(); + timeToFirstReview = firstReviewTime - createdTime; + + if (mergedTime || closedTime) { + const finalTime = mergedTime || closedTime || 0; + reviewCompletionDuration = finalTime - firstReviewTime; + } + } + + let turnaroundTime: number | null = null; + if (mergedTime) { + turnaroundTime = mergedTime - createdTime; + } + + // Check if stalled (open for > 14 days with no reviews/comments in the last 7 days) + const now = Date.now(); + const openDuration = now - createdTime; + const isStalled = + pr.state === "open" && + openDuration > 14 * 24 * 60 * 60 * 1000 && + (submittedReviews.length === 0 || + now - new Date(submittedReviews[submittedReviews.length - 1].submitted_at).getTime() > 7 * 24 * 60 * 60 * 1000); + + const reviewers = Array.from(new Set(reviews.map((r: any) => r.user?.login).filter(Boolean))) as string[]; + + return { + number: pr.number, + title: pr.title, + state: pr.state, + htmlUrl: pr.html_url, + createdAt: pr.created_at, + mergedAt: pr.merged_at, + closedAt: pr.closed_at, + timeToFirstReview, + reviewCompletionDuration, + turnaroundTime, + stalled: isStalled, + reviewers, + }; + }) + ); + + setPrDetailsList(details); + } catch (err: any) { + console.error(err); + setError("Failed to fetch repository pull requests. Check your credentials and repo name."); + } finally { + setLoading(false); + } + }; + + // 3. Compute Metrics + const calculatedMetrics = useMemo(() => { + if (prDetailsList.length === 0) return null; + + let totalTimeToFirstReview = 0; + let countTimeToFirstReview = 0; + + let totalReviewCompletion = 0; + let countReviewCompletion = 0; + + let totalTurnaround = 0; + let countTurnaround = 0; + + let stalledCount = 0; + let unreviewedCount = 0; + + const reviewerCounts: { [key: string]: number } = {}; + + prDetailsList.forEach((pr) => { + if (pr.timeToFirstReview !== null) { + totalTimeToFirstReview += pr.timeToFirstReview; + countTimeToFirstReview++; + } else { + unreviewedCount++; + } + + if (pr.reviewCompletionDuration !== null) { + totalReviewCompletion += pr.reviewCompletionDuration; + countReviewCompletion++; + } + + if (pr.turnaroundTime !== null) { + totalTurnaround += pr.turnaroundTime; + countTurnaround++; + } + + if (pr.stalled) stalledCount++; + + pr.reviewers.forEach((rev) => { + reviewerCounts[rev] = (reviewerCounts[rev] || 0) + 1; + }); + }); + + const avgTimeToFirstReview = countTimeToFirstReview > 0 ? totalTimeToFirstReview / countTimeToFirstReview : null; + const avgReviewCompletion = countReviewCompletion > 0 ? totalReviewCompletion / countReviewCompletion : null; + const avgTurnaround = countTurnaround > 0 ? totalTurnaround / countTurnaround : null; + + // Workload Array + const workloadList: ReviewerWorkload[] = Object.entries(reviewerCounts) + .map(([username, count]) => ({ username, reviewsCount: count })) + .sort((a, b) => b.reviewsCount - a.reviewsCount); + + // Health Score calculation + let healthScore = 100; + if (prDetailsList.length > 0) { + // Unreviewed penalty: up to -30 + const unreviewedRatio = unreviewedCount / prDetailsList.length; + healthScore -= unreviewedRatio * 30; + + // Avg first review latency penalty: up to -25 + if (avgTimeToFirstReview) { + const hours = avgTimeToFirstReview / (3600 * 1000); + if (hours > 12) { + healthScore -= Math.min(25, (hours - 12) * 1.5); + } + } + + // Turnaround speed penalty: up to -25 + if (avgTurnaround) { + const days = avgTurnaround / (24 * 3600 * 1000); + if (days > 3) { + healthScore -= Math.min(25, (days - 3) * 3); + } + } + + // Stalled PR penalty: up to -20 + if (stalledCount > 0) { + healthScore -= Math.min(20, stalledCount * 5); + } + } + healthScore = Math.max(0, Math.round(healthScore)); + + // Bottlenecks + const overloadedReviewers = workloadList.filter( + (w) => w.reviewsCount > 3 || (w.reviewsCount / prDetailsList.length) > 0.4 + ); + + const delayedReviews = prDetailsList.filter( + (pr) => pr.state === "open" && pr.timeToFirstReview === null && (Date.now() - new Date(pr.createdAt).getTime()) > 48 * 3600 * 1000 + ); + + return { + avgTimeToFirstReview, + avgReviewCompletion, + avgTurnaround, + stalledCount, + unreviewedCount, + workloadList, + healthScore, + overloadedReviewers, + delayedReviews, + }; + }, [prDetailsList]); + + // Chart preparation + const chartData = useMemo(() => { + if (!calculatedMetrics) return null; + + // Workload Chart data + const workloadData = calculatedMetrics.workloadList.map((item) => ({ + name: item.username, + value: item.reviewsCount, + })); + + // Lifecycle counts + let openCount = 0; + let closedCount = 0; + let mergedCount = 0; + + prDetailsList.forEach((pr) => { + if (pr.mergedAt) mergedCount++; + else if (pr.state === "open") openCount++; + else closedCount++; + }); + + const lifecycleData = [ + { name: "Merged", value: mergedCount }, + { name: "Open", value: openCount }, + { name: "Closed", value: closedCount }, + ]; + + // Trends Data + const trendData = prDetailsList + .filter((pr) => pr.timeToFirstReview !== null) + .slice(-10) // last 10 reviewed + .map((pr) => ({ + prNumber: `#${pr.number}`, + firstReviewHrs: Math.round((pr.timeToFirstReview || 0) / (3600 * 1000) * 10) / 10, + turnaroundDays: pr.turnaroundTime ? Math.round(pr.turnaroundTime / (24 * 3600 * 1000) * 10) / 10 : 0, + })); + + return { + workloadData, + lifecycleData, + trendData, + }; + }, [prDetailsList, calculatedMetrics]); + + const formatDurationMs = (ms: number | null) => { + if (ms === null) return "N/A"; + const sec = ms / 1000; + const min = sec / 60; + const hr = min / 60; + const day = hr / 24; + + if (day >= 1) return `${Math.round(day * 10) / 10}d`; + if (hr >= 1) return `${Math.round(hr * 10) / 10}h`; + if (min >= 1) return `${Math.round(min * 10) / 10}m`; + return `${Math.round(sec)}s`; + }; + + const getHealthLevel = (score: number) => { + if (score >= 80) return { label: "Excellent", color: "text-green-500", icon: ShieldCheck }; + if (score >= 60) return { label: "Good", color: "text-blue-500", icon: ShieldCheck }; + if (score >= 40) return { label: "Fair", color: "text-yellow-500", icon: ShieldAlert }; + return { label: "Poor", color: "text-red-500", icon: ShieldAlert }; + }; + + return ( + + + + PR Review Intelligence & Bottlenecks + + + Track repository workflow latency, time to first review, reviewer capacity, and blocked workflows. + + +
+ + setRepoInput(e.target.value)} + required + sx={{ flex: 1, minWidth: 250 }} + /> + + +
+
+ + {error && ( + + {error} + + )} + + {loading && ( + + + + Analyzing pull requests and review histories... + + + )} + + {calculatedMetrics && chartData && !loading && ( + + {/* Top Metric Cards */} + + + + + + Time to First Review + + + {formatDurationMs(calculatedMetrics.avgTimeToFirstReview)} + + + Average delay to first action + + + + + + + + + + Review Completion Speed + + + {formatDurationMs(calculatedMetrics.avgReviewCompletion)} + + + Average review iteration speed + + + + + + + + + + Avg Turnaround Time + + + {formatDurationMs(calculatedMetrics.avgTurnaround)} + + + Average time to merge + + + + + + {/* Health Score Card */} + + + + + Repository Health Score + + + + {calculatedMetrics.healthScore} + + + /100 + + + {(() => { + const health = getHealthLevel(calculatedMetrics.healthScore); + const HealthIcon = health.icon; + return ( + + + + {health.label} Workflow + + + ); + })()} + + + + + + {/* Charts Row */} + + + + + + Review Workload Distribution + + + Total review assignments per contributor + + + {chartData.workloadData.length > 0 ? ( + + + entry.name} + > + {chartData.workloadData.map((_, index) => ( + + ))} + + + + + ) : ( + + + No reviews found on recent PRs. + + + )} + + + + + + + + + + Recent Response Times Trend + + + First Review (hrs) & Merge (days) per Pull Request + + + {chartData.trendData.length > 0 ? ( + + + + + + + + + + + + ) : ( + + + Trend data requires PRs with submitted reviews. + + + )} + + + + + + + {/* Collaboration Bottlenecks and Overload */} + + + + + + + Reviewer Capacity & Overload + + + Reviewers carrying a disproportionate share of workload + + + + {calculatedMetrics.overloadedReviewers.length > 0 ? ( + + {calculatedMetrics.overloadedReviewers.map((rev) => ( + + + + + @{rev.username}} + secondary={`${rev.reviewsCount} reviews on recent PRs (High overload risk)`} + /> + + ))} + + ) : ( + + + + Reviewer workload is well distributed! + + + )} + + + + + + + + + + Delayed & Stalled PRs + + + Workflows waiting for review or blocked for too long + + + + {calculatedMetrics.delayedReviews.length > 0 || calculatedMetrics.stalledCount > 0 ? ( + + {calculatedMetrics.delayedReviews.map((pr) => ( + + + + + + PR #{pr.number} - {pr.title} + + } + secondary="Waiting for review for more than 48 hours" + /> + + ))} + + {prDetailsList + .filter((pr) => pr.stalled) + .map((pr) => ( + + + + + + PR #{pr.number} - {pr.title} + + } + secondary="Stalled: Open > 14 days with no recent review updates" + /> + + ))} + + ) : ( + + + + No stalled or delayed PRs detected! + + + )} + + + + + + {/* Pull Request Analytics Table */} + + + + Pull Request Lifecycle Log + + + + + + PR + State + Time to 1st Review + Review Duration + Turnaround Time + + + + {prDetailsList.map((pr) => ( + + + + {pr.mergedAt ? ( + + ) : pr.state === "open" ? ( + + ) : ( + + )} + + #{pr.number} - {pr.title} + + + + + {pr.mergedAt ? "merged" : pr.state} + + + {formatDurationMs(pr.timeToFirstReview)} + + + {formatDurationMs(pr.reviewCompletionDuration)} + + + {formatDurationMs(pr.turnaroundTime)} + + + ))} + +
+
+
+
+
+ )} +
+ ); +}; + +export default PRAnalytics;