setSelectedRepoForHealth(null)}
+ />
)
}
diff --git a/src/pages/RepositoriesPage.jsx b/src/pages/RepositoriesPage.jsx
index 9c035e38..439d96b5 100644
--- a/src/pages/RepositoriesPage.jsx
+++ b/src/pages/RepositoriesPage.jsx
@@ -9,6 +9,7 @@ import EmptyStateCard from '../components/EmptyStateCard'
import { useNavigate } from 'react-router-dom'
import AnalysisBanner from '../components/AnalysisBanner'
import { RepositorySkeleton } from '../components/Orgexplorerskeletons';
+import RepoHealthDrawer from '../components/RepoHealthDrawer'
const ACTIVITY_CLASSIFICATIONS = ['All', 'Thriving', 'Active', 'Dormant', 'Hibernating']
const ACTIVITY_COLORS = { Thriving: 'var(--green)', Active: 'var(--blue)', Dormant: 'var(--amber)', Hibernating: 'var(--red)' }
@@ -21,6 +22,7 @@ export default function RepositoriesPage() {
const [orgFilter, setOrgFilter] = useState('All Organizations')
const [shown, setShown] = useState(20)
const [openInfo, setOpenInfo] = useState(false)
+ const [selectedRepoForHealth, setSelectedRepoForHealth] = useState(null)
const infoRef = useRef(null)
useEffect(() => {
@@ -242,7 +244,15 @@ export default function RepositoriesPage() {
| {r.stargazers_count.toLocaleString()} |
{r.forks_count.toLocaleString()} |
30 ? 'var(--red)' : 'var(--text2)' }}>{r.open_issues_count} |
- |
+
+ setSelectedRepoForHealth(r)}
+ isInteractive={true}
+ title={`Click to inspect health breakdown for ${r.name}`}
+ ariaLabel={`${r.name}: health score ${r.healthScore} out of 100. Open breakdown.`}
+ />
+ |
@@ -274,6 +284,12 @@ export default function RepositoriesPage() {
/>
)}
+
+ setSelectedRepoForHealth(null)}
+ />
)
}
diff --git a/src/services/analytics.buildAnalyticalModel.test.js b/src/services/analytics.buildAnalyticalModel.test.js
index 85cb58ae..100dbb5a 100644
--- a/src/services/analytics.buildAnalyticalModel.test.js
+++ b/src/services/analytics.buildAnalyticalModel.test.js
@@ -28,8 +28,11 @@ describe('buildAnalyticalModel', () => {
const result = buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalReposPerOrg)
expect(result.totalRepos).toHaveLength(2)
+ const repoAResult = result.totalRepos.find(r => r.name === 'repo-a')
const repoBResult = result.totalRepos.find(r => r.name === 'repo-b')
+ expect(repoAResult.contributorsFetched).toBe(true)
expect(repoBResult.contributors).toEqual([])
+ expect(repoBResult.contributorsFetched).toBe(false)
expect(repoBResult.busFactor).toEqual({ factor: 0, risk: 'unknown' })
expect(repoBResult.orgLogin).toBe('org-a')
})
diff --git a/src/services/analytics.healthMetrics.test.js b/src/services/analytics.healthMetrics.test.js
index 44781173..cc953709 100644
--- a/src/services/analytics.healthMetrics.test.js
+++ b/src/services/analytics.healthMetrics.test.js
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest'
import {
computeHealthScore,
+ computeHealthBreakdown,
+ getHealthRecommendations,
computeActivityClassification,
computeBusFactor,
} from './analytics'
@@ -75,6 +77,12 @@ describe('computeActivityClassification', () => {
it('treats the exact 30-day boundary as Thriving (inclusive)', () => {
expect(computeActivityClassification({ pushed_at: daysAgoISO(30) })).toBe('Thriving')
})
+
+ it('returns Unknown when pushed_at is missing or invalid', () => {
+ expect(computeActivityClassification({})).toBe('Unknown')
+ expect(computeActivityClassification({ pushed_at: null })).toBe('Unknown')
+ expect(computeActivityClassification({ pushed_at: 'not-a-valid-date' })).toBe('Unknown')
+ })
})
describe('computeBusFactor', () => {
@@ -120,3 +128,83 @@ describe('computeBusFactor', () => {
})
})
+describe('computeHealthBreakdown', () => {
+ it('returns breakdown matching overall computeHealthScore', () => {
+ const repo = {
+ pushed_at: daysAgoISO(10),
+ open_issues_count: 5,
+ }
+ const score = computeHealthScore(repo, 3)
+ const breakdown = computeHealthBreakdown(repo, 3)
+
+ expect(breakdown.overall).toBe(score)
+ expect(breakdown.categories).toHaveLength(3)
+
+ const [activity, issues, diversity] = breakdown.categories
+ expect(activity.id).toBe('activity')
+ expect(activity.weight).toBe(0.4)
+ expect(issues.id).toBe('issues')
+ expect(issues.weight).toBe(0.3)
+ expect(diversity.id).toBe('diversity')
+ expect(diversity.weight).toBe(0.3)
+ })
+
+ it('handles missing or invalid pushed_at gracefully', () => {
+ const repo = { pushed_at: null, open_issues_count: 0 }
+ const breakdown = computeHealthBreakdown(repo, 0)
+ const activity = breakdown.categories.find(c => c.id === 'activity')
+ expect(activity.score).toBe(0)
+ expect(activity.metrics.find(m => m.label === 'Days Since Push').value).toBe('Unknown')
+ expect(activity.metrics.find(m => m.label === 'Status').value).toBe('Unknown')
+ expect(breakdown.overall).toBe(30) // issueHealth 100 * 0.3
+ })
+
+ it('handles unfetched / unavailable contributor data gracefully without diversity penalty', () => {
+ const repo = { pushed_at: daysAgoISO(10), open_issues_count: 0 }
+ const breakdown = computeHealthBreakdown(repo, null)
+ const diversity = breakdown.categories.find(c => c.id === 'diversity')
+ expect(diversity.score).toBeNull()
+ expect(diversity.isAvailable).toBe(false)
+ expect(diversity.metrics.find(m => m.label === 'Contributors').value).toBe('Unavailable')
+ expect(breakdown.overall).toBeGreaterThanOrEqual(90)
+ })
+})
+
+describe('getHealthRecommendations', () => {
+ it('flags hibernating repos, high issues, single contributor, and missing license', () => {
+ const repo = {
+ pushed_at: daysAgoISO(200),
+ open_issues_count: 45,
+ license: null,
+ description: null,
+ }
+ const recs = getHealthRecommendations(repo, 1)
+
+ expect(recs.some(r => r.category === 'Activity' && r.type === 'critical')).toBe(true)
+ expect(recs.some(r => r.category === 'Issues' && r.type === 'critical')).toBe(true)
+ expect(recs.some(r => r.category === 'Community' && r.type === 'critical')).toBe(true)
+ expect(recs.some(r => r.category === 'Governance' && r.type === 'warning')).toBe(true)
+ expect(recs.some(r => r.category === 'Documentation' && r.type === 'optimization')).toBe(true)
+ })
+
+ it('recognizes healthy activity, clean backlog, and good community', () => {
+ const repo = {
+ pushed_at: daysAgoISO(5),
+ open_issues_count: 0,
+ license: { name: 'MIT' },
+ description: 'An open-source tool',
+ }
+ const recs = getHealthRecommendations(repo, 12)
+
+ expect(recs.some(r => r.category === 'Activity' && r.type === 'good')).toBe(true)
+ expect(recs.some(r => r.category === 'Issues' && r.type === 'good')).toBe(true)
+ expect(recs.some(r => r.category === 'Community' && r.type === 'good')).toBe(true)
+ expect(recs.some(r => r.type === 'critical')).toBe(false)
+ })
+
+ it('omits community maintainer recommendations when contributor data is unavailable (null)', () => {
+ const repo = { pushed_at: daysAgoISO(10), open_issues_count: 0 }
+ const recs = getHealthRecommendations(repo, null)
+ expect(recs.some(r => r.category === 'Community')).toBe(false)
+ })
+})
diff --git a/src/services/analytics.js b/src/services/analytics.js
index 8546455e..ea32114e 100644
--- a/src/services/analytics.js
+++ b/src/services/analytics.js
@@ -1,17 +1,209 @@
// Repo Health Indicator
-// Activity (40%) + Issue Health (30%) + Diversity (30%)
+// Derived directly from computeHealthBreakdown for consistent single-source scoring
export function computeHealthScore(repo, contributorCount = 0) {
- const daysSince = (Date.now() - new Date(repo.pushed_at)) / 86_400_000
- const activity = Math.max(0, 100 - daysSince)
- const total = (repo.open_issues_count || 0) + 10
- const issueHealth = Math.max(0, 100 - (repo.open_issues_count / total) * 100)
- const diversity = Math.min(100, contributorCount * 10)
- return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3)
+ return computeHealthBreakdown(repo, contributorCount).overall
+}
+
+/**
+ * Detailed breakdown of health score components, weights, metrics, and formula.
+ */
+export function computeHealthBreakdown(repo, contributorCount = 0) {
+ const pushedAtMs = repo?.pushed_at ? new Date(repo.pushed_at).getTime() : NaN
+ const hasPush = Number.isFinite(pushedAtMs)
+ const daysSince = hasPush ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365
+ const activity = hasPush ? Math.max(0, 100 - daysSince) : 0
+
+ const openIssues = repo?.open_issues_count || 0
+ const total = openIssues + 10
+ const issueHealth = Math.max(0, 100 - (openIssues / total) * 100)
+
+ const hasContributorData = typeof contributorCount === 'number' && Number.isFinite(contributorCount)
+ const diversity = hasContributorData ? Math.min(100, Math.max(0, contributorCount) * 10) : null
+
+ let overall = 0
+ let weights = { activity: 0.4, issues: 0.3, diversity: 0.3 }
+
+ if (hasContributorData) {
+ const activityWeighted = activity * 0.4
+ const issueHealthWeighted = issueHealth * 0.3
+ const diversityWeighted = diversity * 0.3
+ overall = Math.round(activityWeighted + issueHealthWeighted + diversityWeighted)
+ } else {
+ // When contributor data was not fetched, normalize across available dimensions
+ const normActivityWeight = 0.4 / 0.7
+ const normIssueWeight = 0.3 / 0.7
+ weights = { activity: normActivityWeight, issues: normIssueWeight, diversity: 0 }
+ overall = Math.round(activity * normActivityWeight + issueHealth * normIssueWeight)
+ }
+
+ return {
+ overall,
+ weights,
+ categories: [
+ {
+ id: 'activity',
+ name: 'Activity Health',
+ score: Math.round(activity),
+ weight: hasContributorData ? 0.4 : Number((0.4 / 0.7).toFixed(2)),
+ weightedScore: Number((activity * (hasContributorData ? 0.4 : 0.4 / 0.7)).toFixed(1)),
+ metrics: [
+ { label: 'Last Push', value: repo?.pushed_at ? repo.pushed_at.slice(0, 10) : 'No recorded push' },
+ { label: 'Days Since Push', value: hasPush ? Math.floor(daysSince) : 'Unknown' },
+ { label: 'Status', value: hasPush ? computeActivityClassification(repo) : 'Unknown' }
+ ],
+ description: 'Measures recent maintenance activity and commit momentum. Repositories updated within the last 30 days earn the highest score.'
+ },
+ {
+ id: 'issues',
+ name: 'Issue Health',
+ score: Math.round(issueHealth),
+ weight: hasContributorData ? 0.3 : Number((0.3 / 0.7).toFixed(2)),
+ weightedScore: Number((issueHealth * (hasContributorData ? 0.3 : 0.3 / 0.7)).toFixed(1)),
+ metrics: [
+ { label: 'Open Issues', value: openIssues },
+ { label: 'Issue Load Ratio', value: `${Math.round((openIssues / total) * 100)}%` }
+ ],
+ description: 'Evaluates issue maintenance burden. A lower backlog relative to project scale yields a healthier score.'
+ },
+ {
+ id: 'diversity',
+ name: 'Contributor Diversity',
+ score: hasContributorData ? Math.round(diversity) : null,
+ weight: hasContributorData ? 0.3 : 0,
+ weightedScore: hasContributorData ? Number((diversity * 0.3).toFixed(1)) : null,
+ isAvailable: hasContributorData,
+ metrics: [
+ { label: 'Contributors', value: hasContributorData ? contributorCount : 'Unavailable' },
+ { label: 'Target Base', value: '10+ contributors' }
+ ],
+ description: hasContributorData
+ ? 'Reflects contributor spread and project resilience. Projects with 10 or more contributors reach maximum diversity score.'
+ : 'Contributor data was not fetched for this repository. Connect a GitHub PAT in Settings to analyze full contributor diversity.'
+ }
+ ]
+ }
+}
+
+/**
+ * Actionable recommendations based on repository signals and metrics.
+ */
+export function getHealthRecommendations(repo, contributorCount = 0) {
+ const recommendations = []
+ const pushedAtMs = repo?.pushed_at ? new Date(repo.pushed_at).getTime() : NaN
+ const hasPush = Number.isFinite(pushedAtMs)
+ const daysSince = hasPush ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365
+ const openIssues = repo?.open_issues_count || 0
+
+ // 1. Activity & Recency
+ if (!hasPush) {
+ recommendations.push({
+ type: 'critical',
+ category: 'Activity',
+ title: 'No Recorded Push Activity',
+ description: 'No push date is recorded for this repository. Push a commit to establish activity history.'
+ })
+ } else if (daysSince > 180) {
+ recommendations.push({
+ type: 'critical',
+ category: 'Activity',
+ title: 'Resume Development & Push Updates',
+ description: `Repository has been inactive for ${Math.floor(daysSince)} days (Hibernating). Regular commits and maintenance prevent code rot and signal active stewardship.`
+ })
+ } else if (daysSince > 90) {
+ recommendations.push({
+ type: 'warning',
+ category: 'Activity',
+ title: 'Address Inactivity',
+ description: `Last push was ${Math.floor(daysSince)} days ago (Dormant). Pushing routine dependency upgrades or bug fixes will help restore Active status.`
+ })
+ } else if (daysSince <= 30) {
+ recommendations.push({
+ type: 'good',
+ category: 'Activity',
+ title: 'Strong Development Momentum',
+ description: 'Recent pushes within the last 30 days demonstrate ongoing active maintenance.'
+ })
+ }
+
+ // 2. Issue Health
+ if (openIssues > 40) {
+ recommendations.push({
+ type: 'critical',
+ category: 'Issues',
+ title: 'Triage High Issue Backlog',
+ description: `There are ${openIssues} open issues. A large unresolved backlog can discourage contributors and slow release cycles.`
+ })
+ } else if (openIssues > 20) {
+ recommendations.push({
+ type: 'warning',
+ category: 'Issues',
+ title: 'Review Open Issues',
+ description: `There are ${openIssues} open issues. Consider tagging stale issues, grouping similar bug reports, or marking 'good first issue' tasks.`
+ })
+ } else if (openIssues === 0) {
+ recommendations.push({
+ type: 'good',
+ category: 'Issues',
+ title: 'Clean Issue Backlog',
+ description: 'Zero open issues indicate high responsiveness and prompt resolution.'
+ })
+ }
+
+ // 3. Contributor Diversity / Resilience
+ if (typeof contributorCount === 'number' && Number.isFinite(contributorCount)) {
+ if (contributorCount <= 1) {
+ recommendations.push({
+ type: 'critical',
+ category: 'Community',
+ title: 'Mitigate Single Maintainer Risk',
+ description: `${contributorCount === 0 ? 'No contributors are' : 'Only 1 contributor is'} recorded. Onboarding co-maintainers or reviewing external PRs is crucial to prevent single point of failure.`
+ })
+ } else if (contributorCount < 5) {
+ recommendations.push({
+ type: 'warning',
+ category: 'Community',
+ title: 'Expand Contributor Base',
+ description: `Only ${contributorCount} contributor(s) recorded. Promoting community contributions will improve diversity and resilience.`
+ })
+ } else if (contributorCount >= 10) {
+ recommendations.push({
+ type: 'good',
+ category: 'Community',
+ title: 'Healthy Contributor Community',
+ description: `${contributorCount} contributors active across the repository provide strong organizational stability.`
+ })
+ }
+ }
+
+ // 4. Governance & Documentation
+ if (!repo?.license) {
+ recommendations.push({
+ type: 'warning',
+ category: 'Governance',
+ title: 'Add an Open Source License',
+ description: 'No license detected. Without an explicit open-source license (such as MIT or Apache 2.0), third parties may hesitate to adopt or contribute.'
+ })
+ }
+
+ if (!repo?.description) {
+ recommendations.push({
+ type: 'optimization',
+ category: 'Documentation',
+ title: 'Add Repository Description & Topics',
+ description: 'Providing a clear summary and discoverability tags helps developers and automated tools understand the project purpose.'
+ })
+ }
+
+ // Sort order: critical first, then warning, optimization, good
+ const PRIORITY = { critical: 0, warning: 1, optimization: 2, good: 3 }
+ return recommendations.sort((a, b) => PRIORITY[a.type] - PRIORITY[b.type])
}
// Repo Lifecycle — Thriving, Active, Dormant, Hibernating based on recency of last push
export function computeActivityClassification(repo) {
- const days = (Date.now() - new Date(repo.pushed_at)) / 86_400_000
+ const pushedAtMs = repo?.pushed_at ? new Date(repo.pushed_at).getTime() : NaN
+ if (!Number.isFinite(pushedAtMs)) return 'Unknown'
+ const days = (Date.now() - pushedAtMs) / 86_400_000
if (days <= 30) return 'Thriving'
if (days <= 90) return 'Active'
if (days <= 180) return 'Dormant'
@@ -49,17 +241,31 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalRe
total.forEach(repo => {
const key = `${org.login}/${repo.name}`
- const contribs = contribsPerRepo[key] || []
- const health = computeHealthScore(repo, contribs.length)
+ const hasContributors = Boolean(contribsPerRepo && Object.prototype.hasOwnProperty.call(contribsPerRepo, key))
+ const contribs = hasContributors ? (contribsPerRepo[key] || []) : []
+ const health = computeHealthScore(repo, hasContributors ? contribs.length : null)
const activityClassification = computeActivityClassification(repo)
const bf = computeBusFactor(contribs)
- totalRepos.push({ ...repo, orgLogin: org.login, contributors: contribs, healthScore: health, activityClassification: activityClassification, busFactor: bf })
+ totalRepos.push({
+ ...repo,
+ orgLogin: org.login,
+ contributors: contribs,
+ contributorsFetched: hasContributors,
+ healthScore: health,
+ activityClassification: activityClassification,
+ busFactor: bf,
+ })
})
repos.forEach(repo => {
const key = `${org.login}/${repo.name}`
- const contribs = contribsPerRepo[key] || []
- allRepos.push({ ...repo, orgLogin: org.login });
+ const hasContributors = Boolean(contribsPerRepo && Object.prototype.hasOwnProperty.call(contribsPerRepo, key))
+ const contribs = hasContributors ? (contribsPerRepo[key] || []) : []
+ allRepos.push({
+ ...repo,
+ orgLogin: org.login,
+ contributorsFetched: hasContributors,
+ });
// Build contributor map — deduplicated by login across orgs
contribs.forEach(c => {
|