CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
repo-health.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 77cf834 | 1 | /** |
| 2 | * Repository Health Score — composite 0-100 signal. | |
| 3 | * | |
| 4 | * Five signals (always totals 100 when fully populated): | |
| 5 | * CI green rate 25 pts gate_runs last 30 days | |
| 6 | * Bus factor 20 pts bus_factor_cache table | |
| 7 | * Open CVEs 20 pts repo_advisory_alerts table | |
| 8 | * PR review velocity 15 pts pull_requests + pr_comments | |
| 9 | * Tech debt 20 pts repo_onboarding_data (neutral 15 if no data) | |
| 10 | * | |
| 11 | * Cached in-memory with a 6-hour TTL. Call invalidateHealthScore(repoId) on | |
| 12 | * every push to force a fresh computation on the next page load. | |
| 13 | */ | |
| 14 | ||
| 15 | import { db } from "../db"; | |
| 16 | import { | |
| 17 | gateRuns, | |
| 18 | busFactorCache, | |
| 19 | repoAdvisoryAlerts, | |
| 20 | pullRequests, | |
| 21 | prComments, | |
| 22 | repoOnboardingData, | |
| 23 | } from "../db/schema"; | |
| 24 | import { eq, and, gte, lt, sql, count, min } from "drizzle-orm"; | |
| 25 | import type { BusFactorFile } from "./bus-factor"; | |
| 26 | ||
| 27 | // --------------------------------------------------------------------------- | |
| 28 | // Public interface | |
| 29 | // --------------------------------------------------------------------------- | |
| 30 | ||
| 31 | export interface HealthScoreBreakdown { | |
| 32 | total: number; | |
| 33 | ciGreenRate: { | |
| 34 | score: number; // 0-25 | |
| 35 | rate: number; // 0.0-1.0 | |
| 36 | totalRuns: number; | |
| 37 | passedRuns: number; | |
| 38 | }; | |
| 39 | busFactor: { | |
| 40 | score: number; // 0-20 | |
| 41 | atRiskFileCount: number; | |
| 42 | criticalCount: number; | |
| 43 | }; | |
| 44 | openCves: { | |
| 45 | score: number; // 0-20 | |
| 46 | count: number; | |
| 47 | }; | |
| 48 | reviewVelocity: { | |
| 49 | score: number; // 0-15 | |
| 50 | avgHours: number | null; | |
| 51 | sampleSize: number; | |
| 52 | }; | |
| 53 | techDebt: { | |
| 54 | score: number; // 0-20 | |
| 55 | available: boolean; | |
| 56 | }; | |
| 57 | computedAt: Date; | |
| 58 | } | |
| 59 | ||
| 60 | // --------------------------------------------------------------------------- | |
| 61 | // In-memory cache | |
| 62 | // --------------------------------------------------------------------------- | |
| 63 | ||
| 64 | interface CacheEntry { | |
| 65 | breakdown: HealthScoreBreakdown; | |
| 66 | expiresAt: number; // Date.now() ms | |
| 67 | } | |
| 68 | ||
| 69 | const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours | |
| 70 | const memCache = new Map<string, CacheEntry>(); | |
| 71 | ||
| 72 | export function invalidateHealthScore(repoId: string): void { | |
| 73 | memCache.delete(repoId); | |
| 74 | } | |
| 75 | ||
| 76 | // --------------------------------------------------------------------------- | |
| 77 | // Signal: CI green rate (0-25 pts) | |
| 78 | // --------------------------------------------------------------------------- | |
| 79 | ||
| 80 | async function ciGreenRate(repoId: string): Promise<HealthScoreBreakdown["ciGreenRate"]> { | |
| 81 | try { | |
| 82 | const since = new Date(Date.now() - 30 * 86_400_000); | |
| 83 | const rows = await db | |
| 84 | .select({ status: gateRuns.status }) | |
| 85 | .from(gateRuns) | |
| 86 | .where(and(eq(gateRuns.repositoryId, repoId), gte(gateRuns.createdAt, since))); | |
| 87 | ||
| 88 | if (rows.length === 0) { | |
| 89 | // No runs in last 30 days — benefit of the doubt | |
| 90 | return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 }; | |
| 91 | } | |
| 92 | ||
| 93 | const totalRuns = rows.length; | |
| 94 | const passedRuns = rows.filter( | |
| 95 | (r) => r.status === "passed" || r.status === "repaired" | |
| 96 | ).length; | |
| 97 | const rate = passedRuns / totalRuns; | |
| 98 | const score = Math.round(rate * 25); | |
| 99 | return { score, rate, totalRuns, passedRuns }; | |
| 100 | } catch { | |
| 101 | return { score: 20, rate: 1, totalRuns: 0, passedRuns: 0 }; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // --------------------------------------------------------------------------- | |
| 106 | // Signal: Bus factor (0-20 pts) | |
| 107 | // --------------------------------------------------------------------------- | |
| 108 | ||
| 109 | async function busFactorSignal(repoId: string): Promise<HealthScoreBreakdown["busFactor"]> { | |
| 110 | try { | |
| 111 | const rows = await db | |
| 112 | .select() | |
| 113 | .from(busFactorCache) | |
| 114 | .where(eq(busFactorCache.repositoryId, repoId)) | |
| 115 | .limit(1); | |
| 116 | ||
| 117 | if (rows.length === 0) { | |
| 118 | // No cache entry — neutral | |
| 119 | return { score: 15, atRiskFileCount: 0, criticalCount: 0 }; | |
| 120 | } | |
| 121 | ||
| 122 | const atRiskFiles = (rows[0].atRiskFiles ?? []) as BusFactorFile[]; | |
| 123 | const criticalCount = atRiskFiles.filter((f) => f.risk === "critical").length; | |
| 124 | const highCount = atRiskFiles.filter((f) => f.risk === "high").length; | |
| 125 | ||
| 126 | // Start at 20, subtract penalty per risky file (capped) | |
| 127 | const criticalPenalty = Math.min(criticalCount * 5, 20); | |
| 128 | const highPenalty = Math.min(highCount * 2, 10); | |
| 129 | const score = Math.max(0, 20 - criticalPenalty - highPenalty); | |
| 130 | ||
| 131 | return { score, atRiskFileCount: atRiskFiles.length, criticalCount }; | |
| 132 | } catch { | |
| 133 | return { score: 15, atRiskFileCount: 0, criticalCount: 0 }; | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | // --------------------------------------------------------------------------- | |
| 138 | // Signal: Open CVEs (0-20 pts) | |
| 139 | // --------------------------------------------------------------------------- | |
| 140 | ||
| 141 | async function openCvesSignal(repoId: string): Promise<HealthScoreBreakdown["openCves"]> { | |
| 142 | try { | |
| 143 | const rows = await db | |
| 144 | .select({ n: count() }) | |
| 145 | .from(repoAdvisoryAlerts) | |
| 146 | .where( | |
| 147 | and( | |
| 148 | eq(repoAdvisoryAlerts.repositoryId, repoId), | |
| 149 | eq(repoAdvisoryAlerts.status, "open") | |
| 150 | ) | |
| 151 | ); | |
| 152 | ||
| 153 | const cveCount = Number(rows[0]?.n ?? 0); | |
| 154 | let score: number; | |
| 155 | if (cveCount === 0) score = 20; | |
| 156 | else if (cveCount === 1) score = 15; | |
| 157 | else if (cveCount === 2) score = 10; | |
| 158 | else if (cveCount <= 4) score = 5; | |
| 159 | else score = 0; | |
| 160 | ||
| 161 | return { score, count: cveCount }; | |
| 162 | } catch { | |
| 163 | return { score: 20, count: 0 }; | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | // --------------------------------------------------------------------------- | |
| 168 | // Signal: PR review velocity (0-15 pts) | |
| 169 | // --------------------------------------------------------------------------- | |
| 170 | ||
| 171 | async function reviewVelocitySignal(repoId: string): Promise<HealthScoreBreakdown["reviewVelocity"]> { | |
| 172 | try { | |
| 173 | const since = new Date(Date.now() - 30 * 86_400_000); | |
| 174 | ||
| 175 | // Get merged PRs from last 30 days with their first human review comment timestamp | |
| 176 | const rows = await db | |
| 177 | .select({ | |
| 178 | prId: pullRequests.id, | |
| 179 | prCreatedAt: pullRequests.createdAt, | |
| 180 | firstReview: min(prComments.createdAt), | |
| 181 | }) | |
| 182 | .from(pullRequests) | |
| 183 | .innerJoin( | |
| 184 | prComments, | |
| 185 | and( | |
| 186 | eq(prComments.pullRequestId, pullRequests.id), | |
| 187 | eq(prComments.isAiReview, false) | |
| 188 | ) | |
| 189 | ) | |
| 190 | .where( | |
| 191 | and( | |
| 192 | eq(pullRequests.repositoryId, repoId), | |
| 193 | eq(pullRequests.state, "merged"), | |
| 194 | gte(pullRequests.createdAt, since) | |
| 195 | ) | |
| 196 | ) | |
| 197 | .groupBy(pullRequests.id, pullRequests.createdAt) | |
| 198 | .limit(20); | |
| 199 | ||
| 200 | if (rows.length === 0) { | |
| 201 | return { score: 0, avgHours: null, sampleSize: 0 }; | |
| 202 | } | |
| 203 | ||
| 204 | // Compute average hours from PR creation to first review | |
| 205 | let totalHours = 0; | |
| 206 | let validCount = 0; | |
| 207 | for (const row of rows) { | |
| 208 | if (row.firstReview && row.prCreatedAt) { | |
| 209 | const diffMs = | |
| 210 | new Date(row.firstReview).getTime() - | |
| 211 | new Date(row.prCreatedAt).getTime(); | |
| 212 | if (diffMs >= 0) { | |
| 213 | totalHours += diffMs / 3_600_000; | |
| 214 | validCount++; | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | if (validCount === 0) { | |
| 220 | return { score: 0, avgHours: null, sampleSize: rows.length }; | |
| 221 | } | |
| 222 | ||
| 223 | const avgHours = totalHours / validCount; | |
| 224 | let score: number; | |
| 225 | if (avgHours < 4) score = 15; | |
| 226 | else if (avgHours < 8) score = 12; | |
| 227 | else if (avgHours < 24) score = 8; | |
| 228 | else if (avgHours < 72) score = 4; | |
| 229 | else score = 0; | |
| 230 | ||
| 231 | return { score, avgHours, sampleSize: validCount }; | |
| 232 | } catch { | |
| 233 | return { score: 0, avgHours: null, sampleSize: 0 }; | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | // --------------------------------------------------------------------------- | |
| 238 | // Signal: Tech debt (0-20 pts) | |
| 239 | // --------------------------------------------------------------------------- | |
| 240 | ||
| 241 | async function techDebtSignal(repoId: string): Promise<HealthScoreBreakdown["techDebt"]> { | |
| 242 | try { | |
| 243 | const rows = await db | |
| 244 | .select() | |
| 245 | .from(repoOnboardingData) | |
| 246 | .where(eq(repoOnboardingData.repositoryId, repoId)) | |
| 247 | .limit(1); | |
| 248 | ||
| 249 | if (rows.length === 0) { | |
| 250 | // No onboarding data — neutral | |
| 251 | return { score: 15, available: false }; | |
| 252 | } | |
| 253 | ||
| 254 | // Onboarding data exists but doesn't carry a debtScore field in schema v1 — | |
| 255 | // give the neutral 15-pt benefit of the doubt. | |
| 256 | return { score: 15, available: true }; | |
| 257 | } catch { | |
| 258 | return { score: 15, available: false }; | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | // --------------------------------------------------------------------------- | |
| 263 | // Core computation | |
| 264 | // --------------------------------------------------------------------------- | |
| 265 | ||
| 266 | export async function computeHealthScore( | |
| 267 | repoId: string | |
| 268 | ): Promise<HealthScoreBreakdown> { | |
| 269 | const [ci, bf, cve, vel, debt] = await Promise.all([ | |
| 270 | ciGreenRate(repoId), | |
| 271 | busFactorSignal(repoId), | |
| 272 | openCvesSignal(repoId), | |
| 273 | reviewVelocitySignal(repoId), | |
| 274 | techDebtSignal(repoId), | |
| 275 | ]); | |
| 276 | ||
| 277 | const total = Math.min( | |
| 278 | 100, | |
| 279 | ci.score + bf.score + cve.score + vel.score + debt.score | |
| 280 | ); | |
| 281 | ||
| 282 | return { | |
| 283 | total, | |
| 284 | ciGreenRate: ci, | |
| 285 | busFactor: bf, | |
| 286 | openCves: cve, | |
| 287 | reviewVelocity: vel, | |
| 288 | techDebt: debt, | |
| 289 | computedAt: new Date(), | |
| 290 | }; | |
| 291 | } | |
| 292 | ||
| 293 | // --------------------------------------------------------------------------- | |
| 294 | // Cached version (6h TTL) | |
| 295 | // --------------------------------------------------------------------------- | |
| 296 | ||
| 297 | export async function getHealthScore( | |
| 298 | repoId: string | |
| 299 | ): Promise<HealthScoreBreakdown> { | |
| 300 | const now = Date.now(); | |
| 301 | const cached = memCache.get(repoId); | |
| 302 | if (cached && cached.expiresAt > now) { | |
| 303 | return cached.breakdown; | |
| 304 | } | |
| 305 | ||
| 306 | const breakdown = await computeHealthScore(repoId); | |
| 307 | memCache.set(repoId, { | |
| 308 | breakdown, | |
| 309 | expiresAt: now + CACHE_TTL_MS, | |
| 310 | }); | |
| 311 | return breakdown; | |
| 312 | } |