Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/services/analytics.healthMetrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
58 changes: 30 additions & 28 deletions src/services/analytics.js
Original file line number Diff line number Diff line change
@@ -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'
}
Expand All @@ -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 = [];

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -127,15 +129,15 @@ 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) {
const xk = toKey(item.closed_at)
if (xk) {
ensure(xk)
if (isPR) buckets[xk].prs_closed++
else buckets[xk].issues_closed++
else buckets[xk].issues_closed++
}
}

Expand All @@ -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')
}

Expand All @@ -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,
Expand Down
Loading