diff --git a/src/components/RepoHealthDrawer.jsx b/src/components/RepoHealthDrawer.jsx new file mode 100644 index 00000000..c9d179cf --- /dev/null +++ b/src/components/RepoHealthDrawer.jsx @@ -0,0 +1,668 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { + FiX, + FiExternalLink, + FiActivity, + FiAlertCircle, + FiUsers, + FiCheckCircle, + FiAlertTriangle, + FiInfo, + FiShield, + FiCode, + FiStar, + FiGitPullRequest, + FiCalendar, + FiBookOpen +} from 'react-icons/fi' +import { C } from './UI' +import { computeHealthBreakdown, getHealthRecommendations } from '../services/analytics' + +export default function RepoHealthDrawer({ repo, onClose, isOpen }) { + const [activeTab, setActiveTab] = useState('breakdown') + const drawerRef = useRef(null) + const previousActiveElementRef = useRef(null) + + // Reset active tab to breakdown on open or when repository changes + useEffect(() => { + if (isOpen) { + setActiveTab('breakdown') + } + }, [isOpen, repo?.name, repo?.orgLogin]) + + // Focus trap and focus restoration + useEffect(() => { + if (isOpen) { + previousActiveElementRef.current = document.activeElement + drawerRef.current?.focus() + } else if (previousActiveElementRef.current) { + previousActiveElementRef.current.focus?.() + previousActiveElementRef.current = null + } + }, [isOpen]) + + // Handle ESC key and Tab focus trapping + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose?.() + return + } + + if (e.key === 'Tab' && drawerRef.current) { + const focusableEls = drawerRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + if (!focusableEls.length) return + + const firstEl = focusableEls[0] + const lastEl = focusableEls[focusableEls.length - 1] + + if (e.shiftKey) { + if (document.activeElement === firstEl || document.activeElement === drawerRef.current) { + e.preventDefault() + lastEl.focus() + } + } else { + if (document.activeElement === lastEl) { + e.preventDefault() + firstEl.focus() + } + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + // Prevent background scrolling when drawer is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + const hasContributorData = useMemo(() => { + if (!repo) return false + if (typeof repo.contributorsFetched === 'boolean') { + return repo.contributorsFetched + } + if (typeof repo.hasContributorData === 'boolean') { + return repo.hasContributorData + } + if (Array.isArray(repo.contributors) && repo.contributors.length > 0) { + return true + } + if (typeof repo.contributors_count === 'number') { + return true + } + return false + }, [repo]) + + const contributorCount = useMemo(() => { + if (!hasContributorData) return null + if (Array.isArray(repo?.contributors)) return repo.contributors.length + if (typeof repo?.contributors_count === 'number') return repo.contributors_count + return 0 + }, [repo, hasContributorData]) + + const breakdown = useMemo(() => { + if (!repo) return null + return computeHealthBreakdown(repo, contributorCount) + }, [repo, contributorCount]) + + const recommendations = useMemo(() => { + if (!repo) return [] + return getHealthRecommendations(repo, contributorCount) + }, [repo, contributorCount]) + + if (!isOpen || !repo) return null + + const overallScore = breakdown?.overall ?? repo.healthScore ?? 0 + const scoreColor = overallScore >= 70 ? 'var(--green)' : overallScore >= 40 ? 'var(--amber)' : 'var(--red)' + const scorePillBg = overallScore >= 70 + ? 'rgba(34, 197, 94, 0.15)' + : overallScore >= 40 + ? 'rgba(245, 158, 11, 0.15)' + : 'rgba(239, 68, 68, 0.15)' + const scoreRating = overallScore >= 70 ? 'Healthy' : overallScore >= 40 ? 'Moderate Risk' : 'Needs Attention' + + const REC_STYLES = { + critical: { + border: 'var(--red)', + bg: 'rgba(239, 68, 68, 0.08)', + pillBg: 'rgba(239, 68, 68, 0.15)', + badge: 'Critical', + icon: FiAlertCircle, + color: 'var(--red)', + }, + warning: { + border: 'var(--amber)', + bg: 'rgba(245, 158, 11, 0.08)', + pillBg: 'rgba(245, 158, 11, 0.15)', + badge: 'Warning', + icon: FiAlertTriangle, + color: 'var(--amber)', + }, + optimization: { + border: 'var(--blue)', + bg: 'rgba(59, 130, 246, 0.08)', + pillBg: 'rgba(59, 130, 246, 0.15)', + badge: 'Optimization', + icon: FiInfo, + color: 'var(--blue)', + }, + good: { + border: 'var(--green)', + bg: 'rgba(34, 197, 94, 0.08)', + pillBg: 'rgba(34, 197, 94, 0.15)', + badge: 'Good Practice', + icon: FiCheckCircle, + color: 'var(--green)', + }, + } + + const rawMetricsList = [ + { label: 'Repository', value: repo.name, icon: FiBookOpen }, + { label: 'Organization', value: repo.orgLogin || 'N/A', icon: FiUsers }, + { label: 'Primary Language', value: repo.language || 'Not specified', icon: FiCode }, + { label: 'Stars', value: (repo.stargazers_count ?? 0).toLocaleString(), icon: FiStar }, + { label: 'Forks', value: (repo.forks_count ?? 0).toLocaleString(), icon: FiGitPullRequest }, + { label: 'Open Issues', value: (repo.open_issues_count ?? 0).toLocaleString(), icon: FiAlertCircle }, + { + label: 'License', + value: repo.license?.spdx_id || repo.license?.name || (typeof repo.license === 'string' ? repo.license : 'None detected'), + icon: FiShield, + }, + { label: 'Default Branch', value: repo.default_branch || 'main', icon: FiCode }, + { + label: 'Activity Classification', + value: repo.pushed_at && Number.isFinite(new Date(repo.pushed_at).getTime()) + ? (repo.activityClassification || 'Unknown') + : 'Unknown', + icon: FiActivity, + }, + { + label: 'Recorded Contributors', + value: hasContributorData && typeof contributorCount === 'number' + ? contributorCount.toString() + : 'Unavailable', + icon: FiUsers, + }, + { + label: 'Bus Factor Risk', + value: repo.busFactor?.risk ? repo.busFactor.risk.toUpperCase() : 'UNKNOWN', + icon: FiShield, + }, + { + label: 'Last Push Date', + value: repo.pushed_at ? new Date(repo.pushed_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : 'Never', + icon: FiCalendar, + }, + { + label: 'Created Date', + value: repo.created_at ? new Date(repo.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : 'Unknown', + icon: FiCalendar, + }, + ] + + return ( +
+ {/* Backdrop */} +
+ + {/* Drawer dialog panel */} +
+ {/* Header */} +
+
+ Repository Health Inspector + +
+ +
+
+

+ {repo.name} + {repo.html_url && ( + + + )} +

+ {repo.orgLogin && ( +
+ Organization: {repo.orgLogin} +
+ )} +
+ +
+
+ {overallScore} + / 100 +
+ + {scoreRating.toUpperCase()} + +
+
+ + {/* Navigation tabs */} +
+ {[ + { id: 'breakdown', label: 'Score Breakdown' }, + { id: 'recommendations', label: `Recommendations (${recommendations.length})` }, + { id: 'raw', label: 'Raw Metrics' }, + ].map((tab) => { + const active = activeTab === tab.id + return ( + + ) + })} +
+
+ + {/* Tab Body */} +
+ {/* TAB 1: BREAKDOWN */} + {activeTab === 'breakdown' && ( +
+ {/* Score formula card */} +
+
+ + Scoring Formula +
+

+ The repository composite health score is calculated as a weighted average across three key dimensions: +

+
+ Activity ({breakdown?.weights?.activity ? Math.round(breakdown.weights.activity * 100) : (hasContributorData ? 40 : 57)}%) + + + Issue Health ({breakdown?.weights?.issues ? Math.round(breakdown.weights.issues * 100) : (hasContributorData ? 30 : 43)}%) + + + Diversity ({hasContributorData ? `${breakdown?.weights?.diversity ? Math.round(breakdown.weights.diversity * 100) : 30}%` : 'Unavailable'}) +
+
+ + {/* Category cards */} + {breakdown?.categories.map((cat) => { + const isAvailable = cat.score !== null && cat.score !== undefined + const catColor = !isAvailable + ? 'var(--text2)' + : cat.score >= 70 ? 'var(--green)' : cat.score >= 40 ? 'var(--amber)' : 'var(--red)' + return ( +
+
+
+ {cat.name} + + {isAvailable ? `${Math.round(cat.weight * 100)}% WEIGHT` : 'UNAVAILABLE'} + +
+
+ {isAvailable ? ( + <> + {cat.score} + + (+{cat.weightedScore} pts) + + + ) : ( + + Unavailable + + )} +
+
+ + {/* Progress bar */} +
+
+
+ +

+ {cat.description} +

+ + {/* Metrics grid */} +
+ {cat.metrics.map((m, i) => ( +
+ {m.label} + {m.value} +
+ ))} +
+
+ ) + })} +
+ )} + + {/* TAB 2: RECOMMENDATIONS */} + {activeTab === 'recommendations' && ( +
+ {recommendations.length === 0 ? ( +
+ +

No current recommendations

+

This repository is meeting all active health benchmarks.

+
+ ) : ( + recommendations.map((rec, i) => { + const styleCfg = REC_STYLES[rec.type] || REC_STYLES.warning + const Icon = styleCfg.icon + return ( +
+
+
+ + {rec.title} +
+ + {styleCfg.badge.toUpperCase()} + +
+

+ {rec.description} +

+
+ Category: {rec.category} +
+
+ ) + }) + )} +
+ )} + + {/* TAB 3: RAW METRICS */} + {activeTab === 'raw' && ( +
+

+ Direct repository metrics and indicators obtained from GitHub API. +

+
+ + + {rawMetricsList.map((metric, i) => { + const Icon = metric.icon + return ( + + + + + ) + })} + +
+ + {metric.label} + + {metric.value} +
+
+
+ )} +
+
+
+ ) +} diff --git a/src/components/RepoHealthDrawer.test.jsx b/src/components/RepoHealthDrawer.test.jsx new file mode 100644 index 00000000..91e808a8 --- /dev/null +++ b/src/components/RepoHealthDrawer.test.jsx @@ -0,0 +1,171 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import RepoHealthDrawer from './RepoHealthDrawer' + +const sampleRepo = { + name: 'OrgExplorer', + orgLogin: 'AOSSIE-Org', + healthScore: 61, + pushed_at: new Date(Date.now() - 5 * 86_400_000).toISOString(), + created_at: '2024-01-01T00:00:00Z', + stargazers_count: 150, + forks_count: 45, + open_issues_count: 12, + language: 'JavaScript', + default_branch: 'main', + html_url: 'https://github.com/AOSSIE-Org/OrgExplorer', + license: { name: 'MIT License', spdx_id: 'MIT' }, + activityClassification: 'Thriving', + contributors: [ + { login: 'contributor1', contributions: 20 }, + { login: 'contributor2', contributions: 15 }, + { login: 'contributor3', contributions: 10 }, + ], + busFactor: { factor: 2, risk: 'high' }, +} + +describe('RepoHealthDrawer', () => { + it('does not render when isOpen is false', () => { + const { container } = render( + {}} /> + ) + expect(container.firstChild).toBeNull() + }) + + it('renders modal dialog with accessible name, receives initial focus, and displays computed score', () => { + render( {}} />) + + const dialog = screen.getByRole('dialog', { name: /OrgExplorer/i }) + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveFocus() + + expect(screen.getByText('OrgExplorer')).toBeInTheDocument() + expect(screen.getByText('AOSSIE-Org')).toBeInTheDocument() + expect(screen.getByText('61')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Score Breakdown/i })).toHaveAttribute('aria-selected', 'true') + }) + + it('keeps focus inside the dialog at both tab boundaries', () => { + render( {}} />) + + const firstFocusable = screen.getByRole('button', { name: /Close health details/i }) + const lastFocusable = screen.getByRole('tabpanel', { name: /Score Breakdown/i }) + + lastFocusable.focus() + fireEvent.keyDown(window, { key: 'Tab' }) + expect(firstFocusable).toHaveFocus() + + firstFocusable.focus() + fireEvent.keyDown(window, { key: 'Tab', shiftKey: true }) + expect(lastFocusable).toHaveFocus() + }) + + it('renders category breakdown cards on default tab', () => { + render( {}} />) + + expect(screen.getByText('Activity Health')).toBeInTheDocument() + expect(screen.getByText('Issue Health')).toBeInTheDocument() + expect(screen.getByText('Contributor Diversity')).toBeInTheDocument() + expect(screen.getByText('Scoring Formula')).toBeInTheDocument() + expect(screen.getByRole('tabpanel', { name: /Score Breakdown/i })).toBeInTheDocument() + }) + + it('switches to Recommendations tab on click and updates selected tab state', () => { + render( {}} />) + + const recTab = screen.getByRole('tab', { name: /Recommendations/i }) + fireEvent.click(recTab) + + expect(recTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tabpanel', { name: /Recommendations/i })).toBeInTheDocument() + expect(screen.getByText(/Strong Development Momentum/i)).toBeInTheDocument() + }) + + it('supports activating tabs with user interaction', async () => { + const user = userEvent.setup() + render( {}} />) + + const recTab = screen.getByRole('tab', { name: /Recommendations/i }) + await user.click(recTab) + + expect(recTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText(/Strong Development Momentum/i)).toBeInTheDocument() + }) + + it('switches to Raw Metrics tab and displays repository attributes', () => { + render( {}} />) + + const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i }) + fireEvent.click(rawTab) + + expect(rawTab).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tabpanel', { name: /Raw Metrics/i })).toBeInTheDocument() + expect(screen.getByText('Primary Language')).toBeInTheDocument() + expect(screen.getByText('JavaScript')).toBeInTheDocument() + expect(screen.getByText('150')).toBeInTheDocument() + }) + + it('triggers onClose when close button or backdrop is clicked', () => { + const onClose = vi.fn() + render() + + const closeBtn = screen.getByRole('button', { name: /Close health details/i }) + fireEvent.click(closeBtn) + expect(onClose).toHaveBeenCalledTimes(1) + + const backdrop = screen.getByTestId('drawer-backdrop') + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(2) + }) + + it('triggers onClose when Escape key is pressed', () => { + const onClose = vi.fn() + render() + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('displays Unknown for Activity Classification in Raw Metrics when pushed_at is missing or invalid', () => { + const repoWithInvalidDate = { + ...sampleRepo, + pushed_at: null, + activityClassification: 'Hibernating', + } + render( {}} />) + + const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i }) + fireEvent.click(rawTab) + + expect(screen.getByRole('tabpanel', { name: /Raw Metrics/i })).toBeInTheDocument() + expect(screen.getByText('Activity Classification')).toBeInTheDocument() + expect(screen.getByText('Unknown')).toBeInTheDocument() + expect(screen.queryByText('Hibernating')).not.toBeInTheDocument() + }) + + it('handles unfetched contributor data by displaying Unavailable and avoiding zero-based single maintainer warnings', () => { + const repoWithUnfetchedContribs = { + ...sampleRepo, + contributors: [], + contributorsFetched: false, + } + render( {}} />) + + // Breakdown tab should show UNAVAILABLE for diversity + expect(screen.getByText('Contributor Diversity')).toBeInTheDocument() + expect(screen.getByText('UNAVAILABLE')).toBeInTheDocument() + + // Raw metrics tab should show Unavailable for Recorded Contributors + const rawTab = screen.getByRole('tab', { name: /Raw Metrics/i }) + fireEvent.click(rawTab) + expect(screen.getByText('Recorded Contributors')).toBeInTheDocument() + expect(screen.getByText('Unavailable')).toBeInTheDocument() + + // Recommendations tab should not flag single maintainer risk + const recTab = screen.getByRole('tab', { name: /Recommendations/i }) + fireEvent.click(recTab) + expect(screen.queryByText(/Mitigate Single Maintainer Risk/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/components/UI.jsx b/src/components/UI.jsx index 1db1e125..a45c0ea4 100644 --- a/src/components/UI.jsx +++ b/src/components/UI.jsx @@ -82,14 +82,40 @@ export function Badge({ text, variant }) { return {String(text).toUpperCase()} } -export function HealthBar({ score }) { +export function HealthBar({ score, onClick, isInteractive = false, title, ariaLabel }) { const color = score >= 70 ? 'var(--green)' : score >= 40 ? 'var(--amber)' : 'var(--red)' + const interactive = typeof onClick === 'function' && isInteractive !== false + + const handleKeyDown = e => { + if (interactive && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + onClick?.() + } + } + return ( -
-
-
+
+
+
- {score} + {score}
) } diff --git a/src/pages/OverviewPage.jsx b/src/pages/OverviewPage.jsx index 680e77be..cab86711 100644 --- a/src/pages/OverviewPage.jsx +++ b/src/pages/OverviewPage.jsx @@ -9,6 +9,7 @@ import AnalysisBanner from '../components/AnalysisBanner' import { OverviewSkeleton } from '../components/Orgexplorerskeletons' import {formatNumber} from '../utils/formatNumber' import { useTheme } from '../context/ThemeContext' +import RepoHealthDrawer from '../components/RepoHealthDrawer' const LANG_COLORS = ['#22c55e', '#f5c518', '#3b82f6', '#ef4444', '#a855f7', '#f97316', '#06b6d4'] const fmt = n => n > 999 ? (n / 1000).toFixed(1) + 'k' : String(n) @@ -20,6 +21,7 @@ export default function OverviewPage() { const [open, setOpen] = useState(false) const [orgFilter, setOrgFilter] = useState('All Organizations') const [showAllOrgs, setShowAllOrgs] = useState(false) + const [selectedRepoForHealth, setSelectedRepoForHealth] = useState(null) const infoRef = useRef(null) useEffect(() => { @@ -279,9 +281,32 @@ export default function OverviewPage() { {topRepos.map(r => (
- {r.name} +
- + 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.`} + />
))}
@@ -297,6 +322,12 @@ export default function OverviewPage() {
+ + 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 => {