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 => ( + + ))} + + + + {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 ( + + + + + + + + ) + })} + +
+ {h} +
+
{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]) +}