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

hot-files.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.

hot-files.tsxBlame458 lines · 1 contributor
74d8c4dClaude1/**
2 * Hot Files Heatmap.
3 *
4 * Route: GET /:owner/:repo/insights/hotfiles?window=7|30|90
5 *
6 * Shows the most frequently changed files in the last N days, ranked by
7 * churn (lines added + deleted). Helps teams spot complexity hotspots and
8 * high-risk areas of the codebase.
9 */
10
11import { Hono } from "hono";
12import { db } from "../db";
13import { repositories, users } from "../db/schema";
14import { and, eq } from "drizzle-orm";
15import type { AuthEnv } from "../middleware/auth";
16import { softAuth } from "../middleware/auth";
17import { requireRepoAccess } from "../middleware/repo-access";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
20import { getUnreadCount } from "../lib/unread";
21import { getHotFiles } from "../lib/hot-files";
22
23const hotFilesRoutes = new Hono<AuthEnv>();
24
25// ─── CSS ──────────────────────────────────────────────────────────────────────
26
27const styles = `
28 .hf-wrap {
29 max-width: 1080px;
30 margin: 0 auto;
31 padding: var(--space-5) var(--space-4);
32 }
33
34 /* Insights sub-navigation */
35 .hf-subnav {
36 display: flex;
37 gap: 4px;
38 margin-bottom: var(--space-5);
39 border-bottom: 1px solid var(--border);
40 padding-bottom: 0;
41 }
42 .hf-subnav-link {
43 padding: 8px 14px;
44 font-size: 13px;
45 font-weight: 500;
46 color: var(--text-muted);
47 text-decoration: none;
48 border-bottom: 2px solid transparent;
49 margin-bottom: -1px;
50 transition: color 120ms ease, border-color 120ms ease;
51 border-radius: 4px 4px 0 0;
52 }
53 .hf-subnav-link:hover { color: var(--text); }
54 .hf-subnav-link.active {
55 color: var(--accent, #5865f2);
56 border-bottom-color: var(--accent, #5865f2);
57 }
58
59 /* Hero */
60 .hf-hero {
61 position: relative;
62 margin-bottom: var(--space-5);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 14px;
67 overflow: hidden;
68 }
69 .hf-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%);
75 opacity: 0.8;
76 pointer-events: none;
77 }
78 .hf-hero-title {
79 font-size: 22px;
80 font-weight: 700;
81 margin: 0 0 var(--space-2) 0;
82 color: var(--text);
83 }
84 .hf-hero-sub {
85 color: var(--text-muted);
86 font-size: 14px;
87 margin: 0 0 var(--space-4) 0;
88 }
89
90 /* Window selector */
91 .hf-window-bar {
92 display: flex;
93 gap: 6px;
94 align-items: center;
95 flex-wrap: wrap;
96 }
97 .hf-window-label {
98 font-size: 12px;
99 color: var(--text-muted);
100 margin-right: 4px;
101 }
102 .hf-window-btn {
103 display: inline-block;
104 padding: 4px 12px;
105 border-radius: 6px;
106 font-size: 12px;
107 font-weight: 500;
108 text-decoration: none;
109 border: 1px solid var(--border);
110 color: var(--text-muted);
111 background: var(--bg);
112 transition: border-color 120ms ease, color 120ms ease;
113 }
114 .hf-window-btn:hover { color: var(--text); border-color: var(--border-strong, var(--border)); }
115 .hf-window-btn.active {
116 background: var(--accent, #5865f2);
117 border-color: var(--accent, #5865f2);
118 color: #fff;
119 }
120
121 /* Table */
122 .hf-table-wrap {
123 background: var(--bg-elevated);
124 border: 1px solid var(--border);
125 border-radius: 12px;
126 overflow: hidden;
127 margin-bottom: var(--space-5);
128 }
129 .hf-table {
130 width: 100%;
131 border-collapse: collapse;
132 font-size: 13px;
133 }
134 .hf-table th {
135 text-align: left;
136 padding: 10px 16px;
137 font-size: 11px;
138 text-transform: uppercase;
139 letter-spacing: 0.07em;
140 color: var(--text-muted);
141 border-bottom: 1px solid var(--border);
142 white-space: nowrap;
143 }
144 .hf-table td {
145 padding: 10px 16px;
146 border-bottom: 1px solid var(--border);
147 color: var(--text);
148 vertical-align: middle;
149 font-variant-numeric: tabular-nums;
150 }
151 .hf-table tr:last-child td { border-bottom: none; }
152 .hf-table tr:hover td { background: rgba(255,255,255,0.03); }
153 .hf-num { text-align: right; }
154 .hf-table th.hf-num { text-align: right; }
155
156 /* File path cell */
157 .hf-path {
158 font-family: var(--font-mono, monospace);
159 font-size: 12px;
160 color: var(--text);
161 word-break: break-all;
162 }
163 .hf-ext-badge {
164 display: inline-block;
165 padding: 1px 6px;
166 border-radius: 4px;
167 font-size: 10px;
168 font-weight: 600;
169 text-transform: uppercase;
170 background: rgba(255,255,255,0.07);
171 color: var(--text-muted);
172 margin-right: 8px;
173 vertical-align: middle;
174 flex-shrink: 0;
175 }
176 .hf-path-cell {
177 display: flex;
178 align-items: center;
179 gap: 4px;
180 }
181
182 /* Heat bar */
183 .hf-bar-wrap {
184 display: flex;
185 align-items: center;
186 gap: 8px;
187 min-width: 120px;
188 }
189 .hf-bar-track {
190 flex: 1;
191 height: 6px;
192 border-radius: 3px;
193 background: rgba(255,255,255,0.07);
194 overflow: hidden;
195 min-width: 60px;
196 }
197 .hf-bar-fill {
198 height: 100%;
199 border-radius: 3px;
200 background: linear-gradient(90deg, #fb923c, #f87171);
201 transition: width 300ms ease;
202 }
203 .hf-bar-value {
204 font-size: 12px;
205 font-variant-numeric: tabular-nums;
206 color: var(--text-muted);
207 white-space: nowrap;
208 min-width: 48px;
209 text-align: right;
210 }
211
212 /* Risk badges */
213 .hf-risk {
214 display: inline-block;
215 padding: 2px 8px;
216 border-radius: 9999px;
217 font-size: 11px;
218 font-weight: 600;
219 text-transform: uppercase;
220 letter-spacing: 0.04em;
221 }
222 .hf-risk-high { background: rgba(248,113,113,0.18); color: #f87171; }
223 .hf-risk-medium { background: rgba(251,191, 36,0.18); color: #fbbf24; }
224 .hf-risk-low { background: rgba( 52,211,153,0.18); color: #34d399; }
225
226 /* Empty state */
227 .hf-empty {
228 text-align: center;
229 padding: var(--space-6) var(--space-4);
230 border: 1px dashed var(--border);
231 border-radius: 12px;
232 color: var(--text-muted);
233 margin-bottom: var(--space-5);
234 }
235 .hf-empty strong {
236 display: block;
237 font-size: 15px;
238 color: var(--text);
239 margin-bottom: 6px;
240 }
241 .hf-empty span { font-size: 13px; }
242`;
243
244// ─── Helpers ──────────────────────────────────────────────────────────────────
245
246/** Truncate a file path from the left, keeping the last `maxChars` chars. */
247function truncatePath(path: string, maxChars = 40): string {
248 if (path.length <= maxChars) return path;
249 return "…" + path.slice(path.length - maxChars);
250}
251
252// ─── Route ────────────────────────────────────────────────────────────────────
253
254hotFilesRoutes.use("/:owner/:repo/insights/hotfiles", softAuth);
255
256hotFilesRoutes.get(
257 "/:owner/:repo/insights/hotfiles",
258 requireRepoAccess("read"),
259 async (c) => {
260 const { owner, repo } = c.req.param();
261 const user = c.get("user") ?? null;
262
263 // Parse window
264 const windowParam = c.req.query("window");
265 const windowDays =
266 windowParam === "7" ? 7 : windowParam === "90" ? 90 : 30;
267
268 // ─── Resolve owner + repo from DB ────────────────────────────────────
269 // requireRepoAccess already looked up and stashed the repo; mirror the
270 // velocity.tsx pattern and read it from context. Fall back to an
271 // explicit lookup so the handler is safe even without the middleware.
272
273 const repository = (
274 c.get("repository" as never) as
275 | { id: string; name: string; isPrivate: boolean }
276 | undefined
277 ) ?? null;
278
279 if (!repository) {
280 // Explicit fallback: owner → user row → repo row.
281 const ownerRow = await db
282 .select({ id: users.id })
283 .from(users)
284 .where(eq(users.username, owner))
285 .limit(1)
286 .then((rows) => rows[0] ?? null);
287
288 if (!ownerRow) return c.html("Repository not found", 404);
289
290 const repoRow = await db
291 .select({ id: repositories.id, name: repositories.name })
292 .from(repositories)
293 .where(
294 and(eq(repositories.ownerId, ownerRow.id), eq(repositories.name, repo))
295 )
296 .limit(1)
297 .then((rows) => rows[0] ?? null);
298
299 if (!repoRow) return c.html("Repository not found", 404);
300 }
301
302 // ─── Compute hot files ────────────────────────────────────────────────
303
304 const hotFiles = await getHotFiles(owner, repo, windowDays);
305
306 const maxChurn = hotFiles.length > 0 ? hotFiles[0].churn : 1;
307
308 // Unread notification badge
309 const unreadCount = user ? await getUnreadCount(user.id) : 0;
310
311 const baseUrl = `/${owner}/${repo}/insights/hotfiles`;
312
313 // ─── Render ───────────────────────────────────────────────────────────
314
315 return c.html(
316 <Layout
317 title={`Hot Files — ${owner}/${repo}`}
318 user={user}
319 notificationCount={unreadCount}
320 >
321 <style dangerouslySetInnerHTML={{ __html: styles }} />
322 <div class="hf-wrap">
323 <RepoHeader owner={owner} repo={repo} />
324 <RepoNav owner={owner} repo={repo} active="insights" />
325
326 {/* Insights sub-nav */}
327 <div class="hf-subnav">
328 <a href={`/${owner}/${repo}/insights`} class="hf-subnav-link">
329 Insights
330 </a>
331 <a href={`/${owner}/${repo}/insights/dora`} class="hf-subnav-link">
332 DORA
333 </a>
334 <a
335 href={`/${owner}/${repo}/insights/velocity`}
336 class="hf-subnav-link"
337 >
338 Velocity
339 </a>
340 <a href={`/${owner}/${repo}/pulse`} class="hf-subnav-link">
341 Pulse
342 </a>
343 <a href={`/${owner}/${repo}/insights/health`} class="hf-subnav-link">
344 Health
345 </a>
346 <a
347 href={`/${owner}/${repo}/insights/hotfiles`}
348 class="hf-subnav-link active"
349 >
350 Hot Files
351 </a>
352 </div>
353
354 {/* Hero */}
355 <div class="hf-hero">
356 <h1 class="hf-hero-title">Hot Files Heatmap</h1>
357 <p class="hf-hero-sub">
358 Files with the highest churn in {owner}/{repo} — ranked by lines
359 added and deleted. High-churn files are often complexity
360 hotspots.
361 </p>
362
363 {/* Window tabs */}
364 <div class="hf-window-bar">
365 <span class="hf-window-label">Time window:</span>
366 {([7, 30, 90] as const).map((w) => (
367 <a
368 href={`${baseUrl}?window=${w}`}
369 class={`hf-window-btn${windowDays === w ? " active" : ""}`}
370 >
371 {w}d
372 </a>
373 ))}
374 </div>
375 </div>
376
377 {/* Content */}
378 {hotFiles.length === 0 ? (
379 <div class="hf-empty">
380 <strong>No file changes in the last {windowDays} days</strong>
381 <span>
382 Push some commits, then come back to see which files are
383 heating up.
384 </span>
385 </div>
386 ) : (
387 <div class="hf-table-wrap">
388 <table class="hf-table">
389 <thead>
390 <tr>
391 <th>File</th>
392 <th class="hf-num">Changes</th>
393 <th>Churn (lines)</th>
394 <th>Risk</th>
395 </tr>
396 </thead>
397 <tbody>
398 {hotFiles.map((file) => {
399 const barPct =
400 maxChurn > 0
401 ? Math.round((file.churn / maxChurn) * 100)
402 : 0;
403 const displayPath = truncatePath(file.path, 40);
404 return (
405 <tr key={file.path}>
406 {/* File path */}
407 <td>
408 <div class="hf-path-cell">
409 {file.ext && (
410 <span class="hf-ext-badge">{file.ext}</span>
411 )}
412 <span
413 class="hf-path"
414 title={file.path}
415 >
416 {displayPath}
417 </span>
418 </div>
419 </td>
420
421 {/* Commit count */}
422 <td class="hf-num">{file.changes}</td>
423
424 {/* Churn bar */}
425 <td>
426 <div class="hf-bar-wrap">
427 <div class="hf-bar-track">
428 <div
429 class="hf-bar-fill"
430 style={`width:${barPct}%`}
431 />
432 </div>
433 <span class="hf-bar-value">
434 +{file.added} / -{file.deleted}
435 </span>
436 </div>
437 </td>
438
439 {/* Risk badge */}
440 <td>
441 <span class={`hf-risk hf-risk-${file.riskLevel}`}>
442 {file.riskLevel}
443 </span>
444 </td>
445 </tr>
446 );
447 })}
448 </tbody>
449 </table>
450 </div>
451 )}
452 </div>
453 </Layout>
454 );
455 }
456);
457
458export default hotFilesRoutes;