diff --git a/src/components/RadialGauge.jsx b/src/components/RadialGauge.jsx new file mode 100644 index 0000000..c99eebd --- /dev/null +++ b/src/components/RadialGauge.jsx @@ -0,0 +1,60 @@ +import React from 'react' + +export default function RadialGauge({ score, size = 80, strokeWidth = 7 }) { + const center = size / 2 + const radius = center - strokeWidth + const circumference = 2 * Math.PI * radius + + const validScore = typeof score === 'number' && !isNaN(score) ? Math.min(100, Math.max(0, score)) : null + const offset = validScore !== null ? circumference - (validScore / 100) * circumference : circumference + + let color = 'var(--text3)' + let bgStroke = 'var(--border)' + + if (validScore !== null) { + if (validScore >= 70) color = 'var(--green, #22c55e)' + else if (validScore >= 40) color = 'var(--amber, #f59e0b)' + else color = 'var(--red, #ef4444)' + } + + return ( +
+ +
+ + {validScore !== null ? validScore : 'N/A'} + + {validScore !== null && ( + + / 100 + + )} +
+
+ ) +} diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 66f7de0..7922e80 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react' import { fetchOrg, fetchRepos, fetchContributors, fetchIssues, fetchRateLimit, fetchPulls } from '../services/github' -import { buildAnalyticalModel, getTopRepositories } from '../services/analytics' +import { buildAnalyticalModel, getTopRepositories, computeRepoHealthScore } from '../services/analytics' import { saveAnalysis, loadAnalysis } from '../services/cache' const Ctx = createContext(null) @@ -377,15 +377,41 @@ export function AppProvider({ children }) { }).sort((a, b) => b.ratio - a.ratio) }, [issuesData]) + const repoScorecards = useMemo(() => { + if (!model || !model.totalRepos) return [] + return model.totalRepos + .map(repo => { + const key = `${repo.orgLogin}/${repo.name}` + const issues = issuesData[key] || [] + const pulls = pullsData[key] || [] + return computeRepoHealthScore(repo, issues, pulls) + }) + .sort((a, b) => { + if (a.overallScore === null && b.overallScore === null) return 0 + if (a.overallScore === null) return 1 + if (b.overallScore === null) return -1 + return a.overallScore - b.overallScore + }) + }, [model, issuesData, pullsData]) + + const ctxValue = useMemo(() => ({ + pat, savePat, orgs, model, issuesData, pullsData, + rateLimit, loading, loadMsg, govLoading, error, totalRepo, + runAdvanceAnalytics, refreshRateLimit, advanceAnalyticsLoading, advanceAnalyticsComplete, + runFullAnalytics, + isComplete, auditComplete, lastOrgNames, hydrating, + explore, runFullExplore, runAudit, runGovernanceAnalysis, setError, staleRepoStats, repoScorecards + }), [ + pat, savePat, orgs, model, issuesData, pullsData, + rateLimit, loading, loadMsg, govLoading, error, totalRepo, + runAdvanceAnalytics, refreshRateLimit, advanceAnalyticsLoading, advanceAnalyticsComplete, + runFullAnalytics, + isComplete, auditComplete, lastOrgNames, hydrating, + explore, runFullExplore, runAudit, runGovernanceAnalysis, setError, staleRepoStats, repoScorecards + ]) + return ( - + {children} ) diff --git a/src/pages/GovernancePage.jsx b/src/pages/GovernancePage.jsx index 7a209f6..65e07c3 100644 --- a/src/pages/GovernancePage.jsx +++ b/src/pages/GovernancePage.jsx @@ -1,15 +1,17 @@ import React, { useState, useMemo } from 'react' -import { FiRefreshCw, FiExternalLink } from 'react-icons/fi' +import { FiRefreshCw, FiExternalLink, FiSearch, FiAlertTriangle, FiCheckCircle } from 'react-icons/fi' import { useApp } from '../context/AppContext' -import { C, PageTitle, EmptyOk } from '../components/UI' +import { C, PageTitle, EmptyOk, Badge, HealthBar } from '../components/UI' import AnalysisBanner from '../components/AnalysisBanner' import { GovernanceSkeleton } from '../components/Orgexplorerskeletons' +import RadialGauge from '../components/RadialGauge' const TABS = [ - { key: 'dead', label: 'Dead Issues' }, - { key: 'zombie', label: 'Zombie PRs' }, - { key: 'stale', label: 'Stale Issues Ratio' }, - { key: 'license', label: 'No License' }, + { key: 'scorecard', label: 'Health Scorecard' }, + { key: 'dead', label: 'Dead Issues' }, + { key: 'zombie', label: 'Zombie PRs' }, + { key: 'stale', label: 'Stale Issues Ratio' }, + { key: 'license', label: 'No License' }, ] const getStatus = ratio => { @@ -42,17 +44,56 @@ const getStatus = ratio => { } export default function GovernancePage() { - const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis,staleRepoStats } = useApp() - const [tab, setTab] = useState('dead') + const { model, issuesData, runAudit, govLoading, auditComplete, loading, runGovernanceAnalysis, staleRepoStats, repoScorecards } = useApp() + const [tab, setTab] = useState('scorecard') const ITEMS_PER_PAGE = 10 const [stalePage, setStalePage] = useState(1) const totalPages = Math.ceil(staleRepoStats.length / ITEMS_PER_PAGE) + const SCORECARD_ITEMS_PER_PAGE = 5 + const [searchQuery, setSearchQuery] = useState('') + const [scorecardPage, setScorecardPage] = useState(1) + const paginatedStaleRepos = useMemo(() => { const start = (stalePage - 1) * ITEMS_PER_PAGE return staleRepoStats.slice(start, start + ITEMS_PER_PAGE) }, [staleRepoStats, stalePage]) + + // Scorecards filtering & pagination + const filteredScorecards = useMemo(() => { + if (!searchQuery.trim()) return repoScorecards || [] + const q = searchQuery.toLowerCase().trim() + return (repoScorecards || []).filter(s => + s.repoName.toLowerCase().includes(q) || s.repoKey.toLowerCase().includes(q) + ) + }, [repoScorecards, searchQuery]) + + const totalScorecardPages = Math.ceil(filteredScorecards.length / SCORECARD_ITEMS_PER_PAGE) || 1 + const currentScorecardPage = Math.min(scorecardPage, totalScorecardPages) + + const paginatedScorecards = useMemo(() => { + const start = (currentScorecardPage - 1) * SCORECARD_ITEMS_PER_PAGE + return filteredScorecards.slice(start, start + SCORECARD_ITEMS_PER_PAGE) + }, [filteredScorecards, currentScorecardPage]) + + const { avgOrgHealth, reposAtRiskCount, healthyReposCount } = useMemo(() => { + let scoreSum = 0, scoredCount = 0, atRisk = 0, healthy = 0 + for (const s of repoScorecards || []) { + if (s.overallScore !== null) { + scoreSum += s.overallScore + scoredCount++ + } + if (s.riskLevel === 'critical' || s.riskLevel === 'warning') atRisk++ + else if (s.riskLevel === 'healthy') healthy++ + } + return { + avgOrgHealth: scoredCount ? Math.round(scoreSum / scoredCount) : null, + reposAtRiskCount: atRisk, + healthyReposCount: healthy + } + }, [repoScorecards]) + // Flatten all issues and tag with repo/org const allIssues = useMemo(() => { const arr = [] @@ -63,7 +104,7 @@ export default function GovernancePage() { return arr }, [issuesData]) - if(loading) return + if (loading) return if (!model) return null const hasAudit = Object.keys(issuesData || {}).length > 0 @@ -75,7 +116,7 @@ export default function GovernancePage() { .sort((a, b) => daysSince(b.created_at) - daysSince(a.created_at)) // Health check 2 — Percentage of dead issues relative to all issues - const staleIssuesRatio = allIssues.length ? (deadIssues.length / allIssues.length) * 100 : 0; + const staleIssuesRatio = allIssues.length ? (deadIssues.length / allIssues.length) * 100 : 0 // Health check 3 — Zombie PRs (>90 days open) const zombiePRs = allIssues @@ -88,7 +129,13 @@ export default function GovernancePage() { // Issue resolution rate per repo const topRepos = model.allRepos.slice(0, 8) - const counts = { dead: deadIssues.length, zombie: zombiePRs.length, license: noLicense.length, stale: staleIssuesRatio.toFixed(2) } + const counts = { + scorecard: repoScorecards ? repoScorecards.length : 0, + dead: deadIssues.length, + zombie: zombiePRs.length, + license: noLicense.length, + stale: staleIssuesRatio.toFixed(2) + } // Stat card const StatBox = ({ label, value, sub, color }) => ( @@ -135,6 +182,29 @@ export default function GovernancePage() { ) + const pillarRiskBadge = (risk, label) => { + const color = risk === 'healthy' ? 'var(--green)' : risk === 'warning' ? 'var(--amber)' : risk === 'critical' ? 'var(--red)' : 'var(--text3)' + const bg = risk === 'healthy' ? 'rgba(34,197,94,.12)' : risk === 'warning' ? 'rgba(250,204,21,.12)' : risk === 'critical' ? 'rgba(239,68,68,.12)' : 'rgba(102,102,102,.12)' + return {label || risk?.toUpperCase()} + } + + const PillarRow = ({ label, pillar, metric }) => ( +
+ {label} +
+ {pillar.score !== null ? ( + + ) : ( +
+ )} +
+
+ {pillarRiskBadge(pillar.riskLevel, pillar.label)} +
+ {metric} +
+ ) + return (
{t.label}{' '} - 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}> + 40 ? 'var(--red)' : 'var(--green)', marginLeft: 4 }}> {counts[t.key]} ))}
+ {/* Health Scorecard Tab */} + {tab === 'scorecard' && ( +
+ {/* Portfolio Health Summary Bar */} +
+
+
Organization Health
+
= 70 ? 'var(--green)' : avgOrgHealth >= 40 ? 'var(--amber)' : 'var(--red)') : 'var(--text3)', marginTop: 4 }}> + {avgOrgHealth !== null ? `${avgOrgHealth} / 100` : 'N/A'} +
+
+ +
+
Repositories at Risk
+
0 ? 'var(--amber)' : 'var(--green)', marginTop: 4 }}> + {reposAtRiskCount} +
+
+ +
+
Healthy Repositories
+
+ {healthyReposCount} +
+
+
+ + {/* Search Input */} +
+
+ + { setSearchQuery(e.target.value); setScorecardPage(1); }} + style={{ ...C.input, width: '100%', paddingLeft: 36 }} + /> +
+
+ + {/* Scorecard Cards List */} + {paginatedScorecards.length > 0 ? ( +
+ {paginatedScorecards.map(sc => { + const p = sc.pillars + return ( +
+ {/* Scorecard Card Header */} +
+
+ +
+
+ {sc.repoName} +
+
+ {sc.repoKey} +
+
+
+ +
+ {pillarRiskBadge(sc.riskLevel)} + + GitHub + +
+
+ + {/* 5 Pillars Breakdown */} +
+ {[ + { key: 'busFactor', label: 'Bus Factor', pillar: p.busFactor, metric: p.busFactor.factor ? `${p.busFactor.factor} contributors` : 'No data' }, + { key: 'compliance', label: 'Compliance', pillar: p.compliance, metric: p.compliance.checks.license ? 'License ✓' : 'No License ✗' }, + { key: 'freshness', label: 'Freshness', pillar: p.freshness, metric: p.freshness.daysSince !== null ? `${p.freshness.daysSince} days ago` : 'No data' }, + { key: 'responsiveness', label: 'Responsiveness', pillar: p.responsiveness, metric: p.responsiveness.staleRatio !== null ? `${Math.round(p.responsiveness.staleRatio * 100)}% stale` : 'No audit data' }, + { key: 'prResolution', label: 'PR Resolution', pillar: p.prResolution, metric: p.prResolution.mergeRate !== null ? `${Math.round(p.prResolution.mergeRate * 100)}% merge rate` : 'Insufficient data' }, + ].map(row => ( + + ))} +
+ + {/* Recommendations section */} +
+
+ Actionable Recommendations +
+ {sc.recommendations.length > 0 ? ( +
+ {sc.recommendations.map(rec => ( +
+ + + {rec.message} + +
+ ))} +
+ ) : ( +
+ No critical governance risks detected. Repository is healthy! +
+ )} +
+
+ ) + })} +
+ ) : ( + + )} + + {/* Pagination Controls */} + {totalScorecardPages > 1 && ( +
+ + + Page {currentScorecardPage} of {totalScorecardPages} + + +
+ )} +
+ )} + {/* Dead Issues */} {tab === 'dead' && ( deadIssues.length ? ( diff --git a/src/services/analytics.js b/src/services/analytics.js index 2ca5969..18001ff 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -201,3 +201,237 @@ export function getTopRepositories(repos, limit = 10) { .sort((a, b) => b.score - a.score) .slice(0, limit); } + +// Repository Health & Risk Scorecard (Governance 2.0) +export function computeRepoHealthScore(repo, issues = [], pulls = []) { + const orgLogin = repo.orgLogin || repo.owner?.login || ''; + const repoName = repo.name || ''; + const repoKey = repo.repoKey || (orgLogin ? `${orgLogin}/${repoName}` : repoName); + + // Pillar 1: Bus Factor + let busFactorPillar = { + score: null, + weight: 20, + label: 'No data', + riskLevel: 'unknown', + factor: 0 + }; + + const busFactorPillarFrom = f => { + if (f === 1) return { score: 0, weight: 20, label: 'Critical', riskLevel: 'critical', factor: 1 }; + if (f === 2) return { score: 50, weight: 20, label: 'Warning', riskLevel: 'warning', factor: 2 }; + if (f >= 3) return { score: Math.min(100, 80 + (f - 3) * 10), weight: 20, label: 'Healthy', riskLevel: 'healthy', factor: f }; + return { score: null, weight: 20, label: 'No data', riskLevel: 'unknown', factor: 0 }; + }; + + const contribs = repo.contributors || repo.contributorsList; + if (Array.isArray(contribs) && contribs.length > 0) { + busFactorPillar = busFactorPillarFrom(computeBusFactor(contribs).factor); + } else if (repo.busFactor && repo.busFactor.risk !== 'unknown') { + busFactorPillar = busFactorPillarFrom(repo.busFactor.factor); + } + + // Pillar 2: Governance Compliance + const hasLicense = Boolean(repo.license || repo.has_license); + const hasReadme = repo._files?.readme !== undefined ? Boolean(repo._files.readme) : (repo.has_readme !== undefined ? Boolean(repo.has_readme) : null); + const hasContributing = repo._files?.contributing !== undefined ? Boolean(repo._files.contributing) : (repo.has_contributing !== undefined ? Boolean(repo.has_contributing) : null); + const hasSecurity = repo._files?.security !== undefined ? Boolean(repo._files.security) : (repo.has_security !== undefined ? Boolean(repo.has_security) : null); + + const checks = { + license: hasLicense, + readme: hasReadme, + contributing: hasContributing, + security: hasSecurity + }; + + let compliancePillar = null; + const knownChecks = Object.entries(checks).filter(([, v]) => v !== null); + if (knownChecks.length > 0) { + const passedCount = knownChecks.filter(([, v]) => v === true).length; + // Each known check is weighted equally to sum to 100 + const compScore = Math.round((passedCount / knownChecks.length) * 100); + const riskLevel = compScore >= 70 ? 'healthy' : compScore >= 40 ? 'warning' : 'critical'; + const label = compScore >= 70 ? 'Healthy' : compScore >= 40 ? 'Warning' : 'Critical'; + compliancePillar = { + score: compScore, + weight: 20, + label: knownChecks.length < 4 ? `${label} (Limited data)` : label, + riskLevel, + checks + }; + } else { + compliancePillar = { + score: null, + weight: 20, + label: 'No data', + riskLevel: 'unknown', + checks + }; + } + + // Pillar 3: Activity Freshness + let freshnessPillar = { score: null, weight: 20, label: 'No data', riskLevel: 'unknown', daysSince: null }; + const pushedAt = repo.pushed_at || repo.updated_at; + if (pushedAt) { + const pushedMs = Date.parse(pushedAt); + if (Number.isFinite(pushedMs)) { + const daysSince = Math.max(0, Math.floor((Date.now() - pushedMs) / 86_400_000)); + const freshnessScore = Math.max(0, Math.min(100, Math.round(100 - daysSince * (100 / 365)))); + const riskLevel = freshnessScore >= 70 ? 'healthy' : freshnessScore >= 40 ? 'warning' : 'critical'; + const label = freshnessScore >= 70 ? 'Excellent' : freshnessScore >= 40 ? 'Warning' : 'Critical'; + freshnessPillar = { + score: freshnessScore, + weight: 20, + label, + riskLevel, + daysSince + }; + } + } + + // Pillar 4: Responsiveness + let responsivenessPillar = { score: null, weight: 20, label: 'No data', riskLevel: 'unknown', staleRatio: null }; + if (Array.isArray(issues) && (issues.length > 0 || (repo._hasIssuesAudit || repo._auditDone))) { + const normalIssues = issues.filter(i => !i.pull_request); + const openIssues = normalIssues.filter(i => i.state === 'open'); + + let staleRatio = 0; + let baseScore = 100; + + if (openIssues.length > 0) { + const now = Date.now(); + const staleIssues = openIssues.filter(i => (now - new Date(i.updated_at).getTime()) / 86_400_000 >= 90); + staleRatio = staleIssues.length / openIssues.length; + baseScore = 100 - staleRatio * 100; + } + + // Zombie PR penalty (5 pts per zombie PR, max penalty 40) + const zombiePRs = issues.filter(i => i.pull_request && i.state === 'open' && (Date.now() - new Date(i.created_at || i.updated_at).getTime()) / 86_400_000 >= 90); + const zombiePenalty = Math.min(40, zombiePRs.length * 5); + + const respScore = Math.max(0, Math.min(100, Math.round(baseScore - zombiePenalty))); + const riskLevel = respScore >= 70 ? 'healthy' : respScore >= 40 ? 'warning' : 'critical'; + const label = respScore >= 70 ? 'Healthy' : respScore >= 40 ? 'Warning' : 'Critical'; + + responsivenessPillar = { + score: respScore, + weight: 20, + label, + riskLevel, + staleRatio: Number(staleRatio.toFixed(2)) + }; + } + + // Pillar 5: PR Resolution Rate + let prResolutionPillar = { score: null, weight: 20, label: 'Insufficient data', riskLevel: 'unknown', mergeRate: null }; + const allPRs = Array.isArray(pulls) && pulls.length > 0 ? pulls : (Array.isArray(issues) ? issues.filter(i => i.pull_request) : []); + const closedPRs = allPRs.filter(p => p.state === 'closed'); + + if (closedPRs.length > 0) { + const mergedPRs = closedPRs.filter(p => p.merged_at != null || p.pull_request?.merged_at != null || p.merged === true); + const mergeRate = mergedPRs.length / closedPRs.length; + const prScore = Math.round(mergeRate * 100); + const riskLevel = prScore >= 70 ? 'healthy' : prScore >= 40 ? 'warning' : 'critical'; + const label = prScore >= 70 ? 'Healthy' : prScore >= 40 ? 'Warning' : 'Critical'; + + prResolutionPillar = { + score: prScore, + weight: 20, + label, + riskLevel, + mergeRate: Number(mergeRate.toFixed(2)) + }; + } + + // Pillars object + const pillars = { + busFactor: busFactorPillar, + compliance: compliancePillar, + freshness: freshnessPillar, + responsiveness: responsivenessPillar, + prResolution: prResolutionPillar + }; + + // Missing data handling & Weight normalization + const availablePillars = Object.values(pillars).filter(p => p && p.score !== null); + const totalAvailableWeight = availablePillars.reduce((sum, p) => sum + p.weight, 0); + + let overallScore = null; + let overallRiskLevel = 'unknown'; + + if (totalAvailableWeight > 0) { + const weightedSum = availablePillars.reduce((sum, p) => sum + (p.score * p.weight), 0); + overallScore = Math.round(weightedSum / totalAvailableWeight); + overallRiskLevel = overallScore >= 70 ? 'healthy' : overallScore >= 40 ? 'warning' : 'critical'; + } + + // Recommendations Engine + const recommendations = []; + + if (busFactorPillar.score !== null && busFactorPillar.factor <= 1) { + recommendations.push({ + severity: 'critical', + message: 'Single maintainer risk detected — recruit additional contributors' + }); + } + + if (compliancePillar.score !== null && compliancePillar.checks.license === false) { + recommendations.push({ + severity: 'critical', + message: 'No license found — add a license to clarify open-source usage' + }); + } + + if (compliancePillar.score !== null && compliancePillar.checks.readme === false) { + recommendations.push({ + severity: 'warning', + message: 'Add README.md to describe project purpose and setup' + }); + } + + if (compliancePillar.score !== null && compliancePillar.checks.contributing === false) { + recommendations.push({ + severity: 'warning', + message: 'Add CONTRIBUTING.md to guide new contributors' + }); + } + + if (compliancePillar.score !== null && compliancePillar.checks.security === false) { + recommendations.push({ + severity: 'warning', + message: 'Add SECURITY.md to define the vulnerability disclosure process' + }); + } + + if (freshnessPillar.score !== null && freshnessPillar.daysSince > 180) { + recommendations.push({ + severity: 'warning', + message: 'No recent commits — consider re-activating or archiving the repository' + }); + } + + if (responsivenessPillar.score !== null && responsivenessPillar.staleRatio > 0.50) { + recommendations.push({ + severity: 'warning', + message: 'Over 50% of open issues are stale — consider a triage sprint' + }); + } + + if (prResolutionPillar.score !== null && prResolutionPillar.mergeRate < 0.30) { + recommendations.push({ + severity: 'warning', + message: 'Low PR merge rate — review PR acceptance criteria or contributor guidance' + }); + } + + return { + repoKey, + repoName, + orgLogin, + overallScore, + riskLevel: overallRiskLevel, + pillars, + recommendations + }; +} + diff --git a/src/services/analytics.repoHealthScore.test.js b/src/services/analytics.repoHealthScore.test.js new file mode 100644 index 0000000..e92ed1e --- /dev/null +++ b/src/services/analytics.repoHealthScore.test.js @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest' +import { computeRepoHealthScore } from './analytics' + +function daysAgoISO(days) { + return new Date(Date.now() - days * 86_400_000).toISOString() +} + +describe('computeRepoHealthScore', () => { + describe('Bus Factor Pillar', () => { + it('scores 0 / critical for 1 contributor', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [{ login: 'u1', contributions: 100 }] + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.busFactor).toMatchObject({ + score: 0, + factor: 1, + riskLevel: 'critical' + }) + }) + + it('scores 50 / warning when the bus factor is 2', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [ + { login: 'u1', contributions: 40 }, + { login: 'u2', contributions: 30 }, + { login: 'u3', contributions: 30 } + ] + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.busFactor).toMatchObject({ + score: 50, + factor: 2, + riskLevel: 'warning' + }) + }) + + it('scores 80+ / healthy when the bus factor is 3', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [ + { login: 'u1', contributions: 25 }, + { login: 'u2', contributions: 25 }, + { login: 'u3', contributions: 25 }, + { login: 'u4', contributions: 25 } + ] + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.busFactor).toMatchObject({ + score: 80, + factor: 3, + riskLevel: 'healthy' + }) + }) + + it('scores healthy for already-descending contributor distribution', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [ + { login: 'u1', contributions: 35 }, + { login: 'u2', contributions: 25 }, + { login: 'u3', contributions: 20 }, + { login: 'u4', contributions: 20 } + ] + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.busFactor).toMatchObject({ + score: 50, + factor: 2, + riskLevel: 'warning' + }) + }) + }) + + describe('Compliance Pillar', () => { + it('scores 100 when all 4 files are present', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + license: { key: 'mit' }, + has_readme: true, + has_contributing: true, + has_security: true + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.compliance).toMatchObject({ + score: 100, + riskLevel: 'healthy', + checks: { license: true, readme: true, contributing: true, security: true } + }) + }) + + it('detects missing license, contributing, and security files', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + license: null, + has_readme: true, + has_contributing: false, + has_security: false + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.compliance).toMatchObject({ + score: 25, + riskLevel: 'critical', + checks: { license: false, readme: true, contributing: false, security: false } + }) + }) + + it('handles limited data gracefully when contributing and security are not checked', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + license: { key: 'mit' }, + has_readme: true + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.compliance.score).toBe(100) // 2/2 known checks passed + expect(res.pillars.compliance.label).toContain('Limited data') + }) + + it('handles unknown README state when not checked', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + license: { key: 'mit' } + } + const res = computeRepoHealthScore(repo) + expect(res.pillars.compliance.checks.readme).toBeNull() + expect(res.pillars.compliance.score).toBe(100) // 1/1 known check passed + expect(res.pillars.compliance.label).toContain('Limited data') + }) + }) + + describe('Activity Freshness Pillar', () => { + it('scores 100 for today push', () => { + const repo = { name: 'repo1', orgLogin: 'org1', pushed_at: daysAgoISO(0) } + const res = computeRepoHealthScore(repo) + expect(res.pillars.freshness.score).toBe(100) + }) + + it('scores ~92 for 30 days ago push', () => { + const repo = { name: 'repo1', orgLogin: 'org1', pushed_at: daysAgoISO(30) } + const res = computeRepoHealthScore(repo) + expect(res.pillars.freshness.score).toBe(92) + }) + + it('scores ~51 for 180 days ago push', () => { + const repo = { name: 'repo1', orgLogin: 'org1', pushed_at: daysAgoISO(180) } + const res = computeRepoHealthScore(repo) + expect(res.pillars.freshness.score).toBe(51) + expect(res.pillars.freshness.riskLevel).toBe('warning') + }) + + it('scores 0 for 365+ days ago push', () => { + const repo = { name: 'repo1', orgLogin: 'org1', pushed_at: daysAgoISO(400) } + const res = computeRepoHealthScore(repo) + expect(res.pillars.freshness.score).toBe(0) + expect(res.pillars.freshness.riskLevel).toBe('critical') + }) + }) + + describe('Responsiveness Pillar', () => { + it('scores 100 when there are no stale issues', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const issues = [ + { state: 'open', updated_at: daysAgoISO(5) }, + { state: 'open', updated_at: daysAgoISO(10) } + ] + const res = computeRepoHealthScore(repo, issues) + expect(res.pillars.responsiveness.score).toBe(100) + }) + + it('penalizes stale issue ratio', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const issues = [ + { state: 'open', updated_at: daysAgoISO(100) }, // stale + { state: 'open', updated_at: daysAgoISO(5) } + ] + const res = computeRepoHealthScore(repo, issues) + // 50% stale -> baseScore = 50 + expect(res.pillars.responsiveness.score).toBe(50) + expect(res.pillars.responsiveness.staleRatio).toBe(0.5) + }) + + it('applies zombie PR penalty', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const issues = [ + { state: 'open', updated_at: daysAgoISO(5) }, + { pull_request: {}, state: 'open', created_at: daysAgoISO(100) }, // zombie PR (-5) + { pull_request: {}, state: 'open', created_at: daysAgoISO(120) } // zombie PR (-5) + ] + const res = computeRepoHealthScore(repo, issues) + // baseScore = 100, zombiePenalty = 10 -> score = 90 + expect(res.pillars.responsiveness.score).toBe(90) + }) + + it('caps zombie PR penalty at 40 points', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const issues = Array.from({ length: 9 }, () => ({ + pull_request: {}, + state: 'open', + created_at: daysAgoISO(100) + })) + const res = computeRepoHealthScore(repo, issues) + // baseScore = 100, 9 * 5 = 45 -> capped at 40 penalty -> score = 60 + expect(res.pillars.responsiveness.score).toBe(60) + }) + + it('clamps responsiveness score at 0 when stale ratio and penalty exceed 100', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const issues = [ + { state: 'open', updated_at: daysAgoISO(100) }, // 100% stale -> baseScore = 0 + ...Array.from({ length: 9 }, () => ({ + pull_request: {}, + state: 'open', + created_at: daysAgoISO(100) + })) // max penalty = 40 + ] + const res = computeRepoHealthScore(repo, issues) + // baseScore = 0, penalty = 40 -> max(0, -40) = 0 + expect(res.pillars.responsiveness.score).toBe(0) + expect(res.pillars.responsiveness.riskLevel).toBe('critical') + }) + }) + + describe('PR Resolution Rate Pillar', () => { + it('scores 100 when all closed PRs were merged', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const pulls = [ + { state: 'closed', merged_at: daysAgoISO(10) }, + { state: 'closed', merged_at: daysAgoISO(20) } + ] + const res = computeRepoHealthScore(repo, [], pulls) + expect(res.pillars.prResolution.score).toBe(100) + expect(res.pillars.prResolution.mergeRate).toBe(1) + }) + + it('calculates merge rate for mixed merged/unmerged closed PRs', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const pulls = [ + { state: 'closed', merged_at: daysAgoISO(10) }, + { state: 'closed', merged_at: daysAgoISO(20) }, + { state: 'closed', merged_at: null }, + { state: 'closed', merged_at: null } + ] + const res = computeRepoHealthScore(repo, [], pulls) + expect(res.pillars.prResolution.score).toBe(50) + expect(res.pillars.prResolution.mergeRate).toBe(0.5) + }) + + it('returns score: null when there is insufficient PR history', () => { + const repo = { name: 'repo1', orgLogin: 'org1' } + const pulls = [ + { state: 'open', merged_at: null } + ] + const res = computeRepoHealthScore(repo, [], pulls) + expect(res.pillars.prResolution.score).toBeNull() + expect(res.pillars.prResolution.label).toBe('Insufficient data') + }) + }) + + describe('Overall Score & Weight Normalization', () => { + it('normalizes weights correctly when PR resolution data is unavailable', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [{ login: 'u1', contributions: 20 }, { login: 'u2', contributions: 20 }, { login: 'u3', contributions: 20 }, { login: 'u4', contributions: 40 }], // score 80 + pushed_at: daysAgoISO(0), // freshness score 100 + license: { key: 'mit' }, + has_readme: true // compliance score 100 + } + const issues = [{ state: 'open', updated_at: daysAgoISO(5) }] // responsiveness score 100 + // prResolution score: null + const res = computeRepoHealthScore(repo, issues, []) + + // Available weights: 20 * 4 = 80. Weighted sum: 80*20 + 100*20 + 100*20 + 100*20 = 7600 + // 7600 / 80 = 95 + expect(res.overallScore).toBe(95) + expect(res.riskLevel).toBe('healthy') + }) + }) + + describe('Recommendations Engine', () => { + it('generates recommendations for single maintainer, missing license, stale issues', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [{ login: 'u1', contributions: 100 }], // single maintainer + license: null, // missing license + has_readme: true, + pushed_at: daysAgoISO(200) // inactive > 180 days + } + const issues = [ + { state: 'open', updated_at: daysAgoISO(100) }, + { state: 'open', updated_at: daysAgoISO(120) } + ] // 100% stale issues + const pulls = [ + { state: 'closed', merged_at: null }, + { state: 'closed', merged_at: null }, + { state: 'closed', merged_at: null }, + { state: 'closed', merged_at: daysAgoISO(10) } + ] // 25% merge rate + + const res = computeRepoHealthScore(repo, issues, pulls) + const msgs = res.recommendations.map(r => r.message) + + expect(msgs).toContain('Single maintainer risk detected — recruit additional contributors') + expect(msgs).toContain('No license found — add a license to clarify open-source usage') + expect(msgs).toContain('No recent commits — consider re-activating or archiving the repository') + expect(msgs).toContain('Over 50% of open issues are stale — consider a triage sprint') + expect(msgs).toContain('Low PR merge rate — review PR acceptance criteria or contributor guidance') + }) + + it('returns empty recommendations for a healthy repository', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + contributors: [ + { login: 'u1', contributions: 25 }, + { login: 'u2', contributions: 25 }, + { login: 'u3', contributions: 25 }, + { login: 'u4', contributions: 25 } + ], + license: { key: 'mit' }, + has_readme: true, + has_contributing: true, + has_security: true, + pushed_at: daysAgoISO(5) + } + const issues = [{ state: 'open', updated_at: daysAgoISO(5) }] + const pulls = [{ state: 'closed', merged_at: daysAgoISO(5) }] + const res = computeRepoHealthScore(repo, issues, pulls) + expect(res.recommendations).toHaveLength(0) + }) + + it('generates recommendations for missing README, CONTRIBUTING, and SECURITY files', () => { + const repo = { + name: 'repo1', + orgLogin: 'org1', + license: { key: 'mit' }, + has_readme: false, + has_contributing: false, + has_security: false, + contributors: [ + { login: 'u1', contributions: 25 }, + { login: 'u2', contributions: 25 }, + { login: 'u3', contributions: 25 }, + { login: 'u4', contributions: 25 } + ], + pushed_at: daysAgoISO(5) + } + const res = computeRepoHealthScore(repo) + const msgs = res.recommendations.map(r => r.message) + expect(msgs).toContain('Add README.md to describe project purpose and setup') + expect(msgs).toContain('Add CONTRIBUTING.md to guide new contributors') + expect(msgs).toContain('Add SECURITY.md to define the vulnerability disclosure process') + }) + }) +}) diff --git a/src/services/github.js b/src/services/github.js index a4180fa..2b7f0c8 100644 --- a/src/services/github.js +++ b/src/services/github.js @@ -143,3 +143,26 @@ export async function fetchRateLimit(pat) { return data.rate } catch { return null } } + +export async function fetchRepoFilePresence(org, repo, pat) { + if (!pat) return null + try { + const checkFile = async (filename) => { + try { + const url = `https://api.github.com/repos/${org}/${repo}/contents/${filename}` + await fetchWithCache(url, pat) + return true + } catch { + return false + } + } + const [contributing, security] = await Promise.all([ + checkFile('CONTRIBUTING.md'), + checkFile('SECURITY.md') + ]) + return { contributing, security } + } catch { + return null + } +} +