diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index 61027be..ca37de5 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useState, useCallback, useEffect } from 'react'
+import { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'
import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, } from '../services/github'
import { buildAnalyticalModel, getTopRepositories } from '../services/analytics'
@@ -129,7 +129,7 @@ export function AppProvider({ children }) {
if (!model || govLoading) return
setGovLoading(true)
const map = {}
- const repos = pat? model.allRepos : model.allRepos.slice(0, 15)
+ const repos = pat? model.totalRepos : model.totalRepos.slice(0, 15)
// Batches of 5 using Promise.allSettled
for (let i = 0; i < repos.length; i += 5) {
@@ -142,11 +142,45 @@ export function AppProvider({ children }) {
setGovLoading(false)
}, [model, pat, govLoading])
+ 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}
diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx
index daceb66..76a290c 100644
--- a/src/pages/GovernancePage.jsx
+++ b/src/pages/GovernancePage.jsx
@@ -6,14 +6,52 @@ import { C, PageTitle, EmptyOk } from '../components/UI'
const TABS = [
{ key: 'dead', label: 'Dead Issues' },
{ key: 'zombie', label: 'Zombie PRs' },
- { key: 'risky', label: 'Risky Repos' },
+ { 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 } = useApp()
+ const { model, issuesData, runAudit, govLoading, staleRepoStats } = useApp()
const [tab, setTab] = useState('dead')
+ const ITEMS_PER_PAGE = 10
+ const [stalePage, setStalePage] = useState(1)
+ const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE)
+
+ const paginatedStaleRepos = useMemo(() => {
+ const start = (stalePage - 1) * ITEMS_PER_PAGE
+ return staleRepoStats.slice(start, start + ITEMS_PER_PAGE)
+ }, [staleRepoStats, stalePage])
+ console.log(paginatedStaleRepos);
// Flatten all issues and tag with repo/org
const allIssues = useMemo(() => {
const arr = []
@@ -34,28 +72,21 @@ export default function GovernancePage() {
.filter(i => !i.pull_request && daysSince(i.created_at) >= 90)
.sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at))
- // Health check 2 — Zombie PRs (>90 days open)
+ // Health check 2 — Percentage of dead issues relative to all issues
+ const staleIssuesRatio = allIssues.length ? (deadIssues.length / allIssues.length) * 100 : 0;
+
+ // Health check 3 — Zombie PRs (>90 days open)
const zombiePRs = allIssues
.filter(i => i.pull_request && daysSince(i.created_at) >= 90)
.sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at))
- // Health check 3 — Risky repos (top-2 contributor concentration >80%)
- const riskyRepos = model.allRepos.filter(r => {
- const c = r.contributors || []
- if (!c.length) return false
- const total = c.reduce((s, x) => s + x.contributions, 0)
- if (!total) return false
- const topTwo = (c[0]?.contributions || 0) + (c[1]?.contributions || 0)
- return topTwo / total > 0.8
- })
-
// Health check 4 — No license
const noLicense = model.allRepos.filter(r => !r.license && !r.archived && !r.fork)
// Issue resolution rate per repo
const topRepos = model.allRepos.slice(0, 8)
- const counts = { dead: deadIssues.length, zombie: zombiePRs.length, risky: riskyRepos.length, license: noLicense.length }
+ const counts = { dead: deadIssues.length, zombie: zombiePRs.length, license: noLicense.length, stale: staleIssuesRatio.toFixed(2) }
// Stat card
const StatBox = ({ label, value, sub, color }) => (
@@ -124,8 +155,8 @@ export default function GovernancePage() {
{/* Summary stat cards */}
+
-
@@ -182,7 +213,7 @@ export default function GovernancePage() {
}}
>
{t.label}{' '}
- 0 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
+ 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}>
{counts[t.key]}
@@ -213,28 +244,108 @@ export default function GovernancePage() {
) :
)}
- {/* Risky Repos */}
- {tab === 'risky' && (
- riskyRepos.length ? (
-
- {riskyRepos.map(r => {
- const c = r.contributors || []
- const total = c.reduce((s, x) => s + x.contributions, 0) || 1
- const pct = Math.round(((c[0]?.contributions || 0) + (c[1]?.contributions || 0)) / total * 100)
- return (
-
-
-
{r.name}
-
- Top 2 contributors own {pct}% of all commits — concentration risk
+ {/* Stale Issues */}
+ {tab === 'stale' && (
+ staleRepoStats.length ? (
+ <>
+
+ {paginatedStaleRepos.map(repo => {
+ const status = getStatus(repo.ratio)
+
+ return (
+
+
+
+ {repo.repo}
+
+
+
+ {repo.staleCount} stale issues out of{' '}
+ {repo.openCount} open issues ({repo.ratio}%)
+
+
+
+
-
HIGH RISK
-
- )
- })}
-
- ) :
+ )
+ })}
+
+
+ {totalPages > 1 && (
+
+
+
+
+ Page {stalePage} of {totalPages}
+
+
+
+
+ )}
+ >
+ ) : (
+
+ )
)}
{/* No License */}