From 9ecaf61d92efd64e63301449b3091f27f86086c6 Mon Sep 17 00:00:00 2001 From: Rishikesh Singh Date: Wed, 23 Sep 2026 13:44:49 +0530 Subject: [PATCH 1/5] feat(repositories): make repository health scores interactive and explainable (#238) --- src/components/RepoHealthDrawer.jsx | 555 +++++++++++++++++++ src/components/RepoHealthDrawer.test.jsx | 94 ++++ src/components/UI.jsx | 36 +- src/pages/OverviewPage.jsx | 24 +- src/pages/RepositoriesPage.jsx | 17 +- src/services/analytics.healthMetrics.test.js | 65 +++ src/services/analytics.js | 168 ++++++ 7 files changed, 951 insertions(+), 8 deletions(-) create mode 100644 src/components/RepoHealthDrawer.jsx create mode 100644 src/components/RepoHealthDrawer.test.jsx diff --git a/src/components/RepoHealthDrawer.jsx b/src/components/RepoHealthDrawer.jsx new file mode 100644 index 00000000..71ee6ba1 --- /dev/null +++ b/src/components/RepoHealthDrawer.jsx @@ -0,0 +1,555 @@ +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) + + // Handle ESC key to close + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose?.() + } + } + 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]) + + // Focus trap / initial focus + useEffect(() => { + if (isOpen && drawerRef.current) { + drawerRef.current.focus() + } + }, [isOpen]) + + const contributorCount = useMemo(() => { + if (Array.isArray(repo?.contributors)) return repo.contributors.length + if (typeof repo?.contributors_count === 'number') return repo.contributors_count + return 0 + }, [repo]) + + 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 scoreRating = overallScore >= 70 ? 'Healthy' : overallScore >= 40 ? 'Moderate Risk' : 'Needs Attention' + + const REC_STYLES = { + critical: { + border: 'var(--red)', + bg: 'rgba(239, 68, 68, 0.08)', + badge: 'Critical', + icon: FiAlertCircle, + color: 'var(--red)', + }, + warning: { + border: 'var(--amber)', + bg: 'rgba(245, 158, 11, 0.08)', + badge: 'Warning', + icon: FiAlertTriangle, + color: 'var(--amber)', + }, + optimization: { + border: 'var(--blue)', + bg: 'rgba(59, 130, 246, 0.08)', + badge: 'Optimization', + icon: FiInfo, + color: 'var(--blue)', + }, + good: { + border: 'var(--green)', + bg: 'rgba(34, 197, 94, 0.08)', + 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.activityClassification || 'Unknown', icon: FiActivity }, + { label: 'Recorded Contributors', value: contributorCount.toString(), 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 content */} +
+ {/* 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 (40%) + + + Issue Health (30%) + + + Diversity (30%) +
+
+ + {/* Category cards */} + {breakdown?.categories.map((cat) => { + const catColor = + cat.score >= 70 ? 'var(--green)' : cat.score >= 40 ? 'var(--amber)' : 'var(--red)' + return ( +
+
+
+ {cat.name} + + {Math.round(cat.weight * 100)}% WEIGHT + +
+
+ {cat.score} + + (+{cat.weightedScore} pts) + +
+
+ + {/* 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..db3c3978 --- /dev/null +++ b/src/components/RepoHealthDrawer.test.jsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import React from 'react' +import RepoHealthDrawer from './RepoHealthDrawer' + +const sampleRepo = { + name: 'OrgExplorer', + orgLogin: 'AOSSIE-Org', + healthScore: 78, + 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 header, repo name, and overall score when open', () => { + render( {}} />) + + expect(screen.getByText('OrgExplorer')).toBeInTheDocument() + expect(screen.getByText('AOSSIE-Org')).toBeInTheDocument() + expect(screen.getByText('Repository Health Inspector')).toBeInTheDocument() + expect(screen.getByText('Score Breakdown')).toBeInTheDocument() + }) + + 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() + }) + + it('switches to Recommendations tab on click', () => { + render( {}} />) + + const recTab = screen.getByRole('button', { name: /Recommendations/i }) + fireEvent.click(recTab) + + expect(screen.getByText(/Strong Development Momentum/i)).toBeInTheDocument() + }) + + it('switches to Raw Metrics tab and displays repository attributes', () => { + render( {}} />) + + const rawTab = screen.getByRole('button', { name: /Raw Metrics/i }) + fireEvent.click(rawTab) + + 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) + }) +}) diff --git a/src/components/UI.jsx b/src/components/UI.jsx index 1db1e125..ada6a77e 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 }) { const color = score >= 70 ? 'var(--green)' : score >= 40 ? 'var(--amber)' : 'var(--red)' + const interactive = Boolean(isInteractive || onClick) + + 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..93b466bf 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,21 @@ export default function OverviewPage() { {topRepos.map(r => (
- {r.name} + setSelectedRepoForHealth(r)} + style={{ fontSize: 12, fontWeight: 500, cursor: 'pointer' }} + className="hover:text-(--accent) transition" + title={`Click to inspect health breakdown for ${r.name}`} + > + {r.name} +
- + setSelectedRepoForHealth(r)} + isInteractive={true} + title={`Click to inspect health breakdown for ${r.name}`} + />
))}
@@ -297,6 +311,12 @@ export default function OverviewPage() {
+ + setSelectedRepoForHealth(null)} + />
) } diff --git a/src/pages/RepositoriesPage.jsx b/src/pages/RepositoriesPage.jsx index 9c035e38..7b87bf01 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,14 @@ 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}`} + /> +
@@ -274,6 +283,12 @@ export default function RepositoriesPage() { />
)} + + setSelectedRepoForHealth(null)} + />
) } diff --git a/src/services/analytics.healthMetrics.test.js b/src/services/analytics.healthMetrics.test.js index 3fdf4f64..b20e36e3 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' @@ -114,3 +116,66 @@ describe('computeBusFactor', () => { expect(computeBusFactor(contributors)).toEqual({ factor: 2, risk: 'high' }) }) }) + +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) + expect(breakdown.overall).toBeGreaterThanOrEqual(0) + expect(Number.isFinite(breakdown.overall)).toBe(true) + }) +}) + +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) + }) +}) + diff --git a/src/services/analytics.js b/src/services/analytics.js index 2ca59692..43c71265 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -9,6 +9,174 @@ export function computeHealthScore(repo, contributorCount = 0) { return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3) } +/** + * 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 daysSince = Number.isFinite(pushedAtMs) ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365 + const activity = Math.max(0, 100 - daysSince) + + const openIssues = repo?.open_issues_count || 0 + const total = openIssues + 10 + const issueHealth = Math.max(0, 100 - (openIssues / total) * 100) + + const diversity = Math.min(100, (contributorCount || 0) * 10) + + const activityWeighted = activity * 0.4 + const issueHealthWeighted = issueHealth * 0.3 + const diversityWeighted = diversity * 0.3 + const overall = Math.round(activityWeighted + issueHealthWeighted + diversityWeighted) + + return { + overall, + categories: [ + { + id: 'activity', + name: 'Activity Health', + score: Math.round(activity), + weight: 0.4, + weightedScore: Number(activityWeighted.toFixed(1)), + metrics: [ + { label: 'Last Push', value: repo?.pushed_at ? repo.pushed_at.slice(0, 10) : 'No recorded push' }, + { label: 'Days Since Push', value: Number.isFinite(pushedAtMs) ? Math.floor(daysSince) : 'Unknown' }, + { label: 'Status', value: computeActivityClassification(repo) } + ], + 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: 0.3, + weightedScore: Number(issueHealthWeighted.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: Math.round(diversity), + weight: 0.3, + weightedScore: Number(diversityWeighted.toFixed(1)), + metrics: [ + { label: 'Contributors', value: contributorCount }, + { label: 'Target Base', value: '10+ contributors' } + ], + description: 'Reflects contributor spread and project resilience. Projects with 10 or more contributors reach maximum diversity score.' + } + ] + } +} + +/** + * 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 daysSince = Number.isFinite(pushedAtMs) ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365 + const openIssues = repo?.open_issues_count || 0 + + // 1. Activity & Recency + 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 (contributorCount <= 1) { + recommendations.push({ + type: 'critical', + category: 'Community', + title: 'Mitigate Single Maintainer Risk', + description: 'Only 1 contributor is recorded. Onboarding co-maintainers or reviewing external PRs is crucial to prevent single point of failure (Bus Factor = 1).' + }) + } 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 From 2d476a4477765a4bd2dbce9eae6710ff3fcbee30 Mon Sep 17 00:00:00 2001 From: Rishikesh Singh Date: Wed, 23 Sep 2026 14:04:28 +0530 Subject: [PATCH 2/5] fix(a11y): address CodeRabbit review feedback on focus trapping, tab semantics, and scoring deduplication --- src/components/RepoHealthDrawer.jsx | 109 ++++++++++++++++--- src/components/RepoHealthDrawer.test.jsx | 35 ++++-- src/components/UI.jsx | 10 +- src/pages/OverviewPage.jsx | 17 ++- src/pages/RepositoriesPage.jsx | 1 + src/services/analytics.healthMetrics.test.js | 6 +- src/services/analytics.js | 30 ++--- 7 files changed, 160 insertions(+), 48 deletions(-) diff --git a/src/components/RepoHealthDrawer.jsx b/src/components/RepoHealthDrawer.jsx index 71ee6ba1..dbe673a6 100644 --- a/src/components/RepoHealthDrawer.jsx +++ b/src/components/RepoHealthDrawer.jsx @@ -21,15 +21,59 @@ import { computeHealthBreakdown, getHealthRecommendations } from '../services/an export default function RepoHealthDrawer({ repo, onClose, isOpen }) { const [activeTab, setActiveTab] = useState('breakdown') const drawerRef = useRef(null) + const previousActiveElementRef = useRef(null) - // Handle ESC key to close + // 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]) @@ -46,13 +90,6 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { } }, [isOpen]) - // Focus trap / initial focus - useEffect(() => { - if (isOpen && drawerRef.current) { - drawerRef.current.focus() - } - }, [isOpen]) - const contributorCount = useMemo(() => { if (Array.isArray(repo?.contributors)) return repo.contributors.length if (typeof repo?.contributors_count === 'number') return repo.contributors_count @@ -73,12 +110,18 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { 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)', @@ -86,6 +129,7 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { 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)', @@ -93,6 +137,7 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { 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)', @@ -100,6 +145,7 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { 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)', @@ -147,12 +193,10 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { display: 'flex', justifyContent: 'flex-end', }} - role="dialog" - aria-modal="true" - aria-labelledby="health-drawer-title" > {/* Backdrop */}
- {/* Drawer content */} + {/* Drawer dialog panel */}
- +
setActiveTab(tab.id)} style={{ background: 'none', @@ -326,7 +381,13 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) {
{/* TAB 1: BREAKDOWN */} {activeTab === 'breakdown' && ( -
+
{/* Score formula card */}
+
{recommendations.length === 0 ? (
@@ -473,7 +540,7 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { {rec.title}
- + {styleCfg.badge.toUpperCase()}
@@ -492,7 +559,13 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { {/* TAB 3: RAW METRICS */} {activeTab === 'raw' && ( -
+

Direct repository metrics and indicators obtained from GitHub API.

diff --git a/src/components/RepoHealthDrawer.test.jsx b/src/components/RepoHealthDrawer.test.jsx index db3c3978..5351010f 100644 --- a/src/components/RepoHealthDrawer.test.jsx +++ b/src/components/RepoHealthDrawer.test.jsx @@ -1,12 +1,13 @@ 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: 78, + healthScore: 61, pushed_at: new Date(Date.now() - 5 * 86_400_000).toISOString(), created_at: '2024-01-01T00:00:00Z', stargazers_count: 150, @@ -33,13 +34,17 @@ describe('RepoHealthDrawer', () => { expect(container.firstChild).toBeNull() }) - it('renders header, repo name, and overall score when open', () => { + 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('Repository Health Inspector')).toBeInTheDocument() - expect(screen.getByText('Score Breakdown')).toBeInTheDocument() + expect(screen.getByText('61')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /Score Breakdown/i })).toHaveAttribute('aria-selected', 'true') }) it('renders category breakdown cards on default tab', () => { @@ -49,23 +54,39 @@ describe('RepoHealthDrawer', () => { 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', () => { + it('switches to Recommendations tab on click and updates selected tab state', () => { render( {}} />) - const recTab = screen.getByRole('button', { name: /Recommendations/i }) + 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('button', { name: /Raw Metrics/i }) + 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() diff --git a/src/components/UI.jsx b/src/components/UI.jsx index ada6a77e..a45c0ea4 100644 --- a/src/components/UI.jsx +++ b/src/components/UI.jsx @@ -82,9 +82,9 @@ export function Badge({ text, variant }) { return {String(text).toUpperCase()} } -export function HealthBar({ score, onClick, isInteractive = false, title }) { +export function HealthBar({ score, onClick, isInteractive = false, title, ariaLabel }) { const color = score >= 70 ? 'var(--green)' : score >= 40 ? 'var(--amber)' : 'var(--red)' - const interactive = Boolean(isInteractive || onClick) + const interactive = typeof onClick === 'function' && isInteractive !== false const handleKeyDown = e => { if (interactive && (e.key === 'Enter' || e.key === ' ')) { @@ -97,10 +97,10 @@ export function HealthBar({ score, onClick, isInteractive = false, title }) {
(
- setSelectedRepoForHealth(r)} - style={{ fontSize: 12, fontWeight: 500, cursor: 'pointer' }} + style={{ + fontSize: 12, + fontWeight: 500, + cursor: 'pointer', + background: 'none', + border: 'none', + padding: 0, + color: 'inherit', + textAlign: 'left', + }} className="hover:text-(--accent) transition" title={`Click to inspect health breakdown for ${r.name}`} > {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.`} />
))} diff --git a/src/pages/RepositoriesPage.jsx b/src/pages/RepositoriesPage.jsx index 7b87bf01..439d96b5 100644 --- a/src/pages/RepositoriesPage.jsx +++ b/src/pages/RepositoriesPage.jsx @@ -250,6 +250,7 @@ export default function RepositoriesPage() { onClick={() => 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.`} /> diff --git a/src/services/analytics.healthMetrics.test.js b/src/services/analytics.healthMetrics.test.js index b20e36e3..6fd6a1ac 100644 --- a/src/services/analytics.healthMetrics.test.js +++ b/src/services/analytics.healthMetrics.test.js @@ -141,8 +141,10 @@ describe('computeHealthBreakdown', () => { it('handles missing or invalid pushed_at gracefully', () => { const repo = { pushed_at: null, open_issues_count: 0 } const breakdown = computeHealthBreakdown(repo, 0) - expect(breakdown.overall).toBeGreaterThanOrEqual(0) - expect(Number.isFinite(breakdown.overall)).toBe(true) + 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(breakdown.overall).toBe(30) // issueHealth 100 * 0.3 }) }) diff --git a/src/services/analytics.js b/src/services/analytics.js index 43c71265..851b7a03 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -1,12 +1,7 @@ // 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 } /** @@ -14,8 +9,9 @@ export function computeHealthScore(repo, contributorCount = 0) { */ export function computeHealthBreakdown(repo, contributorCount = 0) { const pushedAtMs = repo?.pushed_at ? new Date(repo.pushed_at).getTime() : NaN - const daysSince = Number.isFinite(pushedAtMs) ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365 - const activity = Math.max(0, 100 - daysSince) + 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 @@ -39,7 +35,7 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { weightedScore: Number(activityWeighted.toFixed(1)), metrics: [ { label: 'Last Push', value: repo?.pushed_at ? repo.pushed_at.slice(0, 10) : 'No recorded push' }, - { label: 'Days Since Push', value: Number.isFinite(pushedAtMs) ? Math.floor(daysSince) : 'Unknown' }, + { label: 'Days Since Push', value: hasPush ? Math.floor(daysSince) : 'Unknown' }, { label: 'Status', value: computeActivityClassification(repo) } ], description: 'Measures recent maintenance activity and commit momentum. Repositories updated within the last 30 days earn the highest score.' @@ -78,11 +74,19 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { export function getHealthRecommendations(repo, contributorCount = 0) { const recommendations = [] const pushedAtMs = repo?.pushed_at ? new Date(repo.pushed_at).getTime() : NaN - const daysSince = Number.isFinite(pushedAtMs) ? Math.max(0, (Date.now() - pushedAtMs) / 86_400_000) : 365 + 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 (daysSince > 180) { + 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', @@ -135,7 +139,7 @@ export function getHealthRecommendations(repo, contributorCount = 0) { type: 'critical', category: 'Community', title: 'Mitigate Single Maintainer Risk', - description: 'Only 1 contributor is recorded. Onboarding co-maintainers or reviewing external PRs is crucial to prevent single point of failure (Bus Factor = 1).' + 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({ From 2ae3f7c23c70ca43df91aee974d97a795aef7dd4 Mon Sep 17 00:00:00 2001 From: Rishikesh Singh Date: Thu, 1 Oct 2026 15:28:43 +0530 Subject: [PATCH 3/5] fix(analytics): display Unknown for Status metric when push date is missing --- src/services/analytics.healthMetrics.test.js | 1 + src/services/analytics.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/analytics.healthMetrics.test.js b/src/services/analytics.healthMetrics.test.js index 6fd6a1ac..c16d1277 100644 --- a/src/services/analytics.healthMetrics.test.js +++ b/src/services/analytics.healthMetrics.test.js @@ -144,6 +144,7 @@ describe('computeHealthBreakdown', () => { 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 }) }) diff --git a/src/services/analytics.js b/src/services/analytics.js index 851b7a03..b4804258 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -36,7 +36,7 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { 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: computeActivityClassification(repo) } + { 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.' }, From 8264e269f74ec2363618e576b222ad6c6c9db466 Mon Sep 17 00:00:00 2001 From: Rishikesh Singh Date: Thu, 1 Oct 2026 15:55:03 +0530 Subject: [PATCH 4/5] fix(health): guard activity classification date and handle unfetched contributor data --- src/components/RepoHealthDrawer.jsx | 70 ++++++++--- src/components/RepoHealthDrawer.test.jsx | 41 ++++++ .../analytics.buildAnalyticalModel.test.js | 3 + src/services/analytics.healthMetrics.test.js | 22 ++++ src/services/analytics.js | 118 +++++++++++------- 5 files changed, 197 insertions(+), 57 deletions(-) diff --git a/src/components/RepoHealthDrawer.jsx b/src/components/RepoHealthDrawer.jsx index dbe673a6..c9d179cf 100644 --- a/src/components/RepoHealthDrawer.jsx +++ b/src/components/RepoHealthDrawer.jsx @@ -90,11 +90,29 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { } }, [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]) + }, [repo, hasContributorData]) const breakdown = useMemo(() => { if (!repo) return null @@ -165,8 +183,20 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { icon: FiShield, }, { label: 'Default Branch', value: repo.default_branch || 'main', icon: FiCode }, - { label: 'Activity Classification', value: repo.activityClassification || 'Unknown', icon: FiActivity }, - { label: 'Recorded Contributors', value: contributorCount.toString(), icon: FiUsers }, + { + 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', @@ -416,18 +446,20 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) { border: '1px solid var(--border)', }} > - Activity (40%) + Activity ({breakdown?.weights?.activity ? Math.round(breakdown.weights.activity * 100) : (hasContributorData ? 40 : 57)}%) + - Issue Health (30%) + Issue Health ({breakdown?.weights?.issues ? Math.round(breakdown.weights.issues * 100) : (hasContributorData ? 30 : 43)}%) + - Diversity (30%) + Diversity ({hasContributorData ? `${breakdown?.weights?.diversity ? Math.round(breakdown.weights.diversity * 100) : 30}%` : 'Unavailable'})
{/* Category cards */} {breakdown?.categories.map((cat) => { - const catColor = - cat.score >= 70 ? 'var(--green)' : cat.score >= 40 ? 'var(--amber)' : 'var(--red)' + 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} - - {Math.round(cat.weight * 100)}% WEIGHT + + {isAvailable ? `${Math.round(cat.weight * 100)}% WEIGHT` : 'UNAVAILABLE'}
- {cat.score} - - (+{cat.weightedScore} pts) - + {isAvailable ? ( + <> + {cat.score} + + (+{cat.weightedScore} pts) + + + ) : ( + + Unavailable + + )}
@@ -468,7 +508,7 @@ export default function RepoHealthDrawer({ repo, onClose, isOpen }) {
{ 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/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 c16d1277..ded51bfc 100644 --- a/src/services/analytics.healthMetrics.test.js +++ b/src/services/analytics.healthMetrics.test.js @@ -77,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', () => { @@ -147,6 +153,16 @@ describe('computeHealthBreakdown', () => { 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', () => { @@ -180,5 +196,11 @@ describe('getHealthRecommendations', () => { 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 b4804258..225579bb 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -17,22 +17,35 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { const total = openIssues + 10 const issueHealth = Math.max(0, 100 - (openIssues / total) * 100) - const diversity = Math.min(100, (contributorCount || 0) * 10) - - const activityWeighted = activity * 0.4 - const issueHealthWeighted = issueHealth * 0.3 - const diversityWeighted = diversity * 0.3 - const overall = Math.round(activityWeighted + issueHealthWeighted + diversityWeighted) + 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: 0.4, - weightedScore: Number(activityWeighted.toFixed(1)), + 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' }, @@ -44,8 +57,8 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { id: 'issues', name: 'Issue Health', score: Math.round(issueHealth), - weight: 0.3, - weightedScore: Number(issueHealthWeighted.toFixed(1)), + 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)}%` } @@ -55,14 +68,17 @@ export function computeHealthBreakdown(repo, contributorCount = 0) { { id: 'diversity', name: 'Contributor Diversity', - score: Math.round(diversity), - weight: 0.3, - weightedScore: Number(diversityWeighted.toFixed(1)), + 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: contributorCount }, + { label: 'Contributors', value: hasContributorData ? contributorCount : 'Unavailable' }, { label: 'Target Base', value: '10+ contributors' } ], - description: 'Reflects contributor spread and project resilience. Projects with 10 or more contributors reach maximum diversity score.' + 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.' } ] } @@ -134,27 +150,29 @@ export function getHealthRecommendations(repo, contributorCount = 0) { } // 3. Contributor Diversity / Resilience - 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.` - }) + 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 @@ -183,7 +201,9 @@ export function getHealthRecommendations(repo, contributorCount = 0) { // 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' @@ -220,17 +240,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 => { From 4225af80db28f2a7e33a38c0d4a3702e3a4b59b1 Mon Sep 17 00:00:00 2001 From: Rishikesh Singh Date: Fri, 2 Oct 2026 15:07:44 +0530 Subject: [PATCH 5/5] test(settings): cover cancelled Clear All confirmation path --- src/pages/SettingsPage.test.jsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pages/SettingsPage.test.jsx b/src/pages/SettingsPage.test.jsx index 01bc01ac..f97bd2b9 100644 --- a/src/pages/SettingsPage.test.jsx +++ b/src/pages/SettingsPage.test.jsx @@ -66,4 +66,15 @@ describe('SettingsPage', () => { expect(screen.queryByRole('button', { name: /cleared/i })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /clear all/i })).toBeInTheDocument() }) + + it('does not clear either cache when Clear All is cancelled', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false) + + render() + + await userEvent.click(screen.getByRole('button', { name: /clear all/i })) + + expect(cacheClear).not.toHaveBeenCalled() + expect(clearAnalysis).not.toHaveBeenCalled() + }) })