import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react' import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github' import { buildAnalyticalModel, getTopRepositories } from '../services/analytics' import { saveAnalysis, loadAnalysis } from '../services/cache' const Ctx = createContext(null) function getStoredRateLimit() { const stored = localStorage.getItem('oe_rate_limit') if (!stored) return null try { const data = JSON.parse(stored) if (Date.now() > data.reset * 1000) { localStorage.removeItem('oe_rate_limit') return null } return data } catch { localStorage.removeItem('oe_rate_limit') return null } } export function AppProvider({ children }) { const [pat, setPat] = useState(() => localStorage.getItem('oe_pat') || '') 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); const [advanceAnalyticsComplete, setAdvanceAnalyticsComplete] = useState(false) const [isComplete, setIsComplete] = useState(false) const [auditComplete, setAuditComplete] = useState(false) const [lastOrgNames, setLastOrgNames] = useState([]) // True until the cached analysis has been read, so routes that need a model // wait for the restore instead of bouncing to the picker on first paint. const [hydrating, setHydrating] = useState(true) // Set when state came straight from the cache, so the write-back effect can // skip it. Re-saving an untouched restore would stamp a fresh savedAt on // every page load and the entry would never reach its TTL. const restoredFromCache = useRef(false) const patRequestId = useRef(0) // Restore the last analysis on startup. The model is held in memory, so // without this a reload, bookmark or shared link loses it entirely. useEffect(() => { let cancelled = false loadAnalysis() .then(cached => { if (cancelled || !cached) return restoredFromCache.current = true setOrgs(cached.orgs || []) setModel(cached.model) setTotalRepo(cached.totalRepo || 0) setIsComplete(!!cached.isComplete) setLastOrgNames(cached.lastOrgNames || []) setIssuesData(cached.issuesData || {}) setPullsData(cached.pullsData || {}) setAuditComplete(!!cached.auditComplete) setAdvanceAnalyticsComplete(!!cached.advanceAnalyticsComplete) }) .finally(() => { if (!cancelled) setHydrating(false) }) return () => { cancelled = true } }, []) // Persist the analysis whenever it changes, including audit and analytics // results — those are the most expensive data to refetch. useEffect(() => { if (hydrating || !model) return // Skip the write that would immediately follow a restore. if (restoredFromCache.current) { restoredFromCache.current = false return } saveAnalysis({ orgs, model, totalRepo, isComplete, lastOrgNames, issuesData, pullsData, auditComplete, advanceAnalyticsComplete }) }, [ hydrating, orgs, model, totalRepo, isComplete, lastOrgNames, issuesData, pullsData, auditComplete, advanceAnalyticsComplete ]) useEffect(() => { const handler = e => { setRateLimit(e.detail) localStorage.setItem('oe_rate_limit', JSON.stringify(e.detail)) } window.addEventListener('rate-limit-update', handler) return () => { window.removeEventListener('rate-limit-update', handler) } }, []) useEffect(() => { if (!rateLimit?.reset) return const timeout = setTimeout(() => { localStorage.removeItem('oe_rate_limit') setRateLimit(null) }, Math.max(0, rateLimit.reset * 1000 - Date.now())) return () => clearTimeout(timeout) }, [rateLimit]) const refreshRateLimit = useCallback(async () => { const requestId = patRequestId.current const rl = await fetchRateLimit(pat) if (requestId !== patRequestId.current) return null if (rl) { setRateLimit(rl) return true } return false }, [pat]) const savePat = useCallback(async token => { const requestId = ++patRequestId.current setPat(token) if (token) { localStorage.setItem('oe_pat', token) const rl = await fetchRateLimit(token) // A newer save or delete started while this request was in flight. if (requestId !== patRequestId.current) return if (rl) { setRateLimit(rl) localStorage.setItem('oe_rate_limit', JSON.stringify(rl)) } } else { localStorage.removeItem('oe_pat') localStorage.removeItem('oe_rate_limit') setRateLimit(null) } }, []) // Multi-org explore const explore = useCallback(async orgNames => { // Trim and dedupe case-insensitively: `AOSSIE-Org` and `aossie-org` // are the same org on GitHub and must not trigger duplicate fetches. const seen = new Set() const names = [] for (const n of orgNames) { const trimmed = String(n).trim() const key = trimmed.toLowerCase() if (trimmed && !seen.has(key)) { seen.add(key) names.push(trimmed) } } if (!names.length) return false setLoading(true); setError(''); setModel(null); setOrgs([]); setIssuesData({}); setLastOrgNames(names); setAuditComplete(false); setAdvanceAnalyticsComplete(false); try { setLoadMsg('Fetching organization metadata...') const orgRes = await Promise.allSettled(names.map(n => fetchOrg(n, pat))) const rateLimitError = orgRes.find(r => r.status === 'rejected' && r.reason?.message === 'RATE_LIMIT') if (rateLimitError) { throw new Error('RATE_LIMIT') } const unexpectedError = orgRes.find(r => r.status === 'rejected' && r.reason?.message !== 'NOT_FOUND') if (unexpectedError) { throw (unexpectedError.reason || new Error('Failed to fetch organization data.')) } const validOrgs = orgRes.filter(r => r.status === 'fulfilled').map(r => r.value) if (!validOrgs.length) throw new Error('No valid organizations found. Check the names and try again.') setOrgs(validOrgs) setLoadMsg('Fetching repositories...') const reposPerOrg = {} const repoRes = await Promise.allSettled(validOrgs.map(async org => { const repos = await fetchRepos(org.login, org.public_repos, pat) reposPerOrg[org.login] = repos return repos })) const repoRateLimit = repoRes.find(r => r.status === 'rejected' && r.reason?.message === 'RATE_LIMIT') if (repoRateLimit) { throw new Error('RATE_LIMIT') } const total = Object.values(reposPerOrg).reduce((sum, repos) => sum + (repos ? repos.length : 0), 0); setTotalRepo(total); const totalReposPerOrg = Object.fromEntries( Object.entries(reposPerOrg).map(([org, repos]) => [org, [...(repos || [])]]) ); setLoadMsg('Fetching contributor data for top repositories...') const contribsPerRepo = {} for (const org of validOrgs) { const top = pat ? (reposPerOrg[org.login] || []) : getTopRepositories(reposPerOrg[org.login] || [], 10); reposPerOrg[org.login] = top; const contribRes = await Promise.allSettled(top.map(async repo => { const contribs = await fetchContributors(org.login, repo.name, pat) contribsPerRepo[`${org.login}/${repo.name}`] = contribs return contribs })) const contribRateLimit = contribRes.find(r => r.status === 'rejected' && r.reason?.message === 'RATE_LIMIT') if (contribRateLimit) { throw new Error('RATE_LIMIT') } } setLoadMsg('Building analytical data model...') const builtModel = buildAnalyticalModel(validOrgs, reposPerOrg, contribsPerRepo, totalReposPerOrg) setModel(builtModel) setIsComplete(!!pat) // Save to recent searches, canonicalized to the official login // casing returned by GitHub and deduplicated case-insensitively, so // `AOSSIE-Org` and `aossie-org` share one entry (and one cache key). const prev = JSON.parse(localStorage.getItem('oe_recent') || '[]') const entry = validOrgs.map(o => o.login).join(', ') const entryKey = entry.toLowerCase() const next = [entry, ...prev.filter(r => String(r).toLowerCase() !== entryKey)].slice(0, 6) localStorage.setItem('oe_recent', JSON.stringify(next)) return builtModel } catch (err) { setError(err.message === 'RATE_LIMIT' ? (pat ? 'GitHub API rate limit reached. Please wait for your quota to refresh.' : 'GitHub API rate limit reached. Add a PAT in Settings for 5,000 req/hr.') : err.message) return false } finally { setLoading(false); setLoadMsg('') } }, [pat]) // re-run explore for the same orgs: used by the banner on // Overview / Contributors / Repositories / Network const runFullExplore = useCallback(() => { if (!lastOrgNames.length) return Promise.resolve(false) return explore(lastOrgNames) }, [explore, lastOrgNames]) const selectAnalysisRepos = useCallback((allRepos) => { const byOrg = {} for (const repo of allRepos) { (byOrg[repo.orgLogin] ??= []).push(repo) } return Object.values(byOrg).flatMap(orgRepos => pat ? orgRepos : getTopRepositories(orgRepos, 10) ) }, [pat]) // Shared issue-fetch logic: same repo-selection rule as contributors const auditRepos = useCallback(async (allRepos) => { const repos = selectAnalysisRepos(allRepos) const map = {} 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 fetchIssues(repo.orgLogin, repo.name, pat) })) } return map }, [pat, selectAnalysisRepos]) // Governance audit : used directly when repos are already complete const runAudit = useCallback(async () => { if (!model || govLoading) return setGovLoading(true) const map = await auditRepos(model.allRepos) setIssuesData(map) setGovLoading(false) setAuditComplete(!!pat) }, [model, pat, govLoading, auditRepos]) // Entry point for Governance / Analytics "Run Complete Analysis" // - If repos/contributors aren't complete yet -> fetch them first (explore), // then fetch issues using the freshly-returned model (avoids stale closure). // - If already complete -> skip repo fetching (cache/state already has it), // just fetch issues. const runGovernanceAnalysis = useCallback(async () => { if (govLoading) return let currentModel = model if (!isComplete) { setGovLoading(true) // reflect "working" immediately, explore() also sets its own loading const freshModel = await runFullExplore() setGovLoading(false) if (!freshModel) return currentModel = freshModel } if (!currentModel) return setGovLoading(true) const map = await auditRepos(currentModel.allRepos) setIssuesData(map) setGovLoading(false) setAuditComplete(!!pat) }, [isComplete, model, runFullExplore, auditRepos, pat, govLoading]) // Advanced analytics — parallel batches of 5 (Section 3.2.5) // Entry point for Analytics "Run Complete Analysis" // - If repos/contributors aren't complete yet -> fetch them first (explore), // then fetch pulls using the freshly-returned model (avoids stale closure). // - If already complete -> skip repo fetching, just fetch pulls. const runAdvanceAnalytics = useCallback(async () => { if (advanceAnalyticsLoading) return let currentModel = model if (!isComplete) { setAdvanceAnalyticsLoading(true) // reflect "working" immediately const freshModel = await runFullExplore() setAdvanceAnalyticsLoading(false) if (!freshModel) return currentModel = freshModel } if (!currentModel) return setAdvanceAnalyticsLoading(true) const map = {} const repos = selectAnalysisRepos(currentModel.totalRepos) 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) setAdvanceAnalyticsComplete(!!pat) }, [isComplete, model, runFullExplore, selectAnalysisRepos, pat, advanceAnalyticsLoading]) // Combined entry point for the whole Analytics page banner // Runs explore() once if needed, then fetches issues + pulls in parallel const runFullAnalytics = useCallback(async () => { if (govLoading || advanceAnalyticsLoading) return let currentModel = model if (!isComplete) { setGovLoading(true) setAdvanceAnalyticsLoading(true) const freshModel = await runFullExplore() setGovLoading(false) setAdvanceAnalyticsLoading(false) if (!freshModel) return currentModel = freshModel } if (!currentModel) return setGovLoading(true) setAdvanceAnalyticsLoading(true) const [issuesMap, pullsMap] = await Promise.all([ auditRepos(currentModel.allRepos), (async () => { const repos = selectAnalysisRepos(currentModel.totalRepos) const map = {} 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) })) } return map })() ]) setIssuesData(issuesMap) setPullsData(pullsMap) setGovLoading(false) setAdvanceAnalyticsLoading(false) setAuditComplete(!!pat) setAdvanceAnalyticsComplete(!!pat) }, [model, isComplete, runFullExplore, auditRepos, selectAnalysisRepos, pat, govLoading, advanceAnalyticsLoading]) const STALE_DAYS = 90 const staleRepoStats = useMemo(() => { const now = Date.now() return Object.entries(issuesData || {}).map(([key, issues]) => { const [org, repo] = key.split('/') const normalIssues = issues.filter(i => !i.pull_request) const openIssues = normalIssues.filter(i => i.state === 'open') const staleIssues = openIssues.filter(i => { const updated = new Date(i.updated_at).getTime() const diffDays = (now - updated) / (1000 * 60 * 60 * 24) return diffDays >= STALE_DAYS }) const ratio = openIssues.length === 0 ? 0 : Math.round((staleIssues.length / openIssues.length) * 100) return { id: key, org, repo, ratio, staleCount: staleIssues.length, openCount: openIssues.length } }).sort((a, b) => b.ratio - a.ratio) }, [issuesData]) return ( {children} ) } export const useApp = () => useContext(Ctx)