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

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.

health.tsxBlame543 lines · 1 contributor
2c34075Claude1/**
a7361c0Claude2 * Code Health Report routes.
2c34075Claude3 *
a7361c0Claude4 * GET /:owner/:repo/health — full health dashboard page
5 * POST /:owner/:repo/health/compute — trigger a fresh recompute (owner only)
2c34075Claude6 *
a7361c0Claude7 * The scoring engine lives in src/lib/health-score.ts and writes results to
8 * the repo_health_scores table. These routes load that data and render it.
2c34075Claude9 */
10
11import { Hono } from "hono";
a7361c0Claude12import { eq } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users } from "../db/schema";
15import { and } from "drizzle-orm";
2c34075Claude16import { Layout } from "../views/layout";
17import { RepoHeader, RepoNav } from "../views/components";
a7361c0Claude18import { softAuth, requireAuth } from "../middleware/auth";
2c34075Claude19import type { AuthEnv } from "../middleware/auth";
a7361c0Claude20import { requireRepoAccess } from "../middleware/repo-access";
21import {
22 getLatestHealthScore,
23 getHealthScoreHistory,
24 computeAndStoreHealthScore,
25 getBadgeColor,
26 type StoredHealthScore,
27 type HealthIssue,
28 type HealthRecommendation,
29} from "../lib/health-score";
30import { config } from "../lib/config";
2c34075Claude31
32const health = new Hono<AuthEnv>();
33
a7361c0Claude34// ---------------------------------------------------------------------------
35// Helpers
36// ---------------------------------------------------------------------------
2c34075Claude37
a7361c0Claude38async function resolveRepo(ownerName: string, repoName: string) {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
50 )
51 .limit(1);
52 if (!repo) return null;
53 return { owner, repo };
54}
2c34075Claude55
a7361c0Claude56function gradeColor(grade: string): string {
57 switch (grade) {
58 case "A":
59 return "#2ea44f";
60 case "B":
61 return "#44cc11";
62 case "C":
63 return "#dfb317";
64 case "D":
65 return "#fe7d37";
66 case "F":
67 return "#e05d44";
68 default:
69 return "#9f9f9f";
70 }
71}
2c34075Claude72
a7361c0Claude73function formatRelativeTime(date: Date): string {
74 const now = Date.now();
75 const diffMs = now - date.getTime();
76 const diffSec = Math.floor(diffMs / 1000);
77 if (diffSec < 60) return "just now";
78 const diffMin = Math.floor(diffSec / 60);
79 if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? "s" : ""} ago`;
80 const diffHr = Math.floor(diffMin / 60);
81 if (diffHr < 24) return `${diffHr} hour${diffHr !== 1 ? "s" : ""} ago`;
82 const diffDay = Math.floor(diffHr / 24);
83 return `${diffDay} day${diffDay !== 1 ? "s" : ""} ago`;
84}
2c34075Claude85
a7361c0Claude86function formatDate(date: Date): string {
87 return date.toLocaleDateString("en-US", {
88 month: "short",
89 day: "numeric",
90 });
91}
2c34075Claude92
a7361c0Claude93function severityColor(severity: HealthIssue["severity"]): string {
94 switch (severity) {
95 case "critical":
96 return "#e05d44";
97 case "high":
98 return "#fe7d37";
99 case "medium":
100 return "#dfb317";
101 case "low":
102 return "#9f9f9f";
103 default:
104 return "#9f9f9f";
105 }
106}
2c34075Claude107
a7361c0Claude108function severityDot(severity: HealthIssue["severity"]): string {
109 switch (severity) {
110 case "critical":
111 return "🔴"; // 🔴
112 case "high":
113 return "🟠"; // 🟠
114 case "medium":
115 return "🟡"; // 🟡
116 case "low":
117 return "⚪"; // ⚪
118 default:
119 return "⚪";
120 }
121}
2c34075Claude122
a7361c0Claude123function priorityLabel(priority: HealthRecommendation["priority"]): string {
124 return priority.toUpperCase();
125}
2c34075Claude126
a7361c0Claude127function priorityColor(priority: HealthRecommendation["priority"]): string {
128 switch (priority) {
129 case "high":
130 return "#e05d44";
131 case "medium":
132 return "#dfb317";
133 case "low":
134 return "#9f9f9f";
135 default:
136 return "#9f9f9f";
137 }
138}
2c34075Claude139
a7361c0Claude140// Category max scores (must sum to 100)
141const CATEGORY_MAX: Record<string, number> = {
142 security: 30,
143 gates: 25,
144 ai_review: 20,
145 dependencies: 15,
146 code_quality: 10,
147};
2c34075Claude148
a7361c0Claude149const CATEGORY_ICONS: Record<string, string> = {
150 security: "🔒", // 🔒
151 gates: "✅", // ✅
152 ai_review: "🤖", // 🤖
153 dependencies: "📦", // 📦
154 code_quality: "⭐", // ⭐
155};
2c34075Claude156
a7361c0Claude157const CATEGORY_LABELS: Record<string, string> = {
158 security: "Security",
159 gates: "Gates",
160 ai_review: "AI Review",
161 dependencies: "Dependencies",
162 code_quality: "Code Quality",
163};
164
165// ---------------------------------------------------------------------------
166// Category row component
167// ---------------------------------------------------------------------------
2c34075Claude168
a7361c0Claude169const CategoryRow = ({
170 categoryKey,
2c34075Claude171 score,
a7361c0Claude172 issues,
173 gradeStr,
2c34075Claude174}: {
a7361c0Claude175 categoryKey: string;
2c34075Claude176 score: number;
a7361c0Claude177 issues: HealthIssue[];
178 gradeStr: string;
2c34075Claude179}) => {
a7361c0Claude180 const max = CATEGORY_MAX[categoryKey] ?? 10;
181 const pct = Math.min(100, Math.round((score / max) * 100));
182 const color = gradeColor(gradeStr);
183 const icon = CATEGORY_ICONS[categoryKey] ?? "";
184 const label = CATEGORY_LABELS[categoryKey] ?? categoryKey;
185 const catIssues = issues.filter((i) => i.category === categoryKey);
186
2c34075Claude187 return (
a7361c0Claude188 <div style="margin-bottom: 16px">
189 <div style="display: flex; align-items: center; gap: 12px">
190 <span style="min-width: 140px; font-size: 14px; font-weight: 500; color: var(--text)">
191 {icon} {label}
192 </span>
193 <div
194 style="background: #21262d; border-radius: 4px; height: 8px; width: 200px; display: inline-block; flex-shrink: 0; overflow: hidden"
2c34075Claude195 >
a7361c0Claude196 <div
197 style={`height: 100%; width: ${pct}%; border-radius: 4px; background: ${color};`}
198 />
199 </div>
200 <span style="font-size: 13px; color: var(--text-muted); min-width: 56px">
201 {score}/{max}
2c34075Claude202 </span>
203 </div>
a7361c0Claude204 {catIssues.map((issue) => (
2c34075Claude205 <div
a7361c0Claude206 style={`margin-top: 4px; margin-left: 152px; font-size: 12px; color: ${severityColor(issue.severity)}`}
207 >
208 {severityDot(issue.severity)} {issue.message}
2c34075Claude209 </div>
210 ))}
211 </div>
212 );
213};
214
a7361c0Claude215// ---------------------------------------------------------------------------
216// GET /:owner/:repo/health
217// ---------------------------------------------------------------------------
218
219health.get(
220 "/:owner/:repo/health",
221 softAuth,
222 requireRepoAccess("read"),
223 async (c) => {
224 const { owner: ownerName, repo: repoName } = c.req.param();
225 const user = c.get("user");
226 const repoRow = c.get("repository" as any) as any;
227
228 const resolved = await resolveRepo(ownerName, repoName);
229 if (!resolved) return c.notFound();
230
231 const isOwner = user?.id === resolved.owner.id;
232
233 // Load latest score + history in parallel
234 const [scoreRow, history] = await Promise.all([
235 getLatestHealthScore(resolved.repo.id),
236 getHealthScoreHistory(resolved.repo.id, 10),
237 ]);
238
239 // No score yet — trigger background compute
240 if (!scoreRow) {
241 computeAndStoreHealthScore(resolved.repo.id, ownerName, repoName).catch(
242 (err) => console.error("[health] background compute failed:", err)
243 );
244 }
245
246 const baseUrl = config.appBaseUrl;
247 const badgeUrl = `${baseUrl}/badge/${ownerName}/${repoName}`;
248 const pageUrl = `${baseUrl}/${ownerName}/${repoName}/health`;
249 const badgeMarkdown = `[![Gluecron Health${scoreRow ? ": " + scoreRow.grade : ""}](${badgeUrl})](${pageUrl})`;
250
251 return c.html(
252 <Layout
253 title={`Health — ${ownerName}/${repoName}`}
254 user={user}
255 >
256 <RepoHeader owner={ownerName} repo={repoName} />
257 <RepoNav owner={ownerName} repo={repoName} active={"health" as any} />
258
259 {!scoreRow ? (
260 // Computing state
261 <div
262 style="text-align: center; padding: 60px 20px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 24px"
263 >
264 <div style="font-size: 48px; margin-bottom: 16px">&#x231B;</div>
265 <h2 style="font-size: 24px; margin-bottom: 8px; color: var(--text)">
266 Computing health score…
267 </h2>
268 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 24px">
269 This is the first time we've analysed this repository. Results will
270 be ready shortly — refresh this page in a few seconds.
271 </p>
272 {isOwner && (
273 <form method="post" action={`/${ownerName}/${repoName}/health/compute`} style="display:inline">
274 <button type="submit" class="btn btn-primary">
275 Compute now
276 </button>
277 </form>
278 )}
279 </div>
280 ) : (
281 <>
282 {/* ── Hero section ─────────────────────────────────── */}
283 <div
284 style={`background: var(--bg-secondary); border: 2px solid ${gradeColor(scoreRow.grade)}; border-radius: var(--radius); padding: 24px; margin-bottom: 24px`}
285 >
286 <div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px">
287 <div>
288 <h2 style="font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 4px">
289 Code Health
290 </h2>
291 <div style="font-size: 13px; color: var(--text-muted)">
292 Last computed: {formatRelativeTime(new Date(scoreRow.computedAt))}
293 </div>
294 </div>
295 {isOwner && (
296 <form method="post" action={`/${ownerName}/${repoName}/health/compute`} style="display:inline">
297 <button type="submit" class="btn btn-sm">
298 &#x21BA; Recompute
299 </button>
300 </form>
301 )}
302 </div>
303
304 <div style="display: flex; align-items: center; gap: 32px; margin-top: 20px; flex-wrap: wrap">
305 <div style="text-align: center">
306 <div
307 style={`font-size: 72px; font-weight: 700; line-height: 1; color: ${gradeColor(scoreRow.grade)}`}
308 >
309 {scoreRow.grade}
310 </div>
311 <div style="font-size: 14px; color: var(--text-muted); margin-top: 4px">
312 Score: {scoreRow.score}/100
313 </div>
314 </div>
315
316 <div style="flex: 1; min-width: 200px">
317 <CategoryRow
318 categoryKey="security"
319 score={scoreRow.securityScore}
320 issues={scoreRow.issues}
321 gradeStr={scoreRow.grade}
322 />
323 <CategoryRow
324 categoryKey="gates"
325 score={scoreRow.gatesScore}
326 issues={scoreRow.issues}
327 gradeStr={scoreRow.grade}
328 />
329 <CategoryRow
330 categoryKey="ai_review"
331 score={scoreRow.aiReviewScore}
332 issues={scoreRow.issues}
333 gradeStr={scoreRow.grade}
334 />
335 <CategoryRow
336 categoryKey="dependencies"
337 score={scoreRow.dependenciesScore}
338 issues={scoreRow.issues}
339 gradeStr={scoreRow.grade}
340 />
341 <CategoryRow
342 categoryKey="code_quality"
343 score={scoreRow.codeQualityScore}
344 issues={scoreRow.issues}
345 gradeStr={scoreRow.grade}
346 />
347 </div>
348 </div>
349
350 {/* Badge copy */}
351 <div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border)">
352 <button
353 type="button"
354 class="btn btn-sm"
355 onclick={`navigator.clipboard.writeText(${JSON.stringify(badgeMarkdown)}).then(function(){var b=this;b.textContent='Copied!';setTimeout(function(){b.textContent='Copy badge markdown'},1500)}.bind(this)).catch(function(){alert('Copy failed')})`}
356 >
357 Copy badge markdown
358 </button>
359 </div>
360 </div>
361
362 {/* ── Issues section ────────────────────────────────── */}
363 {scoreRow.issues.length > 0 && (
364 <div
365 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px"
366 >
367 <h3
368 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border)"
369 >
370 Issues Found
371 </h3>
372 {scoreRow.issues.map((issue) => (
373 <div
374 style={`display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px`}
375 >
376 <span style="flex-shrink: 0; font-size: 16px">
377 {severityDot(issue.severity)}
378 </span>
379 <span
380 class="badge"
381 style={`flex-shrink: 0; font-size: 11px; color: ${severityColor(issue.severity)}; border-color: ${severityColor(issue.severity)}; text-transform: uppercase; padding: 1px 6px`}
382 >
383 {issue.severity}
384 </span>
385 <span style="color: var(--text)">
386 {issue.message}
387 </span>
388 </div>
389 ))}
390 </div>
391 )}
392
393 {/* ── Recommendations section ────────────────────────── */}
394 {scoreRow.recommendations.length > 0 && (
395 <div
396 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px"
397 >
398 <h3
399 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border)"
400 >
401 How to improve your score
402 </h3>
403 {scoreRow.recommendations.map((rec) => (
404 <div
405 style="display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px"
406 >
407 <span style="flex-shrink: 0; color: var(--text-muted)">
408 &#x2191;
409 </span>
410 <span
411 class="badge"
412 style={`flex-shrink: 0; font-size: 11px; color: ${priorityColor(rec.priority)}; border-color: ${priorityColor(rec.priority)}; text-transform: uppercase; padding: 1px 6px`}
413 >
414 {priorityLabel(rec.priority)}
415 </span>
416 <span style="color: var(--text)">
417 {rec.message}
418 </span>
419 </div>
420 ))}
421 </div>
422 )}
423
424 {/* ── Share your badge ──────────────────────────────── */}
425 <div
426 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px"
427 >
428 <h3
429 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border)"
430 >
431 Share your health badge
432 </h3>
433
434 <div style="margin-bottom: 12px">
435 <img
436 src={badgeUrl}
437 alt={`Gluecron Health: ${scoreRow.grade}`}
438 style="height: 20px; display: inline-block; vertical-align: middle"
439 />
440 </div>
441
442 <div
443 style="background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); word-break: break-all; margin-bottom: 12px"
444 >
445 {badgeMarkdown}
446 </div>
447
448 <div style="display: flex; gap: 8px; flex-wrap: wrap">
449 <button
450 type="button"
451 class="btn btn-sm"
452 onclick={`navigator.clipboard.writeText(${JSON.stringify(badgeMarkdown)}).then(function(){var b=this;b.textContent='Copied!';setTimeout(function(){b.textContent='Copy markdown'},1500)}.bind(this)).catch(function(){alert('Copy failed')})`}
453 >
454 Copy markdown
455 </button>
456 <button
457 type="button"
458 class="btn btn-sm"
459 onclick={`navigator.clipboard.writeText(${JSON.stringify(badgeUrl)}).then(function(){var b=this;b.textContent='Copied!';setTimeout(function(){b.textContent='Copy URL'},1500)}.bind(this)).catch(function(){alert('Copy failed')})`}
460 >
461 Copy URL
462 </button>
463 </div>
464 </div>
465
466 {/* ── Score history ─────────────────────────────────── */}
467 {history.length > 1 && (
468 <div
469 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 24px"
470 >
471 <h3
472 style="font-size: 16px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border)"
473 >
474 Score history
475 </h3>
476 <table style="width: 100%; border-collapse: collapse; font-size: 14px">
477 <thead>
478 <tr style="color: var(--text-muted); font-size: 12px; border-bottom: 1px solid var(--border)">
479 <th style="text-align: left; padding: 4px 8px; font-weight: 500">Date</th>
480 <th style="text-align: left; padding: 4px 8px; font-weight: 500">Grade</th>
481 <th style="text-align: right; padding: 4px 8px; font-weight: 500">Score</th>
482 </tr>
483 </thead>
484 <tbody>
485 {history.map((row, i) => (
486 <tr
487 style={`border-bottom: 1px solid var(--border); ${i === 0 ? "font-weight: 600" : ""}`}
488 >
489 <td style="padding: 6px 8px; color: var(--text-muted)">
490 {formatDate(new Date(row.computedAt))}
491 </td>
492 <td style={`padding: 6px 8px; font-weight: 700; color: ${gradeColor(row.grade)}`}>
493 {row.grade}
494 </td>
495 <td style="padding: 6px 8px; text-align: right; color: var(--text)">
496 {row.score}
497 </td>
498 </tr>
499 ))}
500 </tbody>
501 </table>
502 </div>
503 )}
504 </>
505 )}
506 </Layout>
507 );
508 }
509);
510
511// ---------------------------------------------------------------------------
512// POST /:owner/:repo/health/compute — trigger recompute (owner only)
513// ---------------------------------------------------------------------------
514
515health.post(
516 "/:owner/:repo/health/compute",
517 softAuth,
518 requireAuth,
519 requireRepoAccess("admin"),
520 async (c) => {
521 const { owner: ownerName, repo: repoName } = c.req.param();
522 const user = c.get("user")!;
523
524 const resolved = await resolveRepo(ownerName, repoName);
525 if (!resolved) return c.notFound();
526
527 // Only the repo owner may trigger a manual recompute
528 if (user.id !== resolved.owner.id) {
529 return c.redirect(`/${ownerName}/${repoName}/health`);
530 }
531
532 // Run synchronously so the redirect shows fresh data
533 try {
534 await computeAndStoreHealthScore(resolved.repo.id, ownerName, repoName);
535 } catch (err) {
536 console.error("[health/compute] failed:", err);
537 }
538
539 return c.redirect(`/${ownerName}/${repoName}/health`);
540 }
541);
2c34075Claude542
543export default health;