Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

repo-health.tsBlame312 lines · 1 contributor
77cf834Claude1/**
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
15import { db } from "../db";
16import {
17 gateRuns,
18 busFactorCache,
19 repoAdvisoryAlerts,
20 pullRequests,
21 prComments,
22 repoOnboardingData,
23} from "../db/schema";
24import { eq, and, gte, lt, sql, count, min } from "drizzle-orm";
25import type { BusFactorFile } from "./bus-factor";
26
27// ---------------------------------------------------------------------------
28// Public interface
29// ---------------------------------------------------------------------------
30
31export 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
64interface CacheEntry {
65 breakdown: HealthScoreBreakdown;
66 expiresAt: number; // Date.now() ms
67}
68
69const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
70const memCache = new Map<string, CacheEntry>();
71
72export function invalidateHealthScore(repoId: string): void {
73 memCache.delete(repoId);
74}
75
76// ---------------------------------------------------------------------------
77// Signal: CI green rate (0-25 pts)
78// ---------------------------------------------------------------------------
79
80async 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
109async 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
141async 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
171async 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
241async 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
266export 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
297export 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}