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 }) => (
)
// 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 (
← Previous
Page {page} of {totalPages}
Next →
)
}
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... }
{hasAudit ? 'Re-run Audit' : 'Run Audit'}
}
/>
{/* Primary Navigation Tabs */}
setViewMode('scorecard')}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: viewMode === 'scorecard' ? 'var(--text)' : 'var(--text2)',
fontWeight: viewMode === 'scorecard' ? 600 : 400,
fontSize: 14, padding: '8px 16px',
borderBottom: viewMode === 'scorecard' ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
🏥 Health Scorecard & Risk Advisor
setViewMode('audit')}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: viewMode === 'audit' ? 'var(--text)' : 'var(--text2)',
fontWeight: viewMode === 'audit' ? 600 : 400,
fontSize: 14, padding: '8px 16px',
borderBottom: viewMode === 'audit' ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
📋 Structural Audit Details
{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}%`}
)
})}
{!hasAudit && (
Run the audit to populate resolution rates with live issue data.
)}
{/* Tabbed detail view */}
{TABS.map(t => (
setTab(t.key)}
style={{
background: 'none', border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
color: tab === t.key ? 'var(--text)' : 'var(--text2)',
fontWeight: tab === t.key ? 600 : 400,
fontSize: 13, padding: '6px 12px',
borderBottom: tab === t.key ? '2px solid var(--accent)' : '2px solid transparent',
}}
>
{t.label}{' '}
40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
))}
All Organizations
{orgs.map(o => {o.login} )}
{/* 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}%)
)
})}
setStalePage(p => p - 1)} onNext={() => setStalePage(p => p + 1)} />
>
)
)}
{/* No License */}
{tab === 'license' && (
scopedLicense.length === 0 ? (
) : filteredLicense.length === 0 ? (
noMatch
) : (
{filteredLicense.map(r => (
))}
)
)}
>
)}
)
}