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
15 changes: 14 additions & 1 deletion dashboard/app/report/[owner]/[repo]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function ReportPage() {
const owner = params.owner as string
const repo = params.repo as string
const [report, setReport] = useState<RepoReport | null>(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)
Expand Down Expand Up @@ -101,6 +101,19 @@ export default function ReportPage() {
<TrendChart data={history} />
</div>
)}
{history.some(h => h.events?.length) && (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', padding: 'var(--space-6)', boxShadow: 'var(--shadow-sm)' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 'var(--text-base)', fontWeight: 700, marginBottom: 'var(--space-4)' }}>Change Intelligence</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{history.slice().reverse().flatMap(h => (h.events ?? []).map(event => (
<div key={h.week + event} style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'flex-start', fontSize: 'var(--text-sm)', color: 'var(--text-muted)' }}>
<span style={{ color: 'var(--primary)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', minWidth: 52 }}>{h.week}</span>
<span>{event}</span>
</div>
)))}
</div>
</div>
)}
{report.suggestions.length > 0 && (
<div style={{ background: 'color-mix(in oklch, #d19900 8%, var(--surface))', border: '1px solid var(--border)', borderRadius: 'var(--radius-xl)', padding: 'var(--space-6)' }}>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 'var(--text-base)', fontWeight: 700, marginBottom: 'var(--space-4)' }}>💡 How to improve this repo</h2>
Expand Down
47 changes: 47 additions & 0 deletions dashboard/lib/history-events.mjs
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 20 additions & 4 deletions dashboard/lib/scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<any>(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
Expand Down
20 changes: 20 additions & 0 deletions dashboard/scripts/history-events.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
Loading