From 2b02d499cd5fbcf667014c64a9ad44a4f3881690 Mon Sep 17 00:00:00 2001 From: Rahul Vyas Date: Sat, 4 Jul 2026 23:46:50 +0530 Subject: [PATCH] Added new feature to calculate Advance Analysis with PAT only for more insights. --- src/context/AppContext.jsx | 26 ++- src/hooks/useSortedData.js | 49 +++++ src/pages/AnalyticsPage.jsx | 369 ++++++++++++++++++++++++++++++++++-- src/services/github.js | 11 ++ 4 files changed, 440 insertions(+), 15 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 0d6279c..74a2018 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react' -import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit } from '../services/github' +import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github' import { buildAnalyticalModel, getTopRepositories } from '../services/analytics' const Ctx = createContext(null) @@ -29,12 +29,14 @@ export function AppProvider({ children }) { const [orgs, setOrgs] = useState([]) const [model, setModel] = useState(null) const [issuesData, setIssuesData] = useState({}) + const [pullsData, setPullsData] = useState({}) const [rateLimit, setRateLimit] = useState(getStoredRateLimit) const [loading, setLoading] = useState(false) const [loadMsg, setLoadMsg] = useState('') const [govLoading, setGovLoading] = useState(false) const [error, setError] = useState('') const [totalRepo, setTotalRepo] = useState(0) + const [advanceAnalyticsLoading, setAdvanceAnalyticsLoading] = useState(false); useEffect(() => { const handler = e => { @@ -151,6 +153,24 @@ export function AppProvider({ children }) { setGovLoading(false) }, [model, pat, govLoading]) + // Advanced analytics — parallel batches of 5 (Section 3.2.5) + const runAdvanceAnalytics = useCallback(async () => { + if (!model || advanceAnalyticsLoading) return + setAdvanceAnalyticsLoading(true) + const map = {} + const repos = model.totalRepos; + + // Batches of 5 using Promise.allSettled + for (let i = 0; i < repos.length; i += 5) { + const batch = repos.slice(i, i + 5) + await Promise.allSettled(batch.map(async repo => { + map[`${repo.orgLogin}/${repo.name}`] = await fetchPulls(repo.orgLogin, repo.name, pat) + })) + } + setPullsData(map) + setAdvanceAnalyticsLoading(false) + }, [model, pat, advanceAnalyticsLoading]) + const STALE_DAYS = 90 const staleRepoStats = useMemo(() => { @@ -187,9 +207,9 @@ export function AppProvider({ children }) { return ( {children} diff --git a/src/hooks/useSortedData.js b/src/hooks/useSortedData.js index 4819f3e..5136c87 100644 --- a/src/hooks/useSortedData.js +++ b/src/hooks/useSortedData.js @@ -20,3 +20,52 @@ export function useSortedData(data = [], defaultKey = 'healthScore', defaultDir return { sorted, sortConfig: cfg, onSort } } + + +export function useAdvancedMetrics(pulls = []) { + return useMemo(() => { + if (!pulls.length) { + return { + avgMergeDays: 0, + acceptanceRate: 0, + merged: 0, + rejected: 0 + } + } + + const mergedPRs = pulls.filter(pr => pr.merged_at) + + const rejectedPRs = pulls.filter( + pr => pr.state === 'closed' && !pr.merged_at + ) + + const totalMergeTime = mergedPRs.reduce((sum, pr) => { + return ( + sum + + (new Date(pr.merged_at).getTime() - + new Date(pr.created_at).getTime()) + ) + }, 0) + + const avgMergeDays = + mergedPRs.length === 0 + ? 0 + : totalMergeTime / + mergedPRs.length / + (1000 * 60 * 60 * 24) + + const acceptanceRate = + mergedPRs.length + rejectedPRs.length === 0 + ? 0 + : (mergedPRs.length / + (mergedPRs.length + rejectedPRs.length)) * + 100 + + return { + avgMergeDays, + acceptanceRate, + merged: mergedPRs.length, + rejected: rejectedPRs.length + } + }, [pulls]) +} diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx index 629b454..7a7042e 100644 --- a/src/pages/AnalyticsPage.jsx +++ b/src/pages/AnalyticsPage.jsx @@ -1,12 +1,13 @@ import React, { useState, useMemo } from 'react' -import { - AreaChart, Area, XAxis, YAxis, CartesianGrid, - Tooltip, Legend, ResponsiveContainer, -} from 'recharts' +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts' import { FiDownload, FiRefreshCw } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, PageTitle, InfoBox } from '../components/UI' import { buildTimeSeries, exportTrendsCSV } from '../services/analytics' +import { FaCodeBranch } from 'react-icons/fa' +import { IoChevronDown } from 'react-icons/io5' +import { HiCheck, HiOutlineClock } from 'react-icons/hi' +import { useAdvancedMetrics } from '../hooks/useSortedData' const TOOLTIP_STYLE = { contentStyle: { @@ -20,9 +21,10 @@ const TOOLTIP_STYLE = { } export default function AnalyticsPage() { - const { model, issuesData, runAudit, govLoading } = useApp() + const { model, issuesData, runAudit, govLoading, runAdvanceAnalytics, advanceAnalyticsLoading, pullsData } = useApp() const [granularity, setGranularity] = useState('monthly') - const [selectedRepo, setSelectedRepo] = useState('All') + const [selectedRepo, setSelectedRepo] = useState('All') + const [selectedRepoForAM, setSelectedRepoForAM] = useState("All Repositories"); const allIssues = useMemo(() => { const arr = [] @@ -41,13 +43,53 @@ export default function AnalyticsPage() { [filteredIssues, granularity] ) + const filteredPulls = useMemo(() => { + if (selectedRepoForAM === 'All Repositories') return Object.values(pullsData || {}).flat() + const key = Object.keys(pullsData || {}).find(k => k.split('/')[1] === selectedRepoForAM) + return key ? (pullsData[key] || []) : [] + }, [pullsData, selectedRepoForAM]) + if (!model) return null + const advancedMetrics = useAdvancedMetrics(filteredPulls) + + const acceptanceChart = [ + { + name: 'Merged', + value: advancedMetrics.merged + }, + { + name: 'Rejected', + value: advancedMetrics.rejected + } + ] + const repoNames = ['All', ...model.allRepos.slice(0, 12).map(r => r.name)] - const hasData = Object.keys(issuesData || {}).length > 0 + const allRepoNames = ['All Repositories', ...model.totalRepos.map(r => r.name)]; + const hasData = Object.keys(issuesData || {}).length > 0 + const hasPullsData = Object.keys(pullsData || {}).length > 0 const hasSeries = series.length > 0 + const MAX_DAYS = 14 + + const mergeGauge = [ + { + value: Math.min( + (advancedMetrics.avgMergeDays / MAX_DAYS) * 100, + 100 + ) + } + ] + + const getMergeColor = days => { + if (days <= 5) return 'var(--green)' + if (days <= 10) return 'var(--accent)' + if (days <= 15) return 'var(--amber)' + return 'var(--red)' + } + return ( + <>
- - - + + +
@@ -153,8 +195,8 @@ export default function AnalyticsPage() { - - + + @@ -170,5 +212,308 @@ export default function AnalyticsPage() { )} + +
+ + + {/* Controls */} +
+ {/* Repository Selection */} +
+ + + + +
+ + {!hasPullsData && ( + + )} +
+ + {hasPullsData && + (
+
+
+
+ +
+
+
+ Average PR Merge Time +
+ + {advancedMetrics.avgMergeDays.toFixed(1)} + + +  Days + +
+
+ + + + + + + + +
+
+ {advancedMetrics.avgMergeDays.toFixed(1)} +
+ +
+ Days +
+ +
+ {advancedMetrics.avgMergeDays <= 2 + ? 'Excellent' + : advancedMetrics.avgMergeDays <= 5 + ? 'Good' + : advancedMetrics.avgMergeDays <= 10 + ? 'Slow' + : 'Very Slow'} +
+
+ +
+ Based on {advancedMetrics.merged} merged pull requests +
+
+ +
+
+
+ +
+
+
+ Pull Request Acceptance Rate +
+ +
+ + {advancedMetrics.acceptanceRate.toFixed(1)} + + +  % + +
+
+
+ +
+ {advancedMetrics.merged} merged ·{' '} + {advancedMetrics.rejected} rejected +
+ + + `${name}: ${(percent * 100).toFixed(0)}%`} + blendStroke="var(--surface)" + > + + + + + + + + + +
+
+ )} + +
+ ) } diff --git a/src/services/github.js b/src/services/github.js index e2bda91..5e21d48 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -120,6 +120,17 @@ export async function fetchIssues(org, repo, pat) { return all } +export async function fetchPulls(org, repo, pat) { + const all = [] + for(let page = 1; ; page++) { + const url = `https://api.github.com/repos/${org}/${repo}/pulls?state=all&per_page=100&page=${page}` + const data = await fetchWithCache(url, pat) + all.push(...data) + if(data.length < 100) break + } + return all +} + export async function fetchRateLimit(pat) { try { const headers = { Accept: 'application/vnd.github.v3+json' }