diff --git a/src/components/HealthScorecard.jsx b/src/components/HealthScorecard.jsx
new file mode 100644
index 0000000..6d10d5c
--- /dev/null
+++ b/src/components/HealthScorecard.jsx
@@ -0,0 +1,321 @@
+import React, { useState, useMemo } from 'react'
+import { FiAlertTriangle, FiCheckCircle, FiInfo, FiExternalLink, FiShield, FiTrendingUp, FiUsers, FiFileText } from 'react-icons/fi'
+import { C, EmptyOk } from './UI'
+import { computeOrgHealthSummary } from '../services/healthScorecard'
+import { classifyRepositoryRisk, generateRiskRecommendations } from '../services/riskAdvisor'
+
+export default function HealthScorecard({ model, issuesData = {}, hasAudit = false, onRunAudit, govLoading = false }) {
+ const [filter, setFilter] = useState('all') // 'all' | 'critical' | 'warning' | 'healthy'
+
+ const summary = useMemo(() => computeOrgHealthSummary(model, issuesData, hasAudit), [model, issuesData, hasAudit])
+ const recommendations = useMemo(() => generateRiskRecommendations(model, issuesData), [model, issuesData])
+
+ // Classify all repositories
+ const repoRisks = useMemo(() => {
+ if (!model || !model.allRepos) return []
+ return model.allRepos.map(repo => {
+ const risk = classifyRepositoryRisk(repo, issuesData)
+ return {
+ ...repo,
+ riskTier: risk.tier,
+ mainIssue: risk.mainIssue,
+ score: repo.healthScore ?? 50,
+ }
+ })
+ }, [model, issuesData])
+
+ const counts = useMemo(() => {
+ const crit = repoRisks.filter(r => r.riskTier === 'critical').length
+ const warn = repoRisks.filter(r => r.riskTier === 'warning').length
+ const heal = repoRisks.filter(r => r.riskTier === 'healthy').length
+ return { critical: crit, warning: warn, healthy: heal }
+ }, [repoRisks])
+
+ const filteredRepos = useMemo(() => {
+ if (filter === 'all') return repoRisks
+ return repoRisks.filter(r => r.riskTier === filter)
+ }, [repoRisks, filter])
+
+ if (!model || !model.allRepos || model.allRepos.length === 0) {
+ return
+ }
+
+ // Grade color map
+ const gradeColors = {
+ 'A+': { color: 'var(--green)', bg: 'rgba(34,197,94,.15)', border: 'rgba(34,197,94,.4)' },
+ 'A': { color: 'var(--green)', bg: 'rgba(34,197,94,.12)', border: 'rgba(34,197,94,.3)' },
+ 'B': { color: 'var(--blue)', bg: 'rgba(59,130,246,.12)', border: 'rgba(59,130,246,.3)' },
+ 'C': { color: 'var(--amber)', bg: 'rgba(245,158,11,.12)', border: 'rgba(245,158,11,.3)' },
+ 'D': { color: 'var(--orange)', bg: 'rgba(249,115,22,.12)', border: 'rgba(249,115,22,.3)' },
+ 'F': { color: 'var(--red)', bg: 'rgba(239,68,68,.12)', border: 'rgba(239,68,68,.3)' },
+ }
+
+ const currentGradeStyle = gradeColors[summary.grade] || gradeColors['C']
+
+ const DimensionBar = ({ label, score, icon: Icon, isPendingAudit = false }) => (
+
+
+
+
+ {label}
+
+
+ {isPendingAudit ? (
+
+ Insufficient Data (Audit Pending)
+
+ ) : (
+ = 70 ? 'var(--green)' : score >= 50 ? 'var(--amber)' : 'var(--red)' }}>
+ {score} / 100
+
+ )}
+
+
+
+ {!isPendingAudit && (
+
= 70 ? 'var(--green)' : score >= 50 ? 'var(--amber)' : 'var(--red)',
+ borderRadius: 4,
+ transition: 'width 0.4s ease',
+ }}
+ />
+ )}
+
+
+ )
+
+ return (
+
+ {/* 1. Header Card: Grade & Risk Counter Chips */}
+
+
+
+ {summary.grade}
+ GRADE
+
+
+
+ Organization Health
+
+
+ {summary.score} / 100
+
+
+ Evaluated across {summary.totalRepos} repositories
+
+
+
+
+ {/* Risk Counter Chips */}
+
+
+ ORGANIZATION RISK CLASSIFICATION
+
+
+
+
+
+
+
+
+
+
+
+ {/* 2. Health Dimensions Panel */}
+
+
Health Dimensions
+
Key performance factors shaping the organization score
+
+
+
+
+
+
+ {!hasAudit && onRunAudit && (
+
+ Run issue audit to populate Issue & PR Resolution Health score.
+
+
+ )}
+
+
+ {/* 3. ⚡ Risk Advisor Panel */}
+
+
+ ⚡ Risk Advisor
+
+
Automated priority recommendations for org maintainers
+
+
+ {recommendations.map(rec => {
+ const isCrit = rec.severity === 'critical'
+ const isWarn = rec.severity === 'warning'
+ const borderColor = isCrit ? 'var(--red)' : isWarn ? 'var(--amber)' : 'var(--green)'
+ const bg = isCrit ? 'rgba(239,68,68,.06)' : isWarn ? 'rgba(245,158,11,.06)' : 'rgba(34,197,94,.06)'
+
+ return (
+
+
+
+ {isCrit && }
+ {isWarn && }
+ {!isCrit && !isWarn && }
+ {rec.title}
+
+ {rec.htmlUrl && (
+
+ {rec.action}
+
+ )}
+
+
+ {rec.description}
+
+
+ )
+ })}
+
+
+
+ {/* 4. Repository Risk Table */}
+
+
+
+
Repository Risk Classification
+
Breakdown of repositories filtered by risk signals
+
+
+
+ {[
+ { key: 'all', label: `All (${repoRisks.length})` },
+ { key: 'critical', label: `Critical (${counts.critical})` },
+ { key: 'warning', label: `Warning (${counts.warning})` },
+ { key: 'healthy', label: `Healthy (${counts.healthy})` },
+ ].map(tab => (
+
+ ))}
+
+
+
+ {filteredRepos.length > 0 ? (
+
+
+
+
+ {['REPOSITORY', 'HEALTH SCORE', 'RISK TIER', 'MAIN RISK SIGNAL', 'ACTION'].map(h => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {filteredRepos.map((repo, i) => {
+ const isCrit = repo.riskTier === 'critical'
+ const isWarn = repo.riskTier === 'warning'
+ const badgeColor = isCrit ? 'var(--red)' : isWarn ? 'var(--amber)' : 'var(--green)'
+ const badgeBg = isCrit ? 'rgba(239,68,68,.12)' : isWarn ? 'rgba(245,158,11,.12)' : 'rgba(34,197,94,.12)'
+
+ return (
+
+ |
+ {repo.name}
+ {repo.orgLogin}
+ |
+
+ {repo.score}
+ |
+
+
+ {repo.riskTier.toUpperCase()}
+
+ |
+
+ {repo.mainIssue}
+ |
+
+
+ GitHub
+
+ |
+
+ )
+ })}
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx
index 7a209f6..cd53771 100644
--- a/src/pages/GovernancePage.jsx
+++ b/src/pages/GovernancePage.jsx
@@ -5,6 +5,8 @@ import { C, PageTitle, EmptyOk } from '../components/UI'
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' },
@@ -43,6 +45,7 @@ const getStatus = ratio => {
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 ITEMS_PER_PAGE = 10
@@ -161,14 +164,46 @@ export default function GovernancePage() {
}
/>
- {/* Summary stat cards */}
-
-
-
-
-
+ {/* Primary Navigation Tabs */}
+
+
+
+ {viewMode === 'scorecard' ? (
+
+ ) : (
+ <>
+ {/* Summary stat cards */}
+
+
+
+
+
+
+
{/* Issue Resolution Rate */}
Issue Resolution Rate
@@ -374,6 +409,8 @@ export default function GovernancePage() {
) :
)}
+ >
+ )}
)
}
diff --git a/src/services/healthScorecard.js b/src/services/healthScorecard.js
new file mode 100644
index 0000000..1d8f536
--- /dev/null
+++ b/src/services/healthScorecard.js
@@ -0,0 +1,155 @@
+/**
+ * Health Scorecard calculation service
+ * Computes org-level metrics, dimension scores, and explicit letter grades.
+ */
+
+export function getHealthGrade(score) {
+ if (score >= 95) return 'A+'
+ if (score >= 85) return 'A'
+ if (score >= 70) return 'B'
+ if (score >= 55) return 'C'
+ if (score >= 40) return 'D'
+ return 'F'
+}
+
+export function computeDimensionScores(model, issuesData = {}, hasAudit = false) {
+ if (!model || !model.allRepos || model.allRepos.length === 0) {
+ return {
+ activity: 0,
+ diversity: 0,
+ compliance: 0,
+ issueHealth: null,
+ hasAudit: false,
+ }
+ }
+
+ const repos = model.allRepos
+
+ // 1. Activity Dimension (0-100)
+ // Percentage of active / thriving repos pushed within 90 days
+ const now = Date.now()
+ const recentPushes = repos.filter(r => {
+ if (!r.pushed_at) return false
+ const days = (now - new Date(r.pushed_at)) / 86_400_000
+ return days <= 90
+ }).length
+ const activityScore = Math.round((recentPushes / repos.length) * 100)
+
+ // 2. Maintainer Diversity Dimension (0-100)
+ // Derived directly from bus factor & contributor counts
+ let diversitySum = 0
+ repos.forEach(r => {
+ const bf = r.busFactor?.factor || (r.contributors ? r.contributors.length : 0)
+ const contribCount = r.contributors?.length || 0
+ // Repositories with bus factor < 2 (bus factor 1) are capped at 50 below maximum diversity
+ const repoDiv = bf >= 2
+ ? Math.min(100, 60 + contribCount * 8)
+ : Math.min(50, 20 + contribCount * 8)
+ diversitySum += repoDiv
+ })
+ const diversityScore = Math.round(diversitySum / repos.length)
+
+ // 3. Compliance Dimension (0-100)
+ // License & governance file presence (CODE_OF_CONDUCT, CONTRIBUTING) among non-archived, non-fork repos
+ const validRepos = repos.filter(r => !r.archived && !r.fork)
+ let complianceScore = 100
+ if (validRepos.length > 0) {
+ let repoScoreSum = 0
+ validRepos.forEach(r => {
+ let checksCount = 1
+ let passedCount = Boolean(r.license) ? 1 : 0
+
+ if (r.hasCodeOfConduct !== undefined || r.code_of_conduct !== undefined || r.coc !== undefined) {
+ checksCount++
+ if (r.hasCodeOfConduct || r.code_of_conduct || r.coc) passedCount++
+ }
+ if (r.hasContributing !== undefined || r.contributing !== undefined) {
+ checksCount++
+ if (r.hasContributing || r.contributing) passedCount++
+ }
+
+ repoScoreSum += (passedCount / checksCount)
+ })
+ complianceScore = Math.round((repoScoreSum / validRepos.length) * 100)
+ }
+
+ // 4. Issue / PR Health Dimension (0-100)
+ // Only calculated if audit has been run, otherwise null to indicate Insufficient Data
+ let issueHealthScore = null
+ if (hasAudit && Object.keys(issuesData || {}).length > 0) {
+ const allIssues = []
+ Object.values(issuesData).forEach(issues => {
+ if (Array.isArray(issues)) {
+ issues.forEach(i => allIssues.push(i))
+ }
+ })
+
+ if (allIssues.length > 0) {
+ const closed = allIssues.filter(i => i.state === 'closed').length
+ const resolutionRate = (closed / allIssues.length) * 100
+
+ const daysSince = d => Math.floor((now - new Date(d)) / 86_400_000)
+ const deadCount = allIssues.filter(i => i.state === 'open' && daysSince(i.created_at) >= 90).length
+ const stalePenalty = Math.min(50, (deadCount / allIssues.length) * 100)
+
+ issueHealthScore = Math.max(0, Math.round(resolutionRate * 0.7 + (100 - stalePenalty) * 0.3))
+ } else {
+ issueHealthScore = 100
+ }
+ }
+
+ return {
+ activity: Math.min(100, Math.max(0, activityScore)),
+ diversity: Math.min(100, Math.max(0, diversityScore)),
+ compliance: Math.min(100, Math.max(0, complianceScore)),
+ issueHealth: issueHealthScore !== null ? Math.min(100, Math.max(0, issueHealthScore)) : null,
+ hasAudit: issueHealthScore !== null,
+ }
+}
+
+export function computeOrgHealthSummary(model, issuesData = {}, hasAudit = false) {
+ if (!model || !model.allRepos || model.allRepos.length === 0) {
+ return {
+ score: 0,
+ grade: 'F',
+ riskCounts: { critical: 0, warning: 0, healthy: 0 },
+ dimensions: { activity: 0, diversity: 0, compliance: 0, issueHealth: null, hasAudit: false },
+ totalRepos: 0,
+ }
+ }
+
+ const dimensions = computeDimensionScores(model, issuesData, hasAudit)
+
+ // Compute aggregate score based on available dimensions
+ let totalWeight = 0
+ let weightedScore = 0
+
+ // Activity: 30%
+ weightedScore += dimensions.activity * 0.3
+ totalWeight += 0.3
+
+ // Diversity: 30%
+ weightedScore += dimensions.diversity * 0.3
+ totalWeight += 0.3
+
+ // Compliance: 20%
+ weightedScore += dimensions.compliance * 0.2
+ totalWeight += 0.2
+
+ // Issue / PR Health: 20% (if audit run, otherwise scale remaining weights)
+ if (dimensions.hasAudit && dimensions.issueHealth !== null) {
+ weightedScore += dimensions.issueHealth * 0.2
+ totalWeight += 0.2
+ }
+
+ const finalScore = Math.round(weightedScore / totalWeight)
+ const boundedScore = Math.min(100, Math.max(0, finalScore))
+ const grade = getHealthGrade(boundedScore)
+
+ return {
+ score: boundedScore,
+ grade,
+ dimensions,
+ totalRepos: model.allRepos.length,
+ }
+}
diff --git a/src/services/healthScorecard.test.js b/src/services/healthScorecard.test.js
new file mode 100644
index 0000000..61bcb87
--- /dev/null
+++ b/src/services/healthScorecard.test.js
@@ -0,0 +1,196 @@
+import { describe, it, expect } from 'vitest'
+import { getHealthGrade, computeDimensionScores, computeOrgHealthSummary } from './healthScorecard'
+import { classifyRepositoryRisk, generateRiskRecommendations } from './riskAdvisor'
+
+function daysAgoISO(days) {
+ return new Date(Date.now() - days * 86_400_000).toISOString()
+}
+
+describe('getHealthGrade', () => {
+ it('maps explicit grade thresholds correctly', () => {
+ expect(getHealthGrade(98)).toBe('A+')
+ expect(getHealthGrade(95)).toBe('A+')
+ expect(getHealthGrade(94)).toBe('A')
+ expect(getHealthGrade(85)).toBe('A')
+ expect(getHealthGrade(84)).toBe('B')
+ expect(getHealthGrade(70)).toBe('B')
+ expect(getHealthGrade(69)).toBe('C')
+ expect(getHealthGrade(55)).toBe('C')
+ expect(getHealthGrade(54)).toBe('D')
+ expect(getHealthGrade(40)).toBe('D')
+ expect(getHealthGrade(39)).toBe('F')
+ expect(getHealthGrade(0)).toBe('F')
+ })
+})
+
+describe('computeDimensionScores', () => {
+ it('handles empty organization model gracefully', () => {
+ const res = computeDimensionScores(null)
+ expect(res).toEqual({
+ activity: 0,
+ diversity: 0,
+ compliance: 0,
+ issueHealth: null,
+ hasAudit: false,
+ })
+ })
+
+ it('marks issueHealth as null when audit has not been run (Insufficient Data)', () => {
+ const model = {
+ allRepos: [
+ { name: 'repo-1', pushed_at: daysAgoISO(10), license: { key: 'mit' }, busFactor: { factor: 3 } },
+ ],
+ }
+ const dimensions = computeDimensionScores(model, {}, false)
+ expect(dimensions.hasAudit).toBe(false)
+ expect(dimensions.issueHealth).toBeNull()
+ })
+
+ it('calculates compliance based on license presence among non-archived non-fork repos', () => {
+ const model = {
+ allRepos: [
+ { name: 'repo-licensed', license: { key: 'mit' }, archived: false, fork: false },
+ { name: 'repo-no-license', license: null, archived: false, fork: false },
+ { name: 'archived-no-license', license: null, archived: true, fork: false },
+ ],
+ }
+ const dimensions = computeDimensionScores(model, {}, false)
+ // 1 licensed out of 2 valid repos = 50%
+ expect(dimensions.compliance).toBe(50)
+ })
+
+ it('caps diversity score below 100 for repositories with bus factor 1 even with 10 contributors', () => {
+ const model = {
+ allRepos: [
+ {
+ name: 'bus-factor-1-repo',
+ busFactor: { factor: 1 },
+ contributors: Array(10).fill({ login: 'contrib' }),
+ },
+ ],
+ }
+ const dimensions = computeDimensionScores(model, {}, false)
+ expect(dimensions.diversity).toBe(50)
+ })
+
+ it('calculates issueHealth score and hasAudit value when audit has been run with closed issues, stale open issues, or an empty issue array', () => {
+ const model = {
+ allRepos: [
+ { name: 'repo-1', pushed_at: daysAgoISO(10), license: { key: 'mit' } },
+ ],
+ }
+
+ // 1. Closed issues case
+ const closedData = { 'org/repo-1': [{ state: 'closed', created_at: daysAgoISO(5) }] }
+ const closedRes = computeDimensionScores(model, closedData, true)
+ expect(closedRes.hasAudit).toBe(true)
+ expect(closedRes.issueHealth).toBe(100)
+
+ // 2. Stale open issues case
+ const staleData = { 'org/repo-1': [{ state: 'open', created_at: daysAgoISO(100) }] }
+ const staleRes = computeDimensionScores(model, staleData, true)
+ expect(staleRes.hasAudit).toBe(true)
+ expect(staleRes.issueHealth).toBe(15)
+
+ // 3. Empty audited issue array case
+ const emptyAuditedData = { 'org/repo-1': [] }
+ const emptyAuditedRes = computeDimensionScores(model, emptyAuditedData, true)
+ expect(emptyAuditedRes.hasAudit).toBe(true)
+ expect(emptyAuditedRes.issueHealth).toBe(100)
+ })
+})
+
+describe('computeOrgHealthSummary', () => {
+ it('calculates exact aggregate score and grade for a healthy organization model', () => {
+ const model = {
+ allRepos: [
+ { name: 'repo-1', pushed_at: daysAgoISO(5), license: { key: 'mit' }, busFactor: { factor: 5 }, contributors: [1,2,3,4,5] },
+ ],
+ }
+ const summary = computeOrgHealthSummary(model, {}, false)
+ expect(summary.score).toBe(100)
+ expect(summary.grade).toBe('A+')
+ })
+})
+
+describe('classifyRepositoryRisk', () => {
+ it('flags Bus Factor = 1 as Critical regardless of recency or license', () => {
+ const repo = {
+ name: 'repo-a',
+ orgLogin: 'org',
+ pushed_at: daysAgoISO(1),
+ license: { key: 'mit' },
+ busFactor: { factor: 1 },
+ }
+ const risk = classifyRepositoryRisk(repo)
+ expect(risk.tier).toBe('critical')
+ expect(risk.mainIssue).toContain('Bus Factor = 1')
+ })
+
+ it('flags Hibernating and Missing License as Warning', () => {
+ const repo = {
+ name: 'dormant-repo',
+ orgLogin: 'org',
+ pushed_at: daysAgoISO(200),
+ activityClassification: 'Hibernating',
+ license: null,
+ busFactor: { factor: 3 },
+ }
+ const risk = classifyRepositoryRisk(repo)
+ expect(risk.tier).toBe('warning')
+ })
+
+ it('detects hibernating repo from pushed_at when activityClassification is absent', () => {
+ const repo = {
+ name: 'old-push-repo',
+ orgLogin: 'org',
+ pushed_at: daysAgoISO(200),
+ license: { key: 'mit' },
+ busFactor: { factor: 3 },
+ }
+ const risk = classifyRepositoryRisk(repo)
+ expect(risk.tier).toBe('warning')
+ expect(risk.mainIssue).toContain('Hibernating')
+ })
+
+ it('classifies repos without risk signals as Healthy', () => {
+ const repo = {
+ name: 'healthy-repo',
+ orgLogin: 'org',
+ pushed_at: daysAgoISO(5),
+ activityClassification: 'Thriving',
+ license: { key: 'mit' },
+ busFactor: { factor: 4 },
+ contributors: [1, 2, 3, 4],
+ }
+ const risk = classifyRepositoryRisk(repo)
+ expect(risk.tier).toBe('healthy')
+ })
+})
+
+describe('generateRiskRecommendations', () => {
+ it('sorts recommendations by priority (critical > warning > positive)', () => {
+ const model = {
+ allRepos: [
+ {
+ id: 1, name: 'healthy-repo', orgLogin: 'org', pushed_at: daysAgoISO(5),
+ license: { key: 'mit' }, busFactor: { factor: 4 }, contributors: [1,2,3,4]
+ },
+ {
+ id: 2, name: 'crit-repo', orgLogin: 'org', pushed_at: daysAgoISO(5),
+ license: { key: 'mit' }, busFactor: { factor: 1 }, contributors: [1]
+ },
+ {
+ id: 3, name: 'warn-repo', orgLogin: 'org', pushed_at: daysAgoISO(200),
+ activityClassification: 'Hibernating', license: null, busFactor: { factor: 3 }
+ },
+ ],
+ }
+
+ const recs = generateRiskRecommendations(model, {})
+ expect(recs.length).toBeGreaterThanOrEqual(3)
+ expect(recs[0].severity).toBe('critical')
+ expect(recs[1].severity).toBe('warning')
+ expect(recs[recs.length - 1].severity).toBe('positive')
+ })
+})
diff --git a/src/services/riskAdvisor.js b/src/services/riskAdvisor.js
new file mode 100644
index 0000000..39aef03
--- /dev/null
+++ b/src/services/riskAdvisor.js
@@ -0,0 +1,127 @@
+/**
+ * Risk Advisor Service
+ * Rule engine for signal-based repository risk classification and automated recommendations.
+ */
+
+export function classifyRepositoryRisk(repo, issuesData = {}) {
+ if (!repo) return { tier: 'healthy', mainIssue: 'No data', reasons: [] }
+
+ const reasons = []
+
+ // Signal 1: Bus Factor = 1 is ALWAYS Critical
+ const busFactor = repo.busFactor?.factor ?? (repo.contributors ? repo.contributors.length : 0)
+ if (busFactor === 1) {
+ reasons.push('Bus Factor = 1 (Single maintainer dependency)')
+ }
+
+ // Check critical status
+ if (reasons.length > 0) {
+ return {
+ tier: 'critical',
+ mainIssue: reasons[0],
+ reasons,
+ }
+ }
+
+ // Signal 2: Warning signals (Hibernating, missing license, high stale ratio)
+ const daysSincePush = repo.pushed_at ? (Date.now() - new Date(repo.pushed_at)) / 86_400_000 : null
+ const isHibernating = repo.activityClassification === 'Hibernating' || (daysSincePush !== null && daysSincePush > 180)
+ if (isHibernating) {
+ reasons.push('Hibernating (No pushes in >180 days)')
+ }
+
+ const isMissingLicense = !repo.license && !repo.archived && !repo.fork
+ if (isMissingLicense) {
+ reasons.push('Missing LICENSE file')
+ }
+
+ // Check repo-specific issue audit data if available
+ const key = `${repo.orgLogin}/${repo.name}`
+ const repoIssues = issuesData[key] || []
+ if (repoIssues.length > 0) {
+ const daysSince = d => Math.floor((Date.now() - new Date(d)) / 86_400_000)
+ const openIssues = repoIssues.filter(i => i.state === 'open')
+ const staleCount = openIssues.filter(i => daysSince(i.created_at) >= 90).length
+ const staleRatio = openIssues.length > 0 ? (staleCount / openIssues.length) * 100 : 0
+ if (staleRatio > 25) {
+ reasons.push(`High stale issue ratio (${staleRatio.toFixed(0)}%)`)
+ }
+ }
+
+ if (reasons.length > 0) {
+ return {
+ tier: 'warning',
+ mainIssue: reasons[0],
+ reasons,
+ }
+ }
+
+ return {
+ tier: 'healthy',
+ mainIssue: 'No critical or warning risk signals',
+ reasons: [],
+ }
+}
+
+export function generateRiskRecommendations(model, issuesData = {}) {
+ if (!model || !model.allRepos || model.allRepos.length === 0) {
+ return []
+ }
+
+ const recommendations = []
+
+ model.allRepos.forEach(repo => {
+ const risk = classifyRepositoryRisk(repo, issuesData)
+
+ if (risk.tier === 'critical') {
+ recommendations.push({
+ id: `rec-crit-${repo.id || repo.name}`,
+ severity: 'critical',
+ repoName: repo.name,
+ orgLogin: repo.orgLogin,
+ title: `Bus Factor = 1 in ${repo.name}`,
+ description: `Repository relies on a single contributor. Consider recruiting co-maintainers to mitigate single-point-of-failure risk.`,
+ action: 'Review Contributors',
+ htmlUrl: repo.html_url || `https://github.com/${repo.orgLogin}/${repo.name}`,
+ })
+ } else if (risk.tier === 'warning') {
+ let desc = risk.mainIssue
+ if (risk.reasons.includes('Missing LICENSE file')) {
+ desc = `Non-archived repository lacks a license file. Add an open-source license to ensure legal compliance.`
+ } else if (risk.reasons.some(r => r.includes('Hibernating'))) {
+ desc = `No code pushed for >180 days. Evaluate if project is active or should be archived.`
+ } else if (risk.reasons.some(r => r.includes('stale issue ratio'))) {
+ desc = `Over 25% of open items are untouched for 90+ days. Triage or close stale items.`
+ }
+
+ recommendations.push({
+ id: `rec-warn-${repo.id || repo.name}`,
+ severity: 'warning',
+ repoName: repo.name,
+ orgLogin: repo.orgLogin,
+ title: `${risk.mainIssue} in ${repo.name}`,
+ description: desc,
+ action: 'Inspect Repository',
+ htmlUrl: repo.html_url || `https://github.com/${repo.orgLogin}/${repo.name}`,
+ })
+ }
+ })
+
+ // Add positive highlight recommendation if org has thriving repos
+ const healthyRepos = model.allRepos.filter(r => classifyRepositoryRisk(r, issuesData).tier === 'healthy')
+ if (healthyRepos.length > 0) {
+ recommendations.push({
+ id: 'rec-positive-org',
+ severity: 'positive',
+ repoName: `${healthyRepos.length} Repositories`,
+ orgLogin: model.allRepos[0]?.orgLogin || 'Org',
+ title: `${healthyRepos.length} repositories are operating in Healthy status`,
+ description: `Active push frequency, license compliance, and distributed maintainer participation detected across these repositories.`,
+ action: 'View All Repos',
+ })
+ }
+
+ // Priority sorting: critical > warning > positive
+ const severityRank = { critical: 1, warning: 2, positive: 3 }
+ return recommendations.sort((a, b) => severityRank[a.severity] - severityRank[b.severity])
+}