Skip to content
Merged
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
71 changes: 43 additions & 28 deletions dashboard/lib/scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -146,12 +142,32 @@ async function scoreCI(owner: string, name: string, token?: string): Promise<num

async function scoreIssues(owner: string, name: string, openCount: number, token?: string): Promise<number> {
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 }
}

Expand All @@ -162,19 +178,18 @@ function scoreCommunity(stars: number, forks: number): number {

async function scorePRVelocity(owner: string, name: string, token?: string): Promise<number> {
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 }
}

Expand Down Expand Up @@ -220,13 +235,13 @@ function buildSuggestions(scores: DimScores, advisoryCounts?: RepoReport['adviso
const critHigh = (advisoryCounts?.critical ?? 0) + (advisoryCounts?.high ?? 0)
const MSGS: Record<DimKey, string> = {
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.',
Expand Down
81 changes: 81 additions & 0 deletions dashboard/lib/scoring-metrics.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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) {
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 }
28 changes: 28 additions & 0 deletions dashboard/scripts/scoring-metrics.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
Loading