diff --git a/dashboard/app/report/[owner]/[repo]/page.tsx b/dashboard/app/report/[owner]/[repo]/page.tsx index 87def99..246f1a5 100644 --- a/dashboard/app/report/[owner]/[repo]/page.tsx +++ b/dashboard/app/report/[owner]/[repo]/page.tsx @@ -16,7 +16,7 @@ export default function ReportPage() { const owner = params.owner as string const repo = params.repo as string const [report, setReport] = useState(null) - const [history, setHistory] = useState<{ week: string; score: number }[]>([]) + const [history, setHistory] = useState<{ week: string; score: number; events?: string[] }[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [copied, setCopied] = useState(false) @@ -101,6 +101,19 @@ export default function ReportPage() { )} + {history.some(h => h.events?.length) && ( +
+

Change Intelligence

+
+ {history.slice().reverse().flatMap(h => (h.events ?? []).map(event => ( +
+ {h.week} + {event} +
+ )))} +
+
+ )} {report.suggestions.length > 0 && (

💡 How to improve this repo

diff --git a/dashboard/lib/history-events.mjs b/dashboard/lib/history-events.mjs new file mode 100644 index 0000000..c366d6a --- /dev/null +++ b/dashboard/lib/history-events.mjs @@ -0,0 +1,47 @@ +const DIMENSION_LABELS = { + readme: 'README quality', + activity: 'activity', + freshness: 'freshness', + docs: 'documentation', + ci: 'CI/CD', + issues: 'issue maintenance', + community: 'community signal', + pr_velocity: 'PR maintenance', + security: 'security', +} + +export function buildHistoryEvents(previous, current) { + if (!previous) return ['Initial DevLens baseline recorded'] + const events = [] + const delta = current.score - previous.score + if (Math.abs(delta) >= 5) events.push(`Health ${delta > 0 ? 'improved' : 'declined'} by ${Math.abs(delta)} points`) + + for (const [key, label] of Object.entries(DIMENSION_LABELS)) { + const before = previous.scores?.[key] + const after = current.scores?.[key] + if (Number.isFinite(before) && Number.isFinite(after) && Math.abs(after - before) >= 10) { + events.push(`${label} ${after > before ? 'improved' : 'declined'} by ${Math.abs(after - before)} points`) + } + } + + const previousVulns = previous.advisory?.total ?? 0 + const currentVulns = current.advisory?.total ?? 0 + if (currentVulns !== previousVulns) { + events.push(`Known vulnerabilities ${currentVulns > previousVulns ? 'increased' : 'decreased'} from ${previousVulns} to ${currentVulns}`) + } + + const previousCritical = previous.advisory?.critical ?? 0 + const currentCritical = current.advisory?.critical ?? 0 + if (currentCritical !== previousCritical) { + events.push(`Critical vulnerabilities ${currentCritical > previousCritical ? 'increased' : 'decreased'} from ${previousCritical} to ${currentCritical}`) + } + + return events.slice(0, 6) +} + +export function shouldStartNewSnapshot(lastDate, now = Date.now()) { + if (!lastDate) return true + const timestamp = Date.parse(lastDate) + if (!Number.isFinite(timestamp)) return true + return now - timestamp >= 7 * 86400000 +} diff --git a/dashboard/lib/scorer.ts b/dashboard/lib/scorer.ts index 5d9b680..d2ee5d3 100644 --- a/dashboard/lib/scorer.ts +++ b/dashboard/lib/scorer.ts @@ -3,6 +3,7 @@ import { getRedis } from './redis' import { runAdvisoryCheck } from './advisory' import { sanitizeWeights } from './weights.mjs' import { scoreActivitySignals, scoreIssueMaintenance, scorePRMaintenance } from './scoring-metrics.mjs' +import { buildHistoryEvents, shouldStartNewSnapshot } from './history-events.mjs' export interface DimScores { readme: number @@ -349,12 +350,27 @@ export async function analyzeRepo( await redis.set(cacheKey, JSON.stringify(report), { ex: 900 }) const histKey = `history:${owner}:${name}` const existing = await redis.get(histKey) - const histArr: { week: string; score: number; date: string }[] = existing + const histArr: any[] = existing ? (typeof existing === 'string' ? JSON.parse(existing) : existing) : [] - histArr.push({ week: `W${new Date().toISOString().slice(5, 10)}`, score: health, date: new Date().toISOString() }) - await redis.set(histKey, JSON.stringify(histArr.slice(-12))) - } catch {} + const now = new Date() + const snapshot: any = { + week: `W${now.toISOString().slice(5, 10)}`, + score: health, + date: now.toISOString(), + scores, + advisory: secResult.advisory, + } + const last = histArr[histArr.length - 1] + if (shouldStartNewSnapshot(last?.date, now.getTime())) { + snapshot.events = buildHistoryEvents(last, snapshot) + histArr.push(snapshot) + } else { + snapshot.events = buildHistoryEvents(histArr.length > 1 ? histArr[histArr.length - 2] : undefined, snapshot) + if (histArr.length > 0) histArr[histArr.length - 1] = snapshot + else histArr.push(snapshot) + } + await redis.set(histKey, JSON.stringify(histArr.slice(-12))) } catch {} } return report diff --git a/dashboard/scripts/history-events.test.mjs b/dashboard/scripts/history-events.test.mjs new file mode 100644 index 0000000..e194ca1 --- /dev/null +++ b/dashboard/scripts/history-events.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildHistoryEvents, shouldStartNewSnapshot } from '../lib/history-events.mjs' + +test('history events identify meaningful health and security changes', () => { + const events = buildHistoryEvents( + { score: 70, scores: { security: 60, docs: 50 }, advisory: { total: 4, critical: 1 } }, + { score: 78, scores: { security: 75, docs: 40 }, advisory: { total: 2, critical: 0 } }, + ) + assert.ok(events.some(e => e.includes('Health improved'))) + assert.ok(events.some(e => e.includes('security improved'))) + assert.ok(events.some(e => e.includes('vulnerabilities decreased'))) +}) + +test('history snapshots remain weekly rather than per-request', () => { + const now = Date.parse('2026-09-26T00:00:00Z') + assert.equal(shouldStartNewSnapshot('2026-09-20T00:00:00Z', now), false) + assert.equal(shouldStartNewSnapshot('2026-09-18T00:00:00Z', now), true) + assert.equal(shouldStartNewSnapshot(null, now), true) +})