From df65e113bf7bbf486480c581a7906852a81cbace Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:15:25 +0300 Subject: [PATCH 1/4] feat: add robust maintenance scoring metrics --- dashboard/lib/scoring-metrics.mjs | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 dashboard/lib/scoring-metrics.mjs diff --git a/dashboard/lib/scoring-metrics.mjs b/dashboard/lib/scoring-metrics.mjs new file mode 100644 index 0000000..768e769 --- /dev/null +++ b/dashboard/lib/scoring-metrics.mjs @@ -0,0 +1,80 @@ +const DAY_MS = 86_400_000 + +function clamp(value, min = 0, max = 100) { + return Math.max(min, Math.min(max, value)) +} + +function median(values) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b) + if (!sorted.length) return null + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function percentile(values, p) { + const sorted = values.filter(Number.isFinite).sort((a, b) => a - b) + if (!sorted.length) return null + const index = (sorted.length - 1) * p + const lower = Math.floor(index) + const upper = Math.ceil(index) + if (lower === upper) return sorted[lower] + return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower) +} + +function tier(value, thresholds) { + for (const [limit, score] of thresholds) { + if (value <= limit) return score + } + return thresholds[thresholds.length - 1][1] +} + +export function scoreActivitySignals(commits, now = Date.now()) { + const dates = commits + .map(c => c?.commit?.author?.date ?? c?.commit?.committer?.date) + .map(value => Date.parse(value ?? '')) + .filter(Number.isFinite) + + if (!dates.length) return 0 + + const recent30 = dates.filter(t => now - t <= 30 * DAY_MS).length + const weeks = new Set(dates.map(t => Math.floor((now - t) / (7 * DAY_MS)))).size + const countScore = tier(dates.length, [[0, 0], [1, 20], [4, 45], [14, 70], [29, 90], [Infinity, 100]]) + const cadenceScore = tier(weeks, [[0, 0], [1, 15], [3, 35], [7, 60], [11, 80], [Infinity, 100]]) + const recentScore = tier(recent30, [[0, 0], [1, 20], [2, 40], [5, 65], [10, 85], [Infinity, 100]]) + + return Math.round(countScore * 0.5 + cadenceScore * 0.3 + recentScore * 0.2) +} + +export function scoreIssueMaintenance({ openCount, staleOpenCount, closedAgesDays = [] }) { + const open = Math.max(0, Number(openCount) || 0) + const stale = Math.min(open, Math.max(0, Number(staleOpenCount) || 0)) + const closed = closedAgesDays.filter(Number.isFinite) + + if (open === 0 && closed.length === 0) return 100 + + const triageScore = open === 0 ? 100 : clamp(100 - (stale / open) * 85) + const resolutionDays = median(closed) + const resolutionScore = resolutionDays === null + ? 50 + : tier(resolutionDays, [[3, 100], [7, 90], [14, 80], [30, 65], [60, 50], [90, 35], [180, 20], [Infinity, 10]]) + + return Math.round(triageScore * 0.6 + resolutionScore * 0.4) +} + +export function scorePRMaintenance({ mergedAgesDays = [], openAgesDays = [] }) { + const merged = mergedAgesDays.filter(Number.isFinite) + const open = openAgesDays.filter(Number.isFinite) + if (!merged.length && !open.length) return 50 + + const medianDays = median(merged) + const p90Days = percentile(merged, 0.9) + const staleRatio = open.length ? open.filter(days => days > 30).length / open.length : 0 + + const medianScore = medianDays === null ? 50 : tier(medianDays, [[1, 100], [3, 90], [7, 75], [14, 60], [30, 40], [Infinity, 15]]) + const p90Score = p90Days === null ? 50 : tier(p90Days, [[3, 100], [7, 90], [14, 70], [30, 50], [60, 30], [Infinity, 10]]) + const openScore = clamp(100 - staleRatio * 90) + + return Math.round(medianScore * 0.5 + p90Score * 0.25 + openScore * 0.25) +} + +export { median, percentile } From 56e0de39e450fd83808edd4f855e67e3a9ef46f3 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:15:27 +0300 Subject: [PATCH 2/4] test: cover maintenance scoring metrics --- dashboard/scripts/scoring-metrics.test.mjs | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 dashboard/scripts/scoring-metrics.test.mjs diff --git a/dashboard/scripts/scoring-metrics.test.mjs b/dashboard/scripts/scoring-metrics.test.mjs new file mode 100644 index 0000000..4f9b75d --- /dev/null +++ b/dashboard/scripts/scoring-metrics.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { scoreActivitySignals, scoreIssueMaintenance, scorePRMaintenance } from '../lib/scoring-metrics.mjs' + +const NOW = Date.parse('2026-09-26T00:00:00Z') +const commit = daysAgo => ({ commit: { author: { date: new Date(NOW - daysAgo * 86_400_000).toISOString() } } }) + +test('activity rewards sustained cadence instead of raw commit volume alone', () => { + const burst = Array.from({ length: 30 }, () => commit(10)) + const sustained = Array.from({ length: 30 }, (_, i) => commit(i * 3)) + assert.ok(scoreActivitySignals(sustained, NOW) > scoreActivitySignals(burst, NOW)) +}) + +test('issue maintenance penalizes stale open issues', () => { + const healthy = scoreIssueMaintenance({ openCount: 10, staleOpenCount: 0, closedAgesDays: [2, 4, 7, 10] }) + const stale = scoreIssueMaintenance({ openCount: 10, staleOpenCount: 8, closedAgesDays: [2, 4, 7, 10] }) + assert.ok(healthy > stale) +}) + +test('PR maintenance uses robust merge-time signals and stale open PRs', () => { + const healthy = scorePRMaintenance({ mergedAgesDays: [1, 2, 3, 4, 5], openAgesDays: [2, 5] }) + const slow = scorePRMaintenance({ mergedAgesDays: [10, 20, 40, 60, 90], openAgesDays: [45, 60] }) + assert.ok(healthy > slow) +}) + +test('empty PR history remains neutral', () => { + assert.equal(scorePRMaintenance({}), 50) +}) From 62f3f6b047d7cd567a15d8f639814e7960a7acd1 Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:15:42 +0300 Subject: [PATCH 3/4] feat: upgrade activity issue and PR scoring --- dashboard/lib/scorer.ts | 71 +++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/dashboard/lib/scorer.ts b/dashboard/lib/scorer.ts index 921a4e2..5d9b680 100644 --- a/dashboard/lib/scorer.ts +++ b/dashboard/lib/scorer.ts @@ -2,6 +2,7 @@ import { DEFAULT_WEIGHTS, DimKey, Suggestion } from './constants' import { getRedis } from './redis' import { runAdvisoryCheck } from './advisory' import { sanitizeWeights } from './weights.mjs' +import { scoreActivitySignals, scoreIssueMaintenance, scorePRMaintenance } from './scoring-metrics.mjs' export interface DimScores { readme: number @@ -101,12 +102,7 @@ async function scoreActivity(owner: string, name: string, token?: string): Promi const since = new Date() since.setDate(since.getDate() - 90) const data = await ghFetch(`${GH}/repos/${owner}/${name}/commits?since=${since.toISOString()}&per_page=100`, token) - const n = data.length - if (n >= 30) return 100 - if (n >= 15) return 75 - if (n >= 5) return 50 - if (n >= 1) return 25 - return 0 + return scoreActivitySignals(data) } catch { return 0 } } @@ -146,12 +142,32 @@ async function scoreCI(owner: string, name: string, token?: string): Promise { try { - const closed = await ghFetch(`${GH}/repos/${owner}/${name}/issues?state=closed&per_page=50`, token) - const c = closed.length - if (!c && openCount === 0) return 100 - const total = openCount + c - if (total === 0) return 100 - return Math.round((c / total) * 100) + const [openIssues, closedIssues] = await Promise.all([ + ghFetch(`${GH}/repos/${owner}/${name}/issues?state=open&per_page=50`, token), + ghFetch(`${GH}/repos/${owner}/${name}/issues?state=closed&per_page=50`, token), + ]) + const now = Date.now() + const isIssue = (item: any) => !item.pull_request + const open = openIssues.filter(isIssue) + const closed = closedIssues.filter(isIssue) + const staleOpenCount = open.filter((item: any) => { + const created = Date.parse(item.created_at ?? '') + return Number.isFinite(created) && now - created > 90 * 86400000 + }).length + const closedAgesDays = closed + .map((item: any) => { + const created = Date.parse(item.created_at ?? '') + const closedAt = Date.parse(item.closed_at ?? '') + return Number.isFinite(created) && Number.isFinite(closedAt) + ? (closedAt - created) / 86400000 + : NaN + }) + .filter(Number.isFinite) + return scoreIssueMaintenance({ + openCount: Math.max(openCount, open.length), + staleOpenCount, + closedAgesDays, + }) } catch { return 50 } } @@ -162,19 +178,18 @@ function scoreCommunity(stars: number, forks: number): number { async function scorePRVelocity(owner: string, name: string, token?: string): Promise { try { - const prs = await ghFetch(`${GH}/repos/${owner}/${name}/pulls?state=closed&per_page=20`, token) - const merged = prs.filter((p: any) => p.merged_at) - if (merged.length === 0) return 50 - const avgMs = merged.reduce((sum: number, p: any) => { - return sum + (new Date(p.merged_at).getTime() - new Date(p.created_at).getTime()) - }, 0) / merged.length - const avgDays = avgMs / 86400000 - if (avgDays < 1) return 100 - if (avgDays < 3) return 85 - if (avgDays < 7) return 65 - if (avgDays < 14) return 45 - if (avgDays < 30) return 25 - return 10 + const [closed, open] = await Promise.all([ + ghFetch(`${GH}/repos/${owner}/${name}/pulls?state=closed&per_page=50&sort=updated&direction=desc`, token), + ghFetch(`${GH}/repos/${owner}/${name}/pulls?state=open&per_page=50&sort=created&direction=desc`, token), + ]) + const mergedAgesDays = closed + .filter((p: any) => p.merged_at) + .map((p: any) => (Date.parse(p.merged_at) - Date.parse(p.created_at)) / 86400000) + .filter(Number.isFinite) + const openAgesDays = open + .map((p: any) => (Date.now() - Date.parse(p.created_at)) / 86400000) + .filter(Number.isFinite) + return scorePRMaintenance({ mergedAgesDays, openAgesDays }) } catch { return 50 } } @@ -220,13 +235,13 @@ function buildSuggestions(scores: DimScores, advisoryCounts?: RepoReport['adviso const critHigh = (advisoryCounts?.critical ?? 0) + (advisoryCounts?.high ?? 0) const MSGS: Record = { readme: 'Add a usage section, code examples, and at least one screenshot or GIF to your README.', - activity: 'Commit more regularly. Aim for at least 15 commits per 90 days.', + activity: 'Maintain a steady contribution cadence across the 90-day window rather than relying on short bursts of activity.', freshness: 'Push an update to main. Repos inactive for 30+ days score lower on freshness.', docs: 'Add missing files: LICENSE, CONTRIBUTING.md, CHANGELOG.md, SECURITY.md.', ci: 'Add GitHub Actions workflows. Even a basic lint/test workflow improves this score significantly.', - issues: 'Close or triage open issues. A high open:closed ratio signals poor maintenance.', + issues: 'Triage stale issues and reduce long-running open work. DevLens now weighs issue age and resolution time.', community: 'Promote the repo. Stars and forks improve the community signal dimension.', - pr_velocity: 'Merge pull requests faster. Aim for an average PR merge time under 7 days.', + pr_velocity: 'Reduce long-running pull requests. DevLens now weighs median and tail merge time plus stale open PRs.', security: critHigh > 0 ? `${critHigh} critical/high CVE(s) found in installed dependencies. Run the Advisory scan for fix versions.` : 'Add SECURITY.md, configure Dependabot in .github/dependabot.yml, and consider CodeQL scanning.', From 29542996789ae1b070dccc3f5e13996fba4d18ad Mon Sep 17 00:00:00 2001 From: Ossama Hashim Date: Sat, 26 Sep 2026 20:18:45 +0300 Subject: [PATCH 4/4] chore: retrigger deployment after Vercel rate-limit cooldown --- dashboard/lib/scoring-metrics.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard/lib/scoring-metrics.mjs b/dashboard/lib/scoring-metrics.mjs index 768e769..453b3a4 100644 --- a/dashboard/lib/scoring-metrics.mjs +++ b/dashboard/lib/scoring-metrics.mjs @@ -1,3 +1,4 @@ +// Health model v3: evidence is weighted toward cadence, age, and robust percentiles. const DAY_MS = 86_400_000 function clamp(value, min = 0, max = 100) {