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

health-score.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.

health-score.tsxBlame419 lines · 1 contributor
74d8c4dClaude1/**
2 * Repository Health Score Dashboard — M14.
3 *
4 * Route: GET /:owner/:repo/insights/health
5 *
6 * Composite 0-100 score combining:
7 * - Security (0-30 pts): open advisory alerts
8 * - Green Gates (0-25 pts): gate pass rate (30d)
9 * - Velocity (0-25 pts): avg PR time-to-merge (90d)
10 * - Maintenance (0-20 pts): avg open issue age
11 *
12 * Zero new DB tables — pure computation from existing tables via
13 * src/lib/health-score.ts.
14 *
15 * Scoped CSS: `.hs-*`
16 */
17
18import { Hono } from "hono";
19import type { AuthEnv } from "../middleware/auth";
20import { softAuth } from "../middleware/auth";
21import { requireRepoAccess } from "../middleware/repo-access";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { getUnreadCount } from "../lib/unread";
25import { computeHealthScore } from "../lib/health-score";
26
27const healthRoutes = new Hono<AuthEnv>();
28
29// ─── CSS ──────────────────────────────────────────────────────────────────────
30
31const styles = `
32 .hs-wrap {
33 max-width: 1080px;
34 margin: 0 auto;
35 padding: var(--space-5) var(--space-4);
36 }
37
38 /* Insights sub-navigation — mirrors .vel-subnav */
39 .hs-subnav {
40 display: flex;
41 gap: 4px;
42 margin-bottom: var(--space-5);
43 border-bottom: 1px solid var(--border);
44 padding-bottom: 0;
45 }
46 .hs-subnav-link {
47 padding: 8px 14px;
48 font-size: 13px;
49 font-weight: 500;
50 color: var(--text-muted);
51 text-decoration: none;
52 border-bottom: 2px solid transparent;
53 margin-bottom: -1px;
54 transition: color 120ms ease, border-color 120ms ease;
55 border-radius: 4px 4px 0 0;
56 }
57 .hs-subnav-link:hover { color: var(--text); }
58 .hs-subnav-link.active {
59 color: var(--accent, #5865f2);
60 border-bottom-color: var(--accent, #5865f2);
61 }
62
63 /* Hero card */
64 .hs-hero {
65 position: relative;
66 margin-bottom: var(--space-5);
67 padding: var(--space-5) var(--space-6);
68 background: var(--bg-elevated);
69 border: 1px solid var(--border);
70 border-radius: 14px;
71 overflow: hidden;
72 display: flex;
73 align-items: center;
74 gap: var(--space-6);
75 flex-wrap: wrap;
76 }
77 .hs-hero::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #3b82f6 70%, transparent 100%);
83 opacity: 0.8;
84 pointer-events: none;
85 }
86
87 /* Circular gauge (CSS-only) */
88 .hs-gauge {
89 position: relative;
90 width: 120px;
91 height: 120px;
92 flex-shrink: 0;
93 }
94 .hs-gauge-svg {
95 width: 120px;
96 height: 120px;
97 transform: rotate(-90deg);
98 }
99 .hs-gauge-track {
100 fill: none;
101 stroke: var(--border);
102 stroke-width: 10;
103 }
104 .hs-gauge-fill {
105 fill: none;
106 stroke-width: 10;
107 stroke-linecap: round;
108 transition: stroke-dashoffset 600ms ease;
109 }
110 .hs-gauge-label {
111 position: absolute;
112 inset: 0;
113 display: flex;
114 flex-direction: column;
115 align-items: center;
116 justify-content: center;
117 text-align: center;
118 }
119 .hs-gauge-score {
120 font-size: 28px;
121 font-weight: 800;
122 font-variant-numeric: tabular-nums;
123 line-height: 1;
124 color: var(--text);
125 }
126 .hs-gauge-max {
127 font-size: 11px;
128 color: var(--text-muted);
129 margin-top: 2px;
130 }
131
132 /* Grade badge */
133 .hs-grade-badge {
134 display: inline-block;
135 padding: 4px 14px;
136 border-radius: 20px;
137 font-size: 12px;
138 font-weight: 700;
139 letter-spacing: 0.06em;
140 text-transform: uppercase;
141 }
142 .hs-grade-elite { background: rgba(52,211,153,.15); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
143 .hs-grade-strong { background: rgba(96,165,250,.15); color: #60a5fa; border: 1px solid rgba(96,165,250,.3); }
144 .hs-grade-improving { background: rgba(250,204,21,.15); color: #facc15; border: 1px solid rgba(250,204,21,.3); }
145 .hs-grade-needs-attention { background: rgba(248,113,113,.15); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
146
147 .hs-hero-text {
148 flex: 1;
149 min-width: 200px;
150 }
151 .hs-hero-title {
152 font-size: 22px;
153 font-weight: 700;
154 margin: 0 0 var(--space-2) 0;
155 color: var(--text);
156 }
157 .hs-hero-sub {
158 color: var(--text-muted);
159 font-size: 14px;
160 margin: 0 0 var(--space-3) 0;
161 line-height: 1.5;
162 }
163
164 /* Component bars */
165 .hs-components {
166 margin-bottom: var(--space-5);
167 display: flex;
168 flex-direction: column;
169 gap: var(--space-3);
170 }
171 .hs-component {
172 background: var(--bg-elevated);
173 border: 1px solid var(--border);
174 border-radius: 12px;
175 padding: var(--space-4) var(--space-5);
176 }
177 .hs-component-header {
178 display: flex;
179 align-items: baseline;
180 justify-content: space-between;
181 margin-bottom: 8px;
182 }
183 .hs-component-label {
184 font-size: 14px;
185 font-weight: 600;
186 color: var(--text);
187 }
188 .hs-component-score {
189 font-size: 13px;
190 font-variant-numeric: tabular-nums;
191 color: var(--text-muted);
192 }
193 .hs-component-score strong {
194 color: var(--text);
195 font-weight: 700;
196 }
197 .hs-bar-track {
198 height: 8px;
199 background: var(--border);
200 border-radius: 4px;
201 overflow: hidden;
202 margin-bottom: 6px;
203 }
204 .hs-bar-fill {
205 height: 100%;
206 border-radius: 4px;
207 transition: width 500ms ease;
208 }
209 .hs-component-hint {
210 font-size: 12px;
211 color: var(--text-muted);
212 }
213
214 /* Bar fill colours keyed to component */
215 .hs-fill-security { background: #f87171; }
216 .hs-fill-security.good { background: #34d399; }
217 .hs-fill-greenGates { background: #34d399; }
218 .hs-fill-velocity { background: #60a5fa; }
219 .hs-fill-maintenance { background: #a78bfa; }
220
221 /* Section title */
222 .hs-section-title {
223 font-size: 15px;
224 font-weight: 600;
225 color: var(--text);
226 margin: 0 0 var(--space-3) 0;
227 }
228`;
229
230// ─── Helpers ──────────────────────────────────────────────────────────────────
231
232function gradeLabel(grade: string): string {
233 switch (grade) {
234 case "elite": return "Elite";
235 case "strong": return "Strong";
236 case "improving": return "Improving";
237 case "needs-attention": return "Needs Attention";
238 default: return grade;
239 }
240}
241
242/** Gauge stroke-dasharray / stroke-dashoffset for an SVG circle of r=50 */
243function gaugeProps(score: number): { dasharray: string; dashoffset: string; color: string } {
244 const circumference = 2 * Math.PI * 50; // ≈ 314.16
245 const dashoffset = circumference * (1 - score / 100);
246 const color =
247 score >= 85 ? "#34d399" :
248 score >= 70 ? "#60a5fa" :
249 score >= 50 ? "#facc15" :
250 "#f87171";
251 return {
252 dasharray: circumference.toFixed(2),
253 dashoffset: dashoffset.toFixed(2),
254 color,
255 };
256}
257
258/** Pick bar-fill CSS class for a component key */
259function barClass(key: string, score: number, max: number): string {
260 const base = `hs-bar-fill hs-fill-${key}`;
261 // Security bar flips to green when full score
262 if (key === "security" && score === max) return `${base} good`;
263 return base;
264}
265
266// ─── Path-scoped middleware ────────────────────────────────────────────────────
267
268healthRoutes.use("/:owner/:repo/insights/health", softAuth);
269
270// ─── GET handler ──────────────────────────────────────────────────────────────
271
272healthRoutes.get(
273 "/:owner/:repo/insights/health",
274 requireRepoAccess("read"),
275 async (c) => {
276 const { owner, repo } = c.req.param();
277 const user = c.get("user") ?? null;
278 const repository = (
279 c.get("repository" as never) as { id: string; name: string; isPrivate: boolean }
280 ) ?? null;
281
282 if (!repository) {
283 return c.html("Repository not found", 404);
284 }
285
286 const repoId = repository.id;
287
288 // Compute health score + unread count in parallel
289 const [health, unreadCount] = await Promise.all([
290 computeHealthScore(repoId),
291 user ? getUnreadCount(user.id) : Promise.resolve(0),
292 ]);
293
294 const gauge = gaugeProps(health.total);
295
296 // Ordered component entries for rendering
297 const componentEntries = [
298 { key: "security", data: health.components.security },
299 { key: "greenGates", data: health.components.greenGates },
300 { key: "velocity", data: health.components.velocity },
301 { key: "maintenance", data: health.components.maintenance },
302 ] as const;
303
304 return c.html(
305 <Layout
306 title={`Health Score — ${owner}/${repo}`}
307 user={user}
308 notificationCount={unreadCount}
309 >
310 <style dangerouslySetInnerHTML={{ __html: styles }} />
311 <div class="hs-wrap">
312 <RepoHeader owner={owner} repo={repo} />
313 <RepoNav owner={owner} repo={repo} active="insights" />
314
315 {/* Insights sub-nav */}
316 <div class="hs-subnav">
317 <a href={`/${owner}/${repo}/insights`} class="hs-subnav-link">
318 Insights
319 </a>
320 <a href={`/${owner}/${repo}/insights/dora`} class="hs-subnav-link">
321 DORA
322 </a>
323 <a
324 href={`/${owner}/${repo}/insights/velocity`}
325 class="hs-subnav-link"
326 >
327 Velocity
328 </a>
329 <a href={`/${owner}/${repo}/pulse`} class="hs-subnav-link">
330 Pulse
331 </a>
332 <a
333 href={`/${owner}/${repo}/insights/health`}
334 class="hs-subnav-link active"
335 >
336 Health
337 </a>
338 <a
339 href={`/${owner}/${repo}/insights/hotfiles`}
340 class="hs-subnav-link"
341 >
342 Hot Files
343 </a>
344 </div>
345
346 {/* Hero — gauge + title + grade */}
347 <div class="hs-hero">
348 {/* CSS-only SVG circle gauge */}
349 <div class="hs-gauge">
350 <svg class="hs-gauge-svg" viewBox="0 0 120 120">
351 <circle
352 class="hs-gauge-track"
353 cx="60"
354 cy="60"
355 r="50"
356 />
357 <circle
358 class="hs-gauge-fill"
359 cx="60"
360 cy="60"
361 r="50"
362 stroke={gauge.color}
363 stroke-dasharray={gauge.dasharray}
364 stroke-dashoffset={gauge.dashoffset}
365 />
366 </svg>
367 <div class="hs-gauge-label">
368 <span class="hs-gauge-score">{health.total}</span>
369 <span class="hs-gauge-max">/ 100</span>
370 </div>
371 </div>
372
373 <div class="hs-hero-text">
374 <h1 class="hs-hero-title">Repository Health Score</h1>
375 <p class="hs-hero-sub">
376 Composite score across security, gate reliability, PR velocity,
377 and issue maintenance for{" "}
378 <strong>
379 {owner}/{repo}
380 </strong>
381 .
382 </p>
383 <span class={`hs-grade-badge hs-grade-${health.grade}`}>
384 {gradeLabel(health.grade)}
385 </span>
386 </div>
387 </div>
388
389 {/* Component breakdown */}
390 <h2 class="hs-section-title">Score Breakdown</h2>
391 <div class="hs-components">
392 {componentEntries.map(({ key, data }) => {
393 const pct = data.max > 0 ? (data.score / data.max) * 100 : 0;
394 return (
395 <div class="hs-component" key={key}>
396 <div class="hs-component-header">
397 <span class="hs-component-label">{data.label}</span>
398 <span class="hs-component-score">
399 <strong>{data.score}</strong> / {data.max}
400 </span>
401 </div>
402 <div class="hs-bar-track">
403 <div
404 class={barClass(key, data.score, data.max)}
405 style={`width: ${pct.toFixed(1)}%`}
406 />
407 </div>
408 <div class="hs-component-hint">{data.hint}</div>
409 </div>
410 );
411 })}
412 </div>
413 </div>
414 </Layout>
415 );
416 }
417);
418
419export default healthRoutes;