import React, { useState, useMemo, useEffect } from 'react' import { FiRefreshCw, FiExternalLink, FiSearch } from 'react-icons/fi' import { useApp } from '../context/AppContext' import { C, PageTitle, EmptyOk } from '../components/UI' import EmptyStateCard from '../components/EmptyStateCard' import AnalysisBanner from '../components/AnalysisBanner' import { GovernanceSkeleton } from '../components/Orgexplorerskeletons' import HealthScorecard from '../components/HealthScorecard' const TABS = [ { key: 'dead', label: 'Dead Issues' }, { key: 'zombie', label: 'Zombie PRs' }, { key: 'stale', label: 'Stale Issues Ratio' }, { key: 'license', label: 'No License' }, ] const getStatus = ratio => { if (ratio <= 10) return { label: 'Excellent', color: 'var(--green)', bg: 'rgba(34,197,94,.12)' } if (ratio <= 25) return { label: 'Healthy', color: 'var(--amber)', bg: 'rgba(250,204,21,.12)' } if (ratio <= 40) return { label: 'Warning', color: 'var(--orange)', bg: 'rgba(251,146,60,.12)' } return { label: 'Critical', color: 'var(--red)', bg: 'rgba(239,68,68,.12)' } } export default function GovernancePage() { const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis,staleRepoStats } = useApp() const [viewMode, setViewMode] = useState('scorecard') // 'scorecard' | 'audit' const [tab, setTab] = useState('dead') const [orgFilter, setOrgFilter] = useState('All Organizations') const [search, setSearch] = useState('') const ITEMS_PER_PAGE = 10 const [stalePage, setStalePage] = useState(1) const [deadPage, setDeadPage] = useState(1) const [zombiePage, setZombiePage] = useState(1) const query = search.trim().toLowerCase() const handleSearchChange = e => { setSearch(e.target.value) setDeadPage(1) setZombiePage(1) setStalePage(1) } const handleOrgChange = e => { setOrgFilter(e.target.value) setDeadPage(1) setZombiePage(1) setStalePage(1) } useEffect(() => { setDeadPage(1) setZombiePage(1) setStalePage(1) }, [issuesData]) // Stale repos: org filter first, then search, then pagination const staleReposForOrg = useMemo(() => { if (orgFilter === 'All Organizations') return staleRepoStats return staleRepoStats.filter(r => r.org === orgFilter) }, [staleRepoStats, orgFilter]) const filteredStale = useMemo(() => { if (!query) return staleReposForOrg return staleReposForOrg.filter(r => `${r.org}/${r.repo}`.toLowerCase().includes(query)) }, [staleReposForOrg, query]) const totalPages = Math.ceil(filteredStale.length / ITEMS_PER_PAGE) const paginatedStaleRepos = useMemo(() => { const start = (stalePage - 1) * ITEMS_PER_PAGE return filteredStale.slice(start, start + ITEMS_PER_PAGE) }, [filteredStale, stalePage]) // Flatten all issues and tag with repo/org const allIssues = useMemo(() => { const arr = [] Object.entries(issuesData || {}).forEach(([key, issues]) => { const [org, repo] = key.split('/') issues.forEach(i => arr.push({ ...i, repoName: repo, orgName: org })) }) return arr }, [issuesData]) if (loading) return if (!model) return null const hasAudit = Object.keys(issuesData || {}).length > 0 const daysSince = d => Math.floor((Date.now() - new Date(d)) / 86_400_000) const matchesQuery = text => !query || text.toLowerCase().includes(query) const issuesOfOrg = orgFilter === 'All Organizations' ? allIssues : allIssues.filter(i => i.orgName === orgFilter) // Health check 1 — Dead Issues (>90 days open, not a PR) const deadIssues = allIssues .filter(i => !i.pull_request && i.state === 'open' && daysSince(i.created_at) >= 90) .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)) const deadIssuesOfOrg = (org) => { if (org === 'All Organizations') { return deadIssues } return deadIssues.filter(issue => issue.orgName === org) } // Health check 2 — Percentage of dead issues relative to all issues const staleIssuesRatio = allIssues.length ? (deadIssues.length / allIssues.length) * 100 : 0 const staleIssuesRatioOfOrg = (org) => { if (org === 'All Organizations') { return staleIssuesRatio } const issueOfOrg = allIssues.filter(i => i.orgName === org) return issueOfOrg.length ? (deadIssuesOfOrg(org).length / issueOfOrg.length) * 100 : 0 } // Health check 3 — Zombie PRs (>90 days open) const zombiePRs = allIssues .filter(i => i.pull_request && i.state === 'open' && daysSince(i.created_at) >= 90) .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)) const zombiePRsOfOrg = (org) => { if (org === 'All Organizations') { return zombiePRs } return zombiePRs.filter(pr => pr.orgName === org) } // Health check 4 — No license const noLicense = model.allRepos.filter(r => !r.license && !r.archived && !r.fork) const noLicenseOfOrg = (org) => { if (org === 'All Organizations') { return noLicense } return noLicense.filter(r => r.orgLogin === org) } // Org-scoped lists (org filter applied first) const scopedDead = deadIssuesOfOrg(orgFilter) const scopedZombie = zombiePRsOfOrg(orgFilter) const scopedLicense = noLicenseOfOrg(orgFilter) // Search-filtered lists (client-side, uses already-fetched data) const filteredDead = scopedDead.filter(i => matchesQuery(`${i.orgName}/${i.repoName}`)) const filteredZombie = scopedZombie.filter(i => matchesQuery(`${i.orgName}/${i.repoName}`)) const filteredLicense = scopedLicense.filter(r => matchesQuery(`${r.orgLogin}/${r.name}`)) const deadTotalPages = Math.ceil(filteredDead.length / ITEMS_PER_PAGE) const paginatedDeadIssues = filteredDead.slice((deadPage - 1) * ITEMS_PER_PAGE, deadPage * ITEMS_PER_PAGE) const zombieTotalPages = Math.ceil(filteredZombie.length / ITEMS_PER_PAGE) const paginatedZombiePRs = filteredZombie.slice((zombiePage - 1) * ITEMS_PER_PAGE, zombiePage * ITEMS_PER_PAGE) // Issue resolution rate per repo const topRepos = model.allRepos.slice(0, 8) const counts = { dead: scopedDead.length, zombie: scopedZombie.length, license: scopedLicense.length, stale: staleIssuesRatioOfOrg(orgFilter).toFixed(2) } const tabTotals = { dead: scopedDead.length, zombie: scopedZombie.length, stale: staleReposForOrg.length, license: scopedLicense.length } const tabMatches = { dead: filteredDead.length, zombie: filteredZombie.length, stale: filteredStale.length, license: filteredLicense.length } // Stat card const StatBox = ({ label, value, sub, color }) => (
{label}
{value}
{sub}
) // Issue / PR row const IssueRow = ({ item, i }) => (
{item.title}
#{item.number} · @{item.user?.login}
{item.repoName} {daysSince(item.created_at)} DAYS {item.html_url && ( GitHub )} ) const TableHead = () => ( {['TITLE / ID', 'REPOSITORY', 'AGE', 'ACTION'].map(h => ( {h} ))} ) const Pagination = ({ page, totalPages, onPrev, onNext }) => { if (totalPages <= 1) return null return (
Page {page} of {totalPages}
) } const noMatch = (
} title="No matching repositories" description={`No results match "${search.trim()}" in this tab. Try a different repository or organization name.`} buttonText="Clear Search" onButtonClick={() => setSearch('')} />
) return (
{govLoading && Running audit...}
} /> {/* Primary Navigation Tabs */}
{viewMode === 'scorecard' ? ( ) : ( <> {/* Summary stat cards */}
{/* Issue Resolution Rate */}
Issue Resolution Rate
Resolution velocity across key repositories
{topRepos.map(r => { const repoIssues = allIssues.filter(i => i.repoName === r.name) const closed = repoIssues.filter(i => i.state === 'closed').length const total = repoIssues.length const rate = total ? Math.round(closed / total * 100) : null const color = rate === null ? 'var(--text3)' : rate >= 70 ? 'var(--green)' : rate >= 30 ? 'var(--amber)' : 'var(--red)' return (
{r.name} {r.orgLogin}
{rate === null ? 'No data' : `${rate}%`}
{rate !== null && (
)}
) })} {!hasAudit && (
Run the audit to populate resolution rates with live issue data.
)}
{/* Tabbed detail view */}
{TABS.map(t => ( ))}
{/* Repository search */} {tabTotals[tab] > 0 && (
{query && ( {tabMatches[tab]} of {tabTotals[tab]} matching )}
)} {/* Dead Issues */} {tab === 'dead' && ( scopedDead.length === 0 ? ( ) : filteredDead.length === 0 ? ( noMatch ) : ( <>
{paginatedDeadIssues.map((item, i) => )}
setDeadPage(p => p - 1)} onNext={() => setDeadPage(p => p + 1)} /> ) )} {/* Zombie PRs */} {tab === 'zombie' && ( scopedZombie.length === 0 ? ( ) : filteredZombie.length === 0 ? ( noMatch ) : ( <>
{paginatedZombiePRs.map((item, i) => )}
setZombiePage(p => p - 1)} onNext={() => setZombiePage(p => p + 1)} /> ) )} {/* Stale Issues */} {tab === 'stale' && ( staleReposForOrg.length === 0 ? ( ) : filteredStale.length === 0 ? ( noMatch ) : ( <>
{paginatedStaleRepos.map(repo => { const status = getStatus(repo.ratio) return (
{repo.repo}
{repo.staleCount} stale issues out of{' '} {repo.openCount} open issues ({repo.ratio}%)
{status.label} GitHub
) })}
setStalePage(p => p - 1)} onNext={() => setStalePage(p => p + 1)} /> ) )} {/* No License */} {tab === 'license' && ( scopedLicense.length === 0 ? ( ) : filteredLicense.length === 0 ? ( noMatch ) : (
{filteredLicense.map(r => (
{r.name}
{r.orgLogin}
NO LICENSE
))}
) )}
)}
) }