Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

repo-health.tsx

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.tsxBlame614 lines · 1 contributor
77cf834Claude1/**
2 * Repository Health Score — /:owner/:repo/health
3 *
4 * Full breakdown page for the 0-100 composite health score.
5 *
6 * GET /:owner/:repo/health — breakdown page (softAuth, public repos visible)
7 * POST /:owner/:repo/health/recompute — invalidate cache (requireAuth + repo owner)
8 */
9
10import { Hono } from "hono";
11import { db } from "../db";
12import { repositories, users } from "../db/schema";
13import { and, eq } from "drizzle-orm";
14import type { AuthEnv } from "../middleware/auth";
15import { softAuth, requireAuth } from "../middleware/auth";
16import { requireRepoAccess } from "../middleware/repo-access";
17import { Layout } from "../views/layout";
18import { RepoHeader, RepoNav } from "../views/components";
19import { getUnreadCount } from "../lib/unread";
20import {
21 getHealthScore,
22 invalidateHealthScore,
23 type HealthScoreBreakdown,
24} from "../lib/repo-health";
25
26const repoHealthRoutes = new Hono<AuthEnv>();
27
28// ─── CSS ──────────────────────────────────────────────────────────────────────
29
30const styles = `
31 .rh-wrap {
32 max-width: 1080px;
33 margin: 0 auto;
34 padding: var(--space-5) var(--space-4);
35 }
36
37 /* Sub-navigation */
38 .rh-subnav {
39 display: flex;
40 gap: 4px;
41 margin-bottom: var(--space-5);
42 border-bottom: 1px solid var(--border);
43 padding-bottom: 0;
44 }
45 .rh-subnav-link {
46 padding: 8px 14px;
47 font-size: 13px;
48 font-weight: 500;
49 color: var(--text-muted);
50 text-decoration: none;
51 border-bottom: 2px solid transparent;
52 margin-bottom: -1px;
53 transition: color 120ms ease, border-color 120ms ease;
54 border-radius: 4px 4px 0 0;
55 }
56 .rh-subnav-link:hover { color: var(--text); }
57 .rh-subnav-link.active {
58 color: var(--accent, #5865f2);
59 border-bottom-color: var(--accent, #5865f2);
60 }
61
62 /* Hero */
63 .rh-hero {
64 position: relative;
65 margin-bottom: var(--space-5);
66 padding: var(--space-5) var(--space-6);
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 display: flex;
72 align-items: center;
73 gap: var(--space-6);
74 flex-wrap: wrap;
75 }
76 .rh-hero::before {
77 content: '';
78 position: absolute;
79 top: 0; left: 0; right: 0;
80 height: 2px;
81 pointer-events: none;
82 opacity: 0.8;
83 }
84 .rh-hero--green::before { background: linear-gradient(90deg, transparent, #34d399, transparent); }
85 .rh-hero--yellow::before { background: linear-gradient(90deg, transparent, #facc15, transparent); }
86 .rh-hero--red::before { background: linear-gradient(90deg, transparent, #f87171, transparent); }
87
88 /* SVG gauge */
89 .rh-gauge {
90 position: relative;
91 width: 140px;
92 height: 140px;
93 flex-shrink: 0;
94 }
95 .rh-gauge-svg {
96 width: 140px;
97 height: 140px;
98 transform: rotate(-90deg);
99 }
100 .rh-gauge-track {
101 fill: none;
102 stroke: var(--border);
103 stroke-width: 12;
104 }
105 .rh-gauge-fill {
106 fill: none;
107 stroke-width: 12;
108 stroke-linecap: round;
109 transition: stroke-dashoffset 600ms ease;
110 }
111 .rh-gauge-label {
112 position: absolute;
113 inset: 0;
114 display: flex;
115 flex-direction: column;
116 align-items: center;
117 justify-content: center;
118 text-align: center;
119 }
120 .rh-gauge-score {
121 font-size: 36px;
122 font-weight: 900;
123 font-variant-numeric: tabular-nums;
124 line-height: 1;
125 color: var(--text-strong);
126 }
127 .rh-gauge-max {
128 font-size: 12px;
129 color: var(--text-muted);
130 margin-top: 2px;
131 }
132
133 /* Hero text */
134 .rh-hero-body {
135 flex: 1;
136 min-width: 200px;
137 }
138 .rh-hero-eyebrow {
139 font-size: 11px;
140 font-weight: 700;
141 letter-spacing: 0.07em;
142 text-transform: uppercase;
143 color: var(--text-muted);
144 margin-bottom: 6px;
145 }
146 .rh-hero-title {
147 font-family: var(--font-display);
148 font-size: clamp(20px, 2.5vw, 28px);
149 font-weight: 800;
150 letter-spacing: -0.02em;
151 line-height: 1.15;
152 margin: 0 0 8px;
153 color: var(--text-strong);
154 }
155 .rh-hero-sub {
156 font-size: 14px;
157 color: var(--text-muted);
158 margin: 0 0 14px;
159 line-height: 1.5;
160 }
161 .rh-hero-actions {
162 display: flex;
163 align-items: center;
164 gap: 10px;
165 flex-wrap: wrap;
166 }
167 .rh-computed-at {
168 font-size: 12px;
169 color: var(--text-muted);
170 }
171
172 /* Score badge pill */
173 .rh-score-pill {
174 display: inline-flex;
175 align-items: center;
176 gap: 5px;
177 padding: 4px 12px;
178 border-radius: 9999px;
179 font-size: 12px;
180 font-weight: 700;
181 letter-spacing: 0.04em;
182 }
183 .rh-score-pill--green { background: rgba(52,211,153,.15); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
184 .rh-score-pill--yellow { background: rgba(250,204,21,.15); color: #facc15; border: 1px solid rgba(250,204,21,.3); }
185 .rh-score-pill--red { background: rgba(248,113,113,.15); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
186
187 /* Recompute button */
188 .rh-recompute-btn {
189 display: inline-flex;
190 align-items: center;
191 gap: 6px;
192 padding: 8px 16px;
193 border-radius: 8px;
194 font-size: 13px;
195 font-weight: 600;
196 color: #fff;
197 background: linear-gradient(135deg, #5865f2, #8b5cf6);
198 border: none;
199 cursor: pointer;
200 transition: opacity 120ms ease;
201 }
202 .rh-recompute-btn:hover { opacity: 0.85; }
203
204 /* Section */
205 .rh-section-title {
206 font-size: 15px;
207 font-weight: 700;
208 color: var(--text-strong);
209 margin: 0 0 var(--space-3) 0;
210 }
211
212 /* Signal cards */
213 .rh-signals {
214 display: flex;
215 flex-direction: column;
216 gap: 12px;
217 margin-bottom: var(--space-5);
218 }
219 .rh-signal-card {
220 background: var(--bg-elevated);
221 border: 1px solid var(--border);
222 border-radius: 12px;
223 padding: var(--space-4) var(--space-5);
224 transition: border-color 120ms ease;
225 }
226 .rh-signal-card:hover { border-color: rgba(88,101,242,0.3); }
227
228 .rh-signal-header {
229 display: flex;
230 align-items: baseline;
231 justify-content: space-between;
232 margin-bottom: 10px;
233 gap: 8px;
234 flex-wrap: wrap;
235 }
236 .rh-signal-name {
237 font-size: 14px;
238 font-weight: 600;
239 color: var(--text-strong);
240 }
241 .rh-signal-score {
242 font-size: 13px;
243 color: var(--text-muted);
244 font-variant-numeric: tabular-nums;
245 white-space: nowrap;
246 }
247 .rh-signal-score strong {
248 font-weight: 700;
249 color: var(--text);
250 }
251
252 .rh-bar-track {
253 height: 8px;
254 background: var(--bg-tertiary, rgba(255,255,255,0.06));
255 border-radius: 4px;
256 overflow: hidden;
257 margin-bottom: 8px;
258 }
259 .rh-bar-fill {
260 height: 100%;
261 border-radius: 4px;
262 transition: width 500ms ease;
263 }
264 .rh-bar-green { background: #34d399; }
265 .rh-bar-yellow { background: #facc15; }
266 .rh-bar-red { background: #f87171; }
267 .rh-bar-blue { background: #60a5fa; }
268 .rh-bar-purple { background: #a78bfa; }
269
270 .rh-signal-detail {
271 font-size: 12px;
272 color: var(--text-muted);
273 line-height: 1.5;
274 }
275 .rh-signal-detail strong { color: var(--text); font-weight: 600; }
276`;
277
278// ─── Helpers ──────────────────────────────────────────────────────────────────
279
280function scoreColor(score: number): "green" | "yellow" | "red" {
281 if (score >= 80) return "green";
282 if (score >= 50) return "yellow";
283 return "red";
284}
285
286function gaugeProps(score: number) {
287 const r = 55;
288 const circumference = 2 * Math.PI * r;
289 const dashoffset = circumference * (1 - score / 100);
290 const color =
291 score >= 80 ? "#34d399" :
292 score >= 50 ? "#facc15" :
293 "#f87171";
294 return {
295 r,
296 cx: 70,
297 cy: 70,
298 dasharray: circumference.toFixed(2),
299 dashoffset: dashoffset.toFixed(2),
300 color,
301 };
302}
303
304function barColor(pct: number): string {
305 if (pct >= 0.75) return "rh-bar-green";
306 if (pct >= 0.45) return "rh-bar-yellow";
307 return "rh-bar-red";
308}
309
310function formatHours(h: number): string {
311 if (h < 1) return `${Math.round(h * 60)}m`;
312 if (h < 24) return `${h.toFixed(1)}h`;
313 return `${(h / 24).toFixed(1)}d`;
314}
315
316// ─── Signal card rendering helpers ───────────────────────────────────────────
317
318function CiCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
319 const { score, rate, totalRuns, passedRuns } = breakdown.ciGreenRate;
320 const pct = score / 25;
321 const detail =
322 totalRuns === 0
323 ? "No gate runs in the last 30 days — benefit of the doubt applied."
324 : `${passedRuns} of ${totalRuns} gate runs passed (${Math.round(rate * 100)}%) in the last 30 days.`;
325
326 return (
327 <div class="rh-signal-card">
328 <div class="rh-signal-header">
329 <span class="rh-signal-name">CI Green Rate</span>
330 <span class="rh-signal-score">
331 <strong>{score}</strong> / 25
332 </span>
333 </div>
334 <div class="rh-bar-track">
335 <div
336 class={`rh-bar-fill ${barColor(pct)}`}
337 style={`width:${Math.round(pct * 100)}%`}
338 />
339 </div>
340 <div class="rh-signal-detail">{detail}</div>
341 </div>
342 );
343}
344
345function BusFactorCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
346 const { score, atRiskFileCount, criticalCount } = breakdown.busFactor;
347 const pct = score / 20;
348 const detail =
349 atRiskFileCount === 0
350 ? score === 15
351 ? "No bus factor analysis available yet."
352 : "No at-risk files detected — knowledge well distributed."
353 : `${atRiskFileCount} at-risk file${atRiskFileCount !== 1 ? "s" : ""} (${criticalCount} critical). High knowledge concentration detected.`;
354
355 return (
356 <div class="rh-signal-card">
357 <div class="rh-signal-header">
358 <span class="rh-signal-name">Bus Factor</span>
359 <span class="rh-signal-score">
360 <strong>{score}</strong> / 20
361 </span>
362 </div>
363 <div class="rh-bar-track">
364 <div
365 class={`rh-bar-fill ${barColor(pct)}`}
366 style={`width:${Math.round(pct * 100)}%`}
367 />
368 </div>
369 <div class="rh-signal-detail">{detail}</div>
370 </div>
371 );
372}
373
374function CveCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
375 const { score, count } = breakdown.openCves;
376 const pct = score / 20;
377 const detail =
378 count === 0
379 ? "No open CVE alerts — dependency security looks clean."
380 : `${count} open CVE alert${count !== 1 ? "s" : ""} detected in dependencies.`;
381
382 return (
383 <div class="rh-signal-card">
384 <div class="rh-signal-header">
385 <span class="rh-signal-name">Open CVEs</span>
386 <span class="rh-signal-score">
387 <strong>{score}</strong> / 20
388 </span>
389 </div>
390 <div class="rh-bar-track">
391 <div
392 class={`rh-bar-fill ${barColor(pct)}`}
393 style={`width:${Math.round(pct * 100)}%`}
394 />
395 </div>
396 <div class="rh-signal-detail">{detail}</div>
397 </div>
398 );
399}
400
401function VelocityCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
402 const { score, avgHours, sampleSize } = breakdown.reviewVelocity;
403 const pct = score / 15;
404 let detail: string;
405 if (avgHours === null) {
406 detail = "No merged PRs with human review comments in the last 30 days.";
407 } else {
408 detail = `Average time to first review: <strong>${formatHours(avgHours)}</strong> across ${sampleSize} PR${sampleSize !== 1 ? "s" : ""} (last 30 days).`;
409 }
410
411 return (
412 <div class="rh-signal-card">
413 <div class="rh-signal-header">
414 <span class="rh-signal-name">PR Review Velocity</span>
415 <span class="rh-signal-score">
416 <strong>{score}</strong> / 15
417 </span>
418 </div>
419 <div class="rh-bar-track">
420 <div
421 class={`rh-bar-fill rh-bar-blue`}
422 style={`width:${Math.round(pct * 100)}%`}
423 />
424 </div>
425 <div
426 class="rh-signal-detail"
427 dangerouslySetInnerHTML={{ __html: detail }}
428 />
429 </div>
430 );
431}
432
433function DebtCard({ breakdown }: { breakdown: HealthScoreBreakdown }) {
434 const { score, available } = breakdown.techDebt;
435 const pct = score / 20;
436 const detail = available
437 ? "Onboarding analysis available — neutral score applied (no debt-map data yet)."
438 : "No tech debt analysis available. Neutral score applied.";
439
440 return (
441 <div class="rh-signal-card">
442 <div class="rh-signal-header">
443 <span class="rh-signal-name">Tech Debt</span>
444 <span class="rh-signal-score">
445 <strong>{score}</strong> / 20
446 </span>
447 </div>
448 <div class="rh-bar-track">
449 <div
450 class={`rh-bar-fill rh-bar-purple`}
451 style={`width:${Math.round(pct * 100)}%`}
452 />
453 </div>
454 <div class="rh-signal-detail">{detail}</div>
455 </div>
456 );
457}
458
459// ─── Route: GET /:owner/:repo/health ─────────────────────────────────────────
460
461repoHealthRoutes.use("/:owner/:repo/health", softAuth);
462
463repoHealthRoutes.get(
464 "/:owner/:repo/health",
465 requireRepoAccess("read"),
466 async (c) => {
467 const { owner, repo } = c.req.param();
468 const user = c.get("user") ?? null;
469 const repository = (
470 c.get("repository" as never) as { id: string; ownerId: string } | null
471 );
472
473 if (!repository) return c.notFound();
474
475 const repoId = repository.id;
476 const isOwner = !!user && user.id === repository.ownerId;
477
478 const [breakdown, unreadCount] = await Promise.all([
479 getHealthScore(repoId),
480 user ? getUnreadCount(user.id) : Promise.resolve(0),
481 ]);
482
483 const color = scoreColor(breakdown.total);
484 const gauge = gaugeProps(breakdown.total);
485 const computedAtStr = breakdown.computedAt.toLocaleString();
486
487 return c.html(
488 <Layout
489 title={`Health Score — ${owner}/${repo}`}
490 user={user}
491 notificationCount={unreadCount}
492 >
493 <style dangerouslySetInnerHTML={{ __html: styles }} />
494 <div class="rh-wrap">
495 <RepoHeader owner={owner} repo={repo} healthScore={breakdown.total} />
496 <RepoNav owner={owner} repo={repo} active="insights" />
497
498 {/* Sub-navigation */}
499 <nav class="rh-subnav">
500 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights`}>Overview</a>
501 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/dora`}>DORA</a>
502 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/velocity`}>Velocity</a>
503 <a class="rh-subnav-link" href={`/${owner}/${repo}/pulse`}>Pulse</a>
504 <a class="rh-subnav-link active" href={`/${owner}/${repo}/health`}>Health</a>
505 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/hotfiles`}>Hot Files</a>
506 <a class="rh-subnav-link" href={`/${owner}/${repo}/insights/bus-factor`}>Bus Factor</a>
507 </nav>
508
509 {/* Hero */}
510 <div class={`rh-hero rh-hero--${color}`}>
511 {/* SVG circle gauge */}
512 <div class="rh-gauge">
513 <svg class="rh-gauge-svg" viewBox="0 0 140 140">
514 <circle
515 class="rh-gauge-track"
516 cx={gauge.cx}
517 cy={gauge.cy}
518 r={gauge.r}
519 />
520 <circle
521 class="rh-gauge-fill"
522 cx={gauge.cx}
523 cy={gauge.cy}
524 r={gauge.r}
525 stroke={gauge.color}
526 stroke-dasharray={gauge.dasharray}
527 stroke-dashoffset={gauge.dashoffset}
528 />
529 </svg>
530 <div class="rh-gauge-label">
531 <span class="rh-gauge-score">{breakdown.total}</span>
532 <span class="rh-gauge-max">/ 100</span>
533 </div>
534 </div>
535
536 <div class="rh-hero-body">
537 <div class="rh-hero-eyebrow">Repository Intelligence</div>
538 <h1 class="rh-hero-title">Health Score</h1>
539 <p class="rh-hero-sub">
540 Composite signal across CI reliability, bus factor, CVE exposure,
541 review velocity, and tech debt for{" "}
542 <strong>{owner}/{repo}</strong>.
543 </p>
544 <div class="rh-hero-actions">
545 <span class={`rh-score-pill rh-score-pill--${color}`}>
546 {breakdown.total >= 80 ? "Healthy" : breakdown.total >= 50 ? "Fair" : "Needs Attention"}
547 </span>
548 {isOwner && (
549 <form
550 method="post"
551 action={`/${owner}/${repo}/health/recompute`}
552 style="display:inline"
553 >
554 <button type="submit" class="rh-recompute-btn">
555 ↻ Recompute
556 </button>
557 </form>
558 )}
559 <span class="rh-computed-at">
560 Computed {computedAtStr}
561 </span>
562 </div>
563 </div>
564 </div>
565
566 {/* Signal breakdown */}
567 <h2 class="rh-section-title">Signal Breakdown</h2>
568 <div class="rh-signals">
569 <CiCard breakdown={breakdown} />
570 <BusFactorCard breakdown={breakdown} />
571 <CveCard breakdown={breakdown} />
572 <VelocityCard breakdown={breakdown} />
573 <DebtCard breakdown={breakdown} />
574 </div>
575 </div>
576 </Layout>
577 );
578 }
579);
580
581// ─── Route: POST /:owner/:repo/health/recompute ───────────────────────────────
582
583repoHealthRoutes.use("/:owner/:repo/health/recompute", requireAuth);
584
585repoHealthRoutes.post(
586 "/:owner/:repo/health/recompute",
587 requireRepoAccess("write"),
588 async (c) => {
589 const { owner, repo } = c.req.param();
590 const user = c.get("user")!;
591
592 // Only repo owner may force recompute
593 const repoRows = await db
594 .select({ id: repositories.id, ownerId: repositories.ownerId })
595 .from(repositories)
596 .innerJoin(users, eq(repositories.ownerId, users.id))
597 .where(
598 and(eq(users.username, owner), eq(repositories.name, repo))
599 )
600 .limit(1);
601
602 if (!repoRows.length) return c.notFound();
603 const repository = repoRows[0];
604
605 if (user.id !== repository.ownerId) {
606 return c.text("Forbidden", 403);
607 }
608
609 invalidateHealthScore(repository.id);
610 return c.redirect(`/${owner}/${repo}/health`);
611 }
612);
613
614export default repoHealthRoutes;