diff --git a/src/services/analytics.healthMetrics.test.js b/src/services/analytics.healthMetrics.test.js index 3fdf4f6..f459c9d 100644 --- a/src/services/analytics.healthMetrics.test.js +++ b/src/services/analytics.healthMetrics.test.js @@ -114,3 +114,59 @@ describe('computeBusFactor', () => { expect(computeBusFactor(contributors)).toEqual({ factor: 2, risk: 'high' }) }) }) + +describe('computeHealthScore with invalid inputs', () => { + // Regression guards for this PR. Two NaN sources in computeHealthScore: + // - open_issues_count: the issueHealth line used a bare repo.open_issues_count, + // so a missing or non-numeric value produced NaN (#211). + // - pushed_at: an unguarded new Date(repo.pushed_at) made daysSince NaN for a + // missing/invalid timestamp. Both are now guarded; these fail on the + // unguarded code and pass with the fix. + + it('returns a finite score when open_issues_count is missing but pushed_at is valid', () => { + const score = computeHealthScore({ pushed_at: new Date().toISOString() }, 2) + expect(Number.isFinite(score)).toBe(true) + }) + + it('returns a finite score for an empty repo object', () => { + expect(Number.isFinite(computeHealthScore({}, 3))).toBe(true) + }) + + it('treats a missing open_issues_count the same as zero open issues', () => { + const missing = computeHealthScore({ pushed_at: daysAgoISO(10) }, 2) + const zero = computeHealthScore({ pushed_at: daysAgoISO(10), open_issues_count: 0 }, 2) + expect(missing).toBe(zero) + }) + + it('returns a finite score when open_issues_count is a non-numeric value', () => { + const score = computeHealthScore({ pushed_at: new Date().toISOString(), open_issues_count: 'oops' }, 2) + expect(Number.isFinite(score)).toBe(true) + }) + + it('returns a finite score when pushed_at is null', () => { + expect(Number.isFinite(computeHealthScore({ pushed_at: null, open_issues_count: 0 }, 2))).toBe(true) + }) + + it('returns a finite score when pushed_at is an unparseable string', () => { + expect(Number.isFinite(computeHealthScore({ pushed_at: 'not-a-date', open_issues_count: 0 }, 2))).toBe(true) + }) +}) + +describe('computeActivityClassification with missing or invalid pushed_at', () => { + // Regression guard: computeActivityClassification parses pushed_at with + // Date.parse + Number.isFinite; an invalid or missing value is treated as + // maximally stale and classified as Hibernating rather than throwing or + // mislabelling. + + it('classifies a null pushed_at as Hibernating', () => { + expect(computeActivityClassification({ pushed_at: null })).toBe('Hibernating') + }) + + it('classifies a missing pushed_at as Hibernating', () => { + expect(computeActivityClassification({})).toBe('Hibernating') + }) + + it('classifies an unparseable pushed_at string as Hibernating', () => { + expect(computeActivityClassification({ pushed_at: 'not-a-date' })).toBe('Hibernating') + }) +}) \ No newline at end of file diff --git a/src/services/analytics.js b/src/services/analytics.js index 2ca5969..3ffedfd 100644 --- a/src/services/analytics.js +++ b/src/services/analytics.js @@ -1,19 +1,21 @@ // Repo Health Indicator // Activity (40%) + Issue Health (30%) + Diversity (30%) 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) + const pushedAtMs = Date.parse(repo.pushed_at) + const daysSince = Number.isFinite(pushedAtMs) ? (Date.now() - pushedAtMs) / 86_400_000 : Infinity + const activity = Math.max(0, 100 - daysSince) + const total = (Number(repo.open_issues_count) || 0) + 10 + const issueHealth = Math.max(0, 100 - ((Number(repo.open_issues_count) || 0) / total) * 100) + const diversity = Math.min(100, contributorCount * 10) return Math.round(activity * 0.4 + issueHealth * 0.3 + diversity * 0.3) } // 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 - if (days <= 30) return 'Thriving' - if (days <= 90) return 'Active' + const pushedAtMs = Date.parse(repo.pushed_at) + const days = Number.isFinite(pushedAtMs) ? (Date.now() - pushedAtMs) / 86_400_000 : Infinity + if (days <= 30) return 'Thriving' + if (days <= 90) return 'Active' if (days <= 180) return 'Dormant' return 'Hibernating' } @@ -38,7 +40,7 @@ export function computeBusFactor(contributors = []) { // Merges multiple orgs into one normalized graph: // Organization → Repositories → Contributors → Issues/PRs export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalReposPerOrg) { - const allRepos = [] + const allRepos = [] const contributorMap = {} const totalRepos = []; @@ -86,10 +88,10 @@ export function buildAnalyticalModel(orgs, reposPerOrg, contribsPerRepo, totalRe // Finalize contributors: compute signals const contributors = Object.values(contributorMap).map(c => ({ ...c, - orgs: Array.from(c.orgs), + orgs: Array.from(c.orgs), isConnector: c.repos.length >= 3, - isCrossOrg: c.orgs.size > 1, - freshness: c.lastActive + isCrossOrg: c.orgs.size > 1, + freshness: c.lastActive ? Math.max(0, 100 - (Date.now() - new Date(c.lastActive)) / 86_400_000) : 0, })).sort((a, b) => b.totalContribs - a.totalContribs) @@ -127,7 +129,7 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') { if (ck) { ensure(ck) if (isPR) buckets[ck].prs_created++ - else buckets[ck].issues_created++ + else buckets[ck].issues_created++ } if (item.closed_at) { @@ -135,7 +137,7 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') { if (xk) { ensure(xk) if (isPR) buckets[xk].prs_closed++ - else buckets[xk].issues_closed++ + else buckets[xk].issues_closed++ } } @@ -153,27 +155,27 @@ export function buildTimeSeries(issues = [], granularity = 'monthly') { // CSV Export function download(content, filename, type = 'text/csv') { const blob = new Blob([content], { type }) - const url = URL.createObjectURL(blob) - const a = Object.assign(document.createElement('a'), { href: url, download: filename }) + const url = URL.createObjectURL(blob) + const a = Object.assign(document.createElement('a'), { href: url, download: filename }) a.click() URL.revokeObjectURL(url) } export function exportReposCSV(repos) { - const header = ['Repository','Org','Stars','Forks','Open Issues','Health Score','Activity Classification','Language','Last Active'] - const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)]) + const header = ['Repository', 'Org', 'Stars', 'Forks', 'Open Issues', 'Health Score', 'Activity Classification', 'Language', 'Last Active'] + const rows = repos.map(r => [r.name, r.orgLogin, r.stargazers_count, r.forks_count, r.open_issues_count, r.healthScore, r.activityClassification, r.language || 'N/A', r.pushed_at?.slice(0, 10)]) download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-repos.csv') } export function exportContributorsCSV(contributors) { - const header = ['Login','Total Contributions','Repos','Orgs','Last Active','Connector','Cross-Org'] - const rows = contributors.map(c => [c.login, c.totalContribs, c.repos.length, c.orgs.length, c.lastActive?.slice(0, 10) || '', c.isConnector, c.isCrossOrg]) + const header = ['Login', 'Total Contributions', 'Repos', 'Orgs', 'Last Active', 'Connector', 'Cross-Org'] + const rows = contributors.map(c => [c.login, c.totalContribs, c.repos.length, c.orgs.length, c.lastActive?.slice(0, 10) || '', c.isConnector, c.isCrossOrg]) download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-contributors.csv') } export function exportTrendsCSV(series) { - const header = ['Date','PRs Created','PRs Merged','PRs Closed','Issues Created','Issues Closed'] - const rows = series.map(s => [s.date, s.prs_created, s.prs_merged, s.prs_closed, s.issues_created, s.issues_closed]) + const header = ['Date', 'PRs Created', 'PRs Merged', 'PRs Closed', 'Issues Created', 'Issues Closed'] + const rows = series.map(s => [s.date, s.prs_created, s.prs_merged, s.prs_closed, s.issues_created, s.issues_closed]) download([header, ...rows].map(r => r.join(',')).join('\n'), 'orgexplorer-trends.csv') } @@ -182,16 +184,16 @@ export function getTopRepositories(repos, limit = 10) { return [...repos] .map(repo => { - const pushedAtMs = Date.parse(repo.pushed_at); + const pushedAtMs = Date.parse(repo.pushed_at); const daysSinceLastPush = Number.isFinite(pushedAtMs) ? (Date.now() - pushedAtMs) / MS_PER_DAY : Infinity; const activityBonus = 0.5 * Math.max(0, 365 - daysSinceLastPush); - const score = - (repo.stargazers_count ?? 0) + - (repo.forks_count ?? 0) * 2 + - (repo.watchers_count ?? 0) * 1.5 + - activityBonus; + const score = + (repo.stargazers_count ?? 0) + + (repo.forks_count ?? 0) * 2 + + (repo.watchers_count ?? 0) * 1.5 + + activityBonus; return { ...repo,