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

bus-factor.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.

bus-factor.tsxBlame488 lines · 1 contributor
1d6db4dClaude1/**
2 * Bus Factor Report — /:owner/:repo/insights/bus-factor
3 *
4 * Lists files where knowledge is concentrated in a single author.
5 * Includes a pure-CSS bar chart per file and a "Re-analyze" button
6 * (owner-only) that triggers a fresh background analysis.
7 *
8 * GET /:owner/:repo/insights/bus-factor — report page
9 * POST /:owner/:repo/insights/bus-factor/reanalyze — trigger fresh analysis
10 */
11
12import { Hono } from "hono";
13import { db } from "../db";
14import { repositories, users, busFactorCache } from "../db/schema";
15import { and, eq } from "drizzle-orm";
16import type { AuthEnv } from "../middleware/auth";
17import { softAuth, requireAuth } from "../middleware/auth";
18import { requireRepoAccess } from "../middleware/repo-access";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { getUnreadCount } from "../lib/unread";
22import {
23 analyzeBusFactor,
24 type BusFactorFile,
25 type BusFactorReport,
26} from "../lib/bus-factor";
27
28const busFactorRoutes = new Hono<AuthEnv>();
29
30// ─── CSS ──────────────────────────────────────────────────────────────────────
31
32const styles = `
33 .bf-wrap {
34 max-width: 1080px;
35 margin: 0 auto;
36 padding: var(--space-5) var(--space-4);
37 }
38
39 /* Insights sub-navigation */
40 .bf-subnav {
41 display: flex;
42 gap: 4px;
43 margin-bottom: var(--space-5);
44 border-bottom: 1px solid var(--border);
45 padding-bottom: 0;
46 }
47 .bf-subnav-link {
48 padding: 8px 14px;
49 font-size: 13px;
50 font-weight: 500;
51 color: var(--text-muted);
52 text-decoration: none;
53 border-bottom: 2px solid transparent;
54 margin-bottom: -1px;
55 transition: color 120ms ease, border-color 120ms ease;
56 border-radius: 4px 4px 0 0;
57 }
58 .bf-subnav-link:hover { color: var(--text); }
59 .bf-subnav-link.active {
60 color: var(--accent, #5865f2);
61 border-bottom-color: var(--accent, #5865f2);
62 }
63
64 /* Hero */
65 .bf-hero {
66 position: relative;
67 margin-bottom: var(--space-5);
68 padding: var(--space-5) var(--space-6);
69 background: var(--bg-elevated);
70 border: 1px solid var(--border);
71 border-radius: 16px;
72 overflow: hidden;
73 }
74 .bf-hero::before {
75 content: '';
76 position: absolute;
77 top: 0; left: 0; right: 0;
78 height: 2px;
79 background: linear-gradient(90deg, transparent 0%, #f59e0b 30%, #ef4444 70%, transparent 100%);
80 opacity: 0.75;
81 pointer-events: none;
82 }
83 .bf-hero-orb {
84 position: absolute;
85 inset: -30% -15% auto auto;
86 width: 460px; height: 460px;
87 background: radial-gradient(circle, rgba(245,158,11,0.16), rgba(239,68,68,0.08) 45%, transparent 70%);
88 filter: blur(80px);
89 opacity: 0.75;
90 pointer-events: none;
91 z-index: 0;
92 }
93 .bf-hero-inner { position: relative; z-index: 1; max-width: 760px; }
94 .bf-hero-eyebrow {
95 display: inline-flex; align-items: center; gap: 6px;
96 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
97 text-transform: uppercase;
98 color: #f59e0b;
99 margin-bottom: 10px;
100 }
101 .bf-hero-title {
102 font-family: var(--font-display);
103 font-size: clamp(22px, 3vw, 30px);
104 font-weight: 800;
105 letter-spacing: -0.025em;
106 line-height: 1.1;
107 margin: 0 0 8px;
108 color: var(--text-strong);
109 }
110 .bf-hero-sub {
111 font-size: 14px;
112 color: var(--text-muted);
113 margin: 0;
114 line-height: 1.5;
115 }
116 .bf-hero-actions {
117 display: flex;
118 gap: 10px;
119 align-items: center;
120 margin-top: 16px;
121 flex-wrap: wrap;
122 }
123
124 /* Stats bar */
125 .bf-stats {
126 display: flex;
127 gap: 16px;
128 margin-bottom: var(--space-5);
129 flex-wrap: wrap;
130 }
131 .bf-stat-card {
132 flex: 1 1 160px;
133 padding: 14px 18px;
134 background: var(--bg-elevated);
135 border: 1px solid var(--border);
136 border-radius: 12px;
137 min-width: 120px;
138 }
139 .bf-stat-value {
140 font-family: var(--font-display);
141 font-size: 26px;
142 font-weight: 800;
143 line-height: 1;
144 margin-bottom: 4px;
145 color: var(--text-strong);
146 font-variant-numeric: tabular-nums;
147 }
148 .bf-stat-label {
149 font-size: 12px;
150 color: var(--text-muted);
151 font-weight: 500;
152 }
153 .bf-stat-card.is-critical .bf-stat-value { color: #ef4444; }
154 .bf-stat-card.is-high .bf-stat-value { color: #f97316; }
155 .bf-stat-card.is-medium .bf-stat-value { color: #f59e0b; }
156
157 /* File list */
158 .bf-list {
159 display: flex;
160 flex-direction: column;
161 gap: 10px;
162 }
163 .bf-file-card {
164 padding: 14px 18px;
165 background: var(--bg-elevated);
166 border: 1px solid var(--border);
167 border-radius: 12px;
168 transition: border-color 120ms ease;
169 }
170 .bf-file-card:hover { border-color: rgba(245,158,11,0.4); }
171 .bf-file-card.risk-critical { border-left: 3px solid #ef4444; }
172 .bf-file-card.risk-high { border-left: 3px solid #f97316; }
173 .bf-file-card.risk-medium { border-left: 3px solid #f59e0b; }
174
175 .bf-file-header {
176 display: flex;
177 align-items: center;
178 gap: 10px;
179 margin-bottom: 10px;
180 flex-wrap: wrap;
181 }
182 .bf-file-path {
183 flex: 1 1 auto;
184 font-family: var(--font-mono);
185 font-size: 13px;
186 font-weight: 600;
187 color: var(--text-strong);
188 word-break: break-all;
189 }
190 .bf-risk-badge {
191 display: inline-flex;
192 align-items: center;
193 padding: 2px 9px;
194 border-radius: 9999px;
195 font-size: 10.5px;
196 font-weight: 700;
197 letter-spacing: 0.04em;
198 text-transform: uppercase;
199 flex-shrink: 0;
200 }
201 .bf-risk-badge.is-critical { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
202 .bf-risk-badge.is-high { color: #f97316; background: rgba(249,115,22,0.12); border: 1px solid rgba(249,115,22,0.3); }
203 .bf-risk-badge.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
204
205 .bf-file-meta {
206 display: flex;
207 gap: 16px;
208 font-size: 12px;
209 color: var(--text-muted);
210 margin-bottom: 10px;
211 flex-wrap: wrap;
212 }
213
214 /* CSS bar chart — no JS */
215 .bf-bar-wrap {
216 display: flex;
217 align-items: center;
218 gap: 10px;
219 }
220 .bf-bar-label {
221 font-size: 12px;
222 color: var(--text-muted);
223 white-space: nowrap;
224 min-width: 140px;
225 overflow: hidden;
226 text-overflow: ellipsis;
227 }
228 .bf-bar-track {
229 flex: 1;
230 height: 10px;
231 background: var(--bg-tertiary, rgba(255,255,255,0.06));
232 border-radius: 99px;
233 overflow: hidden;
234 }
235 .bf-bar-fill {
236 height: 100%;
237 border-radius: 99px;
238 background: linear-gradient(90deg, #f59e0b, #ef4444);
239 transition: width 300ms ease;
240 }
241 .bf-bar-pct {
242 font-size: 12px;
243 font-weight: 600;
244 color: var(--text-muted);
245 min-width: 40px;
246 text-align: right;
247 font-variant-numeric: tabular-nums;
248 }
249
250 /* Empty state */
251 .bf-empty {
252 text-align: center;
253 padding: 64px 24px;
254 color: var(--text-muted);
255 }
256 .bf-empty-icon { font-size: 40px; margin-bottom: 12px; }
257 .bf-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
258 .bf-empty-sub { font-size: 14px; }
259
260 /* Action button */
261 .bf-reanalyze-btn {
262 display: inline-flex; align-items: center; gap: 6px;
263 padding: 8px 16px;
264 border-radius: 8px;
265 font-size: 13px; font-weight: 600;
266 color: #fff;
267 background: linear-gradient(135deg, #f59e0b 0%, #ef4444 130%);
268 border: none;
269 cursor: pointer;
270 transition: opacity 120ms ease;
271 }
272 .bf-reanalyze-btn:hover { opacity: 0.85; }
273
274 .bf-analyzed-at {
275 font-size: 12px;
276 color: var(--text-muted);
277 margin-top: 16px;
278 }
279`;
280
281// ─── Route: GET /:owner/:repo/insights/bus-factor ─────────────────────────────
282
283busFactorRoutes.use("/:owner/:repo/insights/bus-factor", softAuth);
284
285busFactorRoutes.get("/:owner/:repo/insights/bus-factor", requireRepoAccess("read"), async (c) => {
286 const user = c.get("user") ?? null;
287 const params = c.req.param() as { owner: string; repo: string };
288 const ownerName = params.owner;
289 const repoName = params.repo;
290
291 // Load repo
292 const repoRows = await db
293 .select({ id: repositories.id, isPrivate: repositories.isPrivate, ownerId: repositories.ownerId })
294 .from(repositories)
295 .innerJoin(users, eq(repositories.ownerId, users.id))
296 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
297 .limit(1);
298
299 if (!repoRows.length) return c.notFound();
300 const repo = repoRows[0];
301 const isOwner = !!user && user.id === repo.ownerId;
302
303 const unreadCount = user ? await getUnreadCount(user.id) : 0;
304
305 // Load cached report
306 let report: BusFactorReport | null = null;
307 const cacheRows = await db
308 .select()
309 .from(busFactorCache)
310 .where(eq(busFactorCache.repositoryId, repo.id))
311 .limit(1);
312
313 if (cacheRows.length > 0) {
314 const cached = cacheRows[0];
315 report = {
316 repoId: repo.id,
317 analyzedAt: cached.analyzedAt.toISOString(),
318 atRiskFiles: cached.atRiskFiles as BusFactorFile[],
319 totalFilesAnalyzed: cached.totalFilesAnalyzed,
320 };
321 }
322
323 const criticalCount = report?.atRiskFiles.filter((f) => f.risk === "critical").length ?? 0;
324 const highCount = report?.atRiskFiles.filter((f) => f.risk === "high").length ?? 0;
325 const mediumCount = report?.atRiskFiles.filter((f) => f.risk === "medium").length ?? 0;
326
327 return c.html(
328 <Layout
329 title={`Bus Factor — ${ownerName}/${repoName}`}
330 user={user}
331 notificationCount={unreadCount}
332 >
333 <style dangerouslySetInnerHTML={{ __html: styles }} />
334 <div class="bf-wrap">
335 <RepoHeader owner={ownerName} repo={repoName} />
336 <RepoNav owner={ownerName} repo={repoName} active="insights" />
337
338 {/* Sub-navigation */}
339 <nav class="bf-subnav">
340 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
341 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
342 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
343 <a class="bf-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
344 <a class="bf-subnav-link active" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
345 </nav>
346
347 {/* Hero */}
348 <div class="bf-hero">
349 <div class="bf-hero-orb" aria-hidden="true" />
350 <div class="bf-hero-inner">
351 <div class="bf-hero-eyebrow">⚠ Knowledge Risk</div>
352 <h1 class="bf-hero-title">Bus Factor Analysis</h1>
353 <p class="bf-hero-sub">
354 Files where a single author owns more than 75% of commits.
355 If that person leaves, the team loses critical context.
356 </p>
357 <div class="bf-hero-actions">
358 {isOwner && (
359 <form method="post" action={`/${ownerName}/${repoName}/insights/bus-factor/reanalyze`}>
360 <button type="submit" class="bf-reanalyze-btn">
361 ↻ Re-analyze
362 </button>
363 </form>
364 )}
365 {report && (
366 <span class="bf-analyzed-at">
367 Last analyzed {new Date(report.analyzedAt).toLocaleDateString()} ·{" "}
368 {report.totalFilesAnalyzed} code files scanned
369 </span>
370 )}
371 </div>
372 </div>
373 </div>
374
375 {report ? (
376 <>
377 {/* Stats */}
378 <div class="bf-stats">
379 <div class="bf-stat-card is-critical">
380 <div class="bf-stat-value">{criticalCount}</div>
381 <div class="bf-stat-label">Critical risk files</div>
382 </div>
383 <div class="bf-stat-card is-high">
384 <div class="bf-stat-value">{highCount}</div>
385 <div class="bf-stat-label">High risk files</div>
386 </div>
387 <div class="bf-stat-card is-medium">
388 <div class="bf-stat-value">{mediumCount}</div>
389 <div class="bf-stat-label">Medium risk files</div>
390 </div>
391 <div class="bf-stat-card">
392 <div class="bf-stat-value">{report.atRiskFiles.length}</div>
393 <div class="bf-stat-label">Total at-risk files</div>
394 </div>
395 </div>
396
397 {/* File list */}
398 {report.atRiskFiles.length === 0 ? (
399 <div class="bf-empty">
400 <div class="bf-empty-icon">✓</div>
401 <div class="bf-empty-title">No knowledge concentration detected</div>
402 <p class="bf-empty-sub">
403 All analyzed files have healthy authorship spread.
404 </p>
405 </div>
406 ) : (
407 <div class="bf-list">
408 {report.atRiskFiles.map((file) => (
409 <div class={`bf-file-card risk-${file.risk}`}>
410 <div class="bf-file-header">
411 <code class="bf-file-path">{file.path}</code>
412 <span class={`bf-risk-badge is-${file.risk}`}>
413 {file.risk}
414 </span>
415 </div>
416 <div class="bf-file-meta">
417 <span>{file.totalCommits} commits</span>
418 <span>Last modified {file.lastModified}</span>
419 </div>
420 <div class="bf-bar-wrap">
421 <span class="bf-bar-label" title={file.primaryAuthor}>
422 {file.primaryAuthor}
423 </span>
424 <div class="bf-bar-track">
425 <div
426 class="bf-bar-fill"
427 style={`width:${file.primaryAuthorPct}%`}
428 />
429 </div>
430 <span class="bf-bar-pct">{file.primaryAuthorPct}%</span>
431 </div>
432 </div>
433 ))}
434 </div>
435 )}
436 </>
437 ) : (
438 <div class="bf-empty">
439 <div class="bf-empty-icon">📊</div>
440 <div class="bf-empty-title">No analysis yet</div>
441 <p class="bf-empty-sub">
442 {isOwner
443 ? "Click Re-analyze to run the bus factor scan on this repository."
444 : "The repository owner hasn't run a bus factor scan yet."}
445 </p>
446 </div>
447 )}
448 </div>
449 </Layout>
450 );
451});
452
453// ─── Route: POST /:owner/:repo/insights/bus-factor/reanalyze ─────────────────
454
455busFactorRoutes.use("/:owner/:repo/insights/bus-factor/reanalyze", requireAuth);
456
457busFactorRoutes.post(
458 "/:owner/:repo/insights/bus-factor/reanalyze",
459 requireRepoAccess("write"),
460 async (c) => {
461 const user = c.get("user")!;
462 const params = c.req.param() as { owner: string; repo: string };
463 const ownerName = params.owner;
464 const repoName = params.repo;
465
466 // Only repo owner may trigger re-analysis
467 const repoRows = await db
468 .select({ id: repositories.id, ownerId: repositories.ownerId })
469 .from(repositories)
470 .innerJoin(users, eq(repositories.ownerId, users.id))
471 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
472 .limit(1);
473
474 if (!repoRows.length) return c.notFound();
475 const repo = repoRows[0];
476
477 if (user.id !== repo.ownerId) {
478 return c.text("Forbidden", 403);
479 }
480
481 // Fire-and-forget background analysis
482 analyzeBusFactor(repo.id, ownerName, repoName).catch(() => {});
483
484 return c.redirect(`/${ownerName}/${repoName}/insights/bus-factor`);
485 }
486);
487
488export default busFactorRoutes;