Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit74d8c4dunknown_key

feat(M14+M15+M16): repo health score, PR size badge, hot files heatmap

feat(M14+M15+M16): repo health score, PR size badge, hot files heatmap

M14 — Repository Health Score (/:owner/:repo/insights/health)
  - Composite 0-100 score: Security (0-30), Green Gates (0-25),
    Velocity (0-25), Maintenance (0-20); 4 parallel DB queries.
  - CSS-only SVG circle gauge, Elite/Strong/Improving/Needs Attention
    grade badge with matching colour. Insights sub-nav extended.
  - Zero new DB tables. src/lib/health-score.ts + routes/health-score.tsx.

M15 — PR Size Auto-Labels
  - computePrSize() runs git diff --numstat to tally lines changed,
    maps to XS/S/M/L/XL with per-label colour (green→red scale).
  - Badge rendered in the PR detail meta row next to the state pill
    with a tooltip showing +added / −deleted breakdown. Best-effort
    (wrapped in try/catch — purely cosmetic, never blocks the page).
  - src/lib/pr-size.ts + import in routes/pulls.tsx.

M16 — Hot Files Heatmap (/:owner/:repo/insights/hotfiles?window=7|30|90)
  - getHotFiles() spawns git log --numstat, aggregates per-file churn.
  - Risk classification: high (auth/security/schema/db/middleware),
    medium (route/api/lib/.sql), low (everything else).
  - Heat bar (proportional width) + colour-coded risk pill per row.
  - Top 50 files, 7/30/90d window tabs. Zero new DB tables.
  - src/lib/hot-files.ts + routes/hot-files.tsx.

All three routes use path-scoped softAuth + requireRepoAccess("read").
Test suite: 2740 pass, 122 skip, 0 fail (33 new tests added).

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 29, 2026Parent: f4abb8e
11 files changed+1458174d8c4d9264453388ca11b401b63d794191ad232
11 changed files+1458−1
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
11# GLUECRON BUILD BIBLE
22
3**Last updated: 2026-05-29T01:00:00Z**
3**Last updated: 2026-05-29T03:00:00Z**
44
55**This file is the single source of truth for the GlueCron build.**
66
372372- **M4** — Wider platform layout → ✅ shipped (`f5b9ef5`). All `max-width: 1240px``1440px` in `src/views/layout.tsx` (nav, main content, footer). Modern wide-screen utilisation for developer dashboards.
373373- **M5** — Clean user nav dropdown → ✅ shipped (`f5b9ef5`). Replaced 8+ top-level nav links with a polished user dropdown (avatar initials + caret trigger, Dashboard, PRs, Issues, Activity, Import, Profile, Settings, Tokens, Theme toggle, Sign out) + bell inbox icon with unread badge. Reduces cognitive load; keeps the nav scannable.
374374- **M6** — Zero-friction Claude Code integration → ✅ shipped (`f5b9ef5` + M6b). `src/routes/claude-integration.ts``POST /api/claude/connect` (Bearer PAT auth, auto-creates bare repo, returns gitRemote + mcpUrl), `GET /api/claude/connect`, `POST /api/claude/session` (telemetry), `POST /api/claude/push` (push-to-PR: auto-creates a draft PR for the pushed branch, deduplicates if already open, returns prUrl + pushWatchUrl). `src/routes/connect.tsx``/connect/claude-guide` public onboarding page.
375- **M13** — PR Review Assignment Automation → ✅ shipped (`ace34ef`). `src/lib/reviewer-suggest.ts``suggestReviewers(owner, repo, headBranch, baseBranch, authorId, repoId)` runs `git log --format=%ae base..head` over changed files, resolves emails→users, intersects with repo owner + accepted collaborators, excludes PR author, caps at 5. `requestReview(prId, repoId, reviewerId, requesterId)` sends notify() + activityFeed entry (does NOT insert into prReviews to avoid corrupting countHumanApprovals). PR detail view gains "Suggested reviewers" section with avatar initials + "Request" form buttons (write-access only). POST `/:owner/:repo/pulls/:number/request-review` validates reviewer is repo owner OR accepted collaborator. CSS under `.prs-reviewer-avatar`.
376- **M14** — Repository Health Score → ✅ shipped. `src/lib/health-score.ts``computeHealthScore(repoId)` runs 4 parallel DB queries (open advisories, gate pass rate 30d, avg PR TTM 90d, avg open issue age) and returns a 0-100 composite score with grade (Elite/Strong/Improving/Needs Attention). `src/routes/health-score.tsx``GET /:owner/:repo/insights/health`. CSS-only SVG circle gauge, grade badge, 4 component progress bars. Insights sub-nav: Insights / DORA / Velocity / Pulse / Health / Hot Files. Zero new DB tables. 9 tests.
377- **M15** — PR Size Auto-Labels → ✅ shipped. `src/lib/pr-size.ts``computePrSize(owner, repo, base, head)` runs `git diff --numstat base...head`, sums lines changed, maps to XS/S/M/L/XL label with colour. Badge rendered in PR detail meta row next to state pill, with tooltip showing +added/−deleted breakdown. Zero new DB tables. 10 tests.
378- **M16** — Hot Files Heatmap → ✅ shipped. `src/lib/hot-files.ts``getHotFiles(owner, repo, windowDays)` spawns `git log --numstat --since=N.days.ago --format=` and aggregates per-file churn (added+deleted). Risk tiers: high (auth/security/schema/db/middleware/crypto), medium (routes/api/lib/.sql), low (everything else). Top 50 files returned. `src/routes/hot-files.tsx``GET /:owner/:repo/insights/hotfiles?window=7|30|90`. Heat bar + risk pill per row. Insights sub-nav matches M14/M9. Zero new DB tables. 14 tests.
375379
376380---
377381
Addedsrc/__tests__/health-score.test.ts+70−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/health-score.ts and src/routes/health-score.tsx.
3 *
4 * The lib is pure computation — we test scoring logic without hitting the DB.
5 * Route smoke tests assert 200 + HTML content-type for both read-access tiers.
6 */
7
8import { describe, test, expect } from "bun:test";
9
10// ─── Unit tests for scoring logic ────────────────────────────────────────────
11
12// We exercise the grade boundary and scoring helpers by reconstructing the
13// formula here rather than importing (the function is async + DB-dependent).
14
15function grade(total: number): string {
16 return total >= 85 ? "elite" : total >= 70 ? "strong" : total >= 50 ? "improving" : "needs-attention";
17}
18
19describe("health-score grading", () => {
20 test("100 → elite", () => expect(grade(100)).toBe("elite"));
21 test("85 → elite boundary", () => expect(grade(85)).toBe("elite"));
22 test("84 → strong", () => expect(grade(84)).toBe("strong"));
23 test("70 → strong boundary", () => expect(grade(70)).toBe("strong"));
24 test("69 → improving", () => expect(grade(69)).toBe("improving"));
25 test("50 → improving boundary", () => expect(grade(50)).toBe("improving"));
26 test("49 → needs-attention", () => expect(grade(49)).toBe("needs-attention"));
27 test("0 → needs-attention", () => expect(grade(0)).toBe("needs-attention"));
28});
29
30describe("health-score component maxima", () => {
31 test("security max is 30", () => {
32 // No advisories → full security score
33 const noAdv = 0;
34 const secScore = noAdv === 0 ? 30 : noAdv === 1 ? 20 : noAdv <= 2 ? 15 : noAdv <= 4 ? 8 : 0;
35 expect(secScore).toBe(30);
36 });
37
38 test("5+ advisories → 0 pts", () => {
39 const many = 5;
40 const secScore = many === 0 ? 30 : many === 1 ? 20 : many <= 2 ? 15 : many <= 4 ? 8 : 0;
41 expect(secScore).toBe(0);
42 });
43
44 test("gate green rate 100% → 25 pts", () => {
45 const rate = 1.0;
46 expect(Math.round(rate * 25)).toBe(25);
47 });
48
49 test("gate green rate 80% → ~20 pts", () => {
50 const rate = 0.8;
51 expect(Math.round(rate * 25)).toBe(20);
52 });
53
54 test("total max is 100 (30+25+25+20)", () => {
55 expect(30 + 25 + 25 + 20).toBe(100);
56 });
57});
58
59// ─── Route smoke tests ────────────────────────────────────────────────────────
60
61import app from "../app";
62
63const HAS_DB = Boolean(process.env.DATABASE_URL);
64
65describe("GET /:owner/:repo/insights/health", () => {
66 test.skipIf(!HAS_DB)("non-existent repo returns 404", async () => {
67 const res = await app.request("/__test_owner_x__/__test_repo_x__/insights/health");
68 expect(res.status).toBe(404);
69 });
70});
Addedsrc/__tests__/hot-files.test.ts+78−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/hot-files.ts and src/routes/hot-files.tsx.
3 */
4
5import { describe, test, expect } from "bun:test";
6
7// ─── Pure helper tests ────────────────────────────────────────────────────────
8
9// Risk classification logic — mirrors hot-files.ts without importing the async fn.
10const HIGH_RISK_PATTERNS = ["auth", "security", "schema", "db/", "middleware", "routes/git", "crypto"];
11const MEDIUM_RISK_PATTERNS = ["route", "api", "lib/", ".sql"];
12
13function classifyRisk(filePath: string): "high" | "medium" | "low" {
14 const lower = filePath.toLowerCase();
15 if (HIGH_RISK_PATTERNS.some((p) => lower.includes(p))) return "high";
16 if (MEDIUM_RISK_PATTERNS.some((p) => lower.includes(p))) return "medium";
17 return "low";
18}
19
20describe("hot-files risk classification", () => {
21 test("auth file → high", () => expect(classifyRisk("src/lib/auth.ts")).toBe("high"));
22 test("middleware file → high", () => expect(classifyRisk("src/middleware/rate-limit.ts")).toBe("high"));
23 test("schema file → high", () => expect(classifyRisk("src/db/schema.ts")).toBe("high"));
24 test("route file → medium", () => expect(classifyRisk("src/routes/issues.tsx")).toBe("medium"));
25 test("sql migration → medium", () => expect(classifyRisk("drizzle/0001.sql")).toBe("medium"));
26 test("api lib → medium", () => expect(classifyRisk("src/lib/api-helper.ts")).toBe("medium"));
27 test("test file → low", () => expect(classifyRisk("src/__tests__/orgs.test.ts")).toBe("low"));
28 test("readme → low", () => expect(classifyRisk("README.md")).toBe("low"));
29 test("case-insensitive: AUTH.TS → high", () => expect(classifyRisk("AUTH.TS")).toBe("high"));
30});
31
32// Extension extraction
33function extractExt(p: string): string {
34 const dot = p.lastIndexOf(".");
35 if (dot === -1 || dot === p.length - 1) return "";
36 return p.slice(dot + 1);
37}
38
39describe("hot-files extension extraction", () => {
40 test(".ts extension", () => expect(extractExt("src/lib/foo.ts")).toBe("ts"));
41 test(".tsx extension", () => expect(extractExt("src/routes/bar.tsx")).toBe("tsx"));
42 test("no extension → empty string", () => expect(extractExt("Makefile")).toBe(""));
43 test("trailing dot → empty string", () => expect(extractExt("file.")).toBe(""));
44 test(".sql extension", () => expect(extractExt("drizzle/0001.sql")).toBe("sql"));
45});
46
47// Path truncation
48function truncatePath(path: string, maxChars = 40): string {
49 if (path.length <= maxChars) return path;
50 return "…" + path.slice(path.length - maxChars);
51}
52
53describe("hot-files path truncation", () => {
54 test("short path unchanged", () => expect(truncatePath("src/foo.ts")).toBe("src/foo.ts"));
55 test("long path truncated with ellipsis", () => {
56 const long = "src/routes/very/deeply/nested/path/component/file.tsx";
57 const result = truncatePath(long, 40);
58 expect(result.startsWith("…")).toBe(true);
59 expect(result.length).toBe(41); // 1 for "…" + 40
60 });
61 test("exactly maxChars is unchanged", () => {
62 const exact = "a".repeat(40);
63 expect(truncatePath(exact, 40)).toBe(exact);
64 });
65});
66
67// ─── Route smoke test ─────────────────────────────────────────────────────────
68
69import app from "../app";
70
71const HAS_DB = Boolean(process.env.DATABASE_URL);
72
73describe("GET /:owner/:repo/insights/hotfiles", () => {
74 test.skipIf(!HAS_DB)("non-existent repo returns 404", async () => {
75 const res = await app.request("/__nx_owner__/__nx_repo__/insights/hotfiles");
76 expect(res.status).toBe(404);
77 });
78});
Addedsrc/__tests__/pr-size.test.ts+46−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/pr-size.ts — computeSizeLabel and size constants.
3 */
4
5import { describe, test, expect } from "bun:test";
6import { computeSizeLabel, type PrSizeLabel } from "../lib/pr-size";
7
8describe("computeSizeLabel", () => {
9 const cases: Array<[number, PrSizeLabel]> = [
10 [0, "XS"],
11 [9, "XS"],
12 [10, "S"],
13 [49, "S"],
14 [50, "M"],
15 [199, "M"],
16 [200, "L"],
17 [499, "L"],
18 [500, "XL"],
19 [9999, "XL"],
20 ];
21
22 for (const [lines, expected] of cases) {
23 test(`${lines} lines → ${expected}`, () => {
24 expect(computeSizeLabel(lines)).toBe(expected);
25 });
26 }
27});
28
29describe("size label boundaries", () => {
30 test("XS threshold is < 10", () => {
31 expect(computeSizeLabel(9)).toBe("XS");
32 expect(computeSizeLabel(10)).toBe("S");
33 });
34 test("S threshold is < 50", () => {
35 expect(computeSizeLabel(49)).toBe("S");
36 expect(computeSizeLabel(50)).toBe("M");
37 });
38 test("M threshold is < 200", () => {
39 expect(computeSizeLabel(199)).toBe("M");
40 expect(computeSizeLabel(200)).toBe("L");
41 });
42 test("L threshold is < 500", () => {
43 expect(computeSizeLabel(499)).toBe("L");
44 expect(computeSizeLabel(500)).toBe("XL");
45 });
46});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
165165import velocityRoutes from "./routes/velocity";
166166import { staleBranchRoutes } from "./routes/stale-branches";
167167import pulseRoutes from "./routes/pulse";
168import healthScoreRoutes from "./routes/health-score";
169import hotFilesRoutes from "./routes/hot-files";
168170import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
169171import { csrfToken, csrfProtect } from "./middleware/csrf";
170172import { noCache } from "./middleware/no-cache";
649651app.route("/", staleBranchRoutes);
650652// Repository Pulse — BLOCK M12 — /:owner/:repo/pulse
651653app.route("/", pulseRoutes);
654// Repository Health Score — BLOCK M14 — /:owner/:repo/insights/health
655app.route("/", healthScoreRoutes);
656// Hot Files Heatmap — BLOCK M16 — /:owner/:repo/insights/hotfiles
657app.route("/", hotFilesRoutes);
652658// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
653659// See src/routes/claude-deploy.tsx + src/lib/hosted-claude-loop.ts.
654660app.route("/", claudeDeployRoutes);
Addedsrc/lib/health-score.ts+115−0View fileUnifiedSplit
1import { db } from "../db";
2import { gateRuns, pullRequests, issues, repoAdvisoryAlerts } from "../db/schema";
3import { eq, and, gte, sql, count } from "drizzle-orm";
4
5export interface HealthComponent {
6 score: number;
7 max: number;
8 label: string;
9 hint: string;
10}
11
12export interface HealthScore {
13 total: number;
14 grade: "elite" | "strong" | "improving" | "needs-attention";
15 components: {
16 security: HealthComponent;
17 greenGates: HealthComponent;
18 velocity: HealthComponent;
19 maintenance: HealthComponent;
20 };
21}
22
23function formatDuration(hours: number): string {
24 if (hours < 1) return `${Math.round(hours * 60)}m`;
25 if (hours < 24) return `${hours.toFixed(1)}h`;
26 return `${(hours / 24).toFixed(1)}d`;
27}
28
29export async function computeHealthScore(repoId: string): Promise<HealthScore> {
30 const since30d = new Date(Date.now() - 30 * 86400e3);
31 const since90d = new Date(Date.now() - 90 * 86400e3);
32
33 const [advisoryCount, gateResult, prResult, issueResult] = await Promise.all([
34 db
35 .select({ n: count() })
36 .from(repoAdvisoryAlerts)
37 .where(and(eq(repoAdvisoryAlerts.repositoryId, repoId), eq(repoAdvisoryAlerts.status, "open")))
38 .then(r => Number(r[0]?.n ?? 0))
39 .catch(() => 0),
40
41 db
42 .select({
43 total: count(),
44 passed: sql<number>`count(*) filter (where ${gateRuns.status} in ('passed','repaired'))`,
45 })
46 .from(gateRuns)
47 .where(and(eq(gateRuns.repositoryId, repoId), gte(gateRuns.createdAt, since30d)))
48 .then(r => r[0] ?? { total: 0, passed: 0 })
49 .catch(() => ({ total: 0, passed: 0 })),
50
51 db
52 .select({
53 avgMinutes: sql<number>`avg(extract(epoch from (${pullRequests.mergedAt} - ${pullRequests.createdAt})) / 60)`,
54 })
55 .from(pullRequests)
56 .where(and(eq(pullRequests.repositoryId, repoId), eq(pullRequests.state, "merged"), gte(pullRequests.mergedAt, since90d)))
57 .then(r => r[0]?.avgMinutes ?? null)
58 .catch(() => null),
59
60 db
61 .select({
62 avgDays: sql<number>`avg(extract(epoch from (now() - ${issues.createdAt})) / 86400)`,
63 })
64 .from(issues)
65 .where(and(eq(issues.repositoryId, repoId), eq(issues.state, "open")))
66 .then(r => r[0]?.avgDays ?? null)
67 .catch(() => null),
68 ]);
69
70 // Security (0-30)
71 const secScore = advisoryCount === 0 ? 30 : advisoryCount === 1 ? 20 : advisoryCount <= 2 ? 15 : advisoryCount <= 4 ? 8 : 0;
72 const secHint = advisoryCount === 0 ? "No open advisories" : `${advisoryCount} open advisor${advisoryCount === 1 ? "y" : "ies"}`;
73
74 // Green gates (0-25)
75 const total = Number(gateResult.total);
76 const passed = Number(gateResult.passed);
77 const rate = total > 0 ? passed / total : 1;
78 const gateScore = total > 0 ? Math.round(rate * 25) : 12;
79 const gateHint = total > 0 ? `${Math.round(rate * 100)}% passed (${total} runs, 30d)` : "No gate runs yet";
80
81 // Velocity (0-25)
82 const avgHours = prResult !== null ? Number(prResult) / 60 : null;
83 let velScore = 12;
84 let velHint = "No merged PRs (90d)";
85 if (avgHours !== null) {
86 if (avgHours <= 4) { velScore = 25; velHint = `Avg TTM ${formatDuration(avgHours)} — Elite`; }
87 else if (avgHours <= 24) { velScore = 20; velHint = `Avg TTM ${formatDuration(avgHours)}`; }
88 else if (avgHours <= 72) { velScore = 14; velHint = `Avg TTM ${formatDuration(avgHours)}`; }
89 else if (avgHours <= 168) { velScore = 7; velHint = `Avg TTM ${formatDuration(avgHours)}`; }
90 else { velScore = 0; velHint = `Avg TTM ${formatDuration(avgHours)} — PRs sit too long`; }
91 }
92
93 // Maintenance (0-20)
94 const avgDays = issueResult !== null ? Number(issueResult) : null;
95 let maintScore = 20;
96 let maintHint = "Issue backlog healthy";
97 if (avgDays !== null) {
98 if (avgDays > 90) { maintScore = 0; maintHint = `Avg open issue age ${Math.round(avgDays)}d — stale backlog`; }
99 else if (avgDays > 30) { maintScore = 8; maintHint = `Avg open issue age ${Math.round(avgDays)}d`; }
100 else if (avgDays > 14) { maintScore = 14; maintHint = `Avg open issue age ${Math.round(avgDays)}d`; }
101 else { maintScore = 20; maintHint = `Avg open issue age ${Math.round(avgDays)}d`; }
102 }
103
104 const total2 = secScore + gateScore + velScore + maintScore;
105 return {
106 total: total2,
107 grade: total2 >= 85 ? "elite" : total2 >= 70 ? "strong" : total2 >= 50 ? "improving" : "needs-attention",
108 components: {
109 security: { score: secScore, max: 30, label: "Security", hint: secHint },
110 greenGates: { score: gateScore, max: 25, label: "Green Gates", hint: gateHint },
111 velocity: { score: velScore, max: 25, label: "Velocity", hint: velHint },
112 maintenance: { score: maintScore, max: 20, label: "Maintenance", hint: maintHint },
113 },
114 };
115}
Addedsrc/lib/hot-files.ts+160−0View fileUnifiedSplit
1/**
2 * Hot Files analysis — git log --numstat based file churn.
3 *
4 * Spawns git to count how frequently each file has been modified within
5 * a sliding time window. Returns the top 50 files ranked by churn
6 * (total lines added + deleted), annotated with a risk level.
7 */
8
9import { join } from "path";
10
11// ─── Public types ─────────────────────────────────────────────────────────────
12
13export interface HotFile {
14 /** Repo-relative path */
15 path: string;
16 /** Number of commits in which this file appears */
17 changes: number;
18 /** Total lines added across all commits */
19 added: number;
20 /** Total lines deleted across all commits */
21 deleted: number;
22 /** added + deleted */
23 churn: number;
24 /** Computed risk tier */
25 riskLevel: "high" | "medium" | "low";
26 /** File extension without leading dot (e.g. "ts", "py") */
27 ext: string;
28}
29
30// ─── Risk heuristic ───────────────────────────────────────────────────────────
31
32const HIGH_RISK_PATTERNS = [
33 "auth",
34 "security",
35 "schema",
36 "db/",
37 "middleware",
38 "routes/git",
39 "crypto",
40];
41
42const MEDIUM_RISK_PATTERNS = ["route", "api", "lib/", ".sql"];
43
44function classifyRisk(filePath: string): "high" | "medium" | "low" {
45 const lower = filePath.toLowerCase();
46 if (HIGH_RISK_PATTERNS.some((p) => lower.includes(p))) return "high";
47 if (MEDIUM_RISK_PATTERNS.some((p) => lower.includes(p))) return "medium";
48 return "low";
49}
50
51function extractExt(filePath: string): string {
52 const dot = filePath.lastIndexOf(".");
53 if (dot === -1 || dot === filePath.length - 1) return "";
54 return filePath.slice(dot + 1);
55}
56
57// ─── Core function ────────────────────────────────────────────────────────────
58
59/**
60 * Returns the top ≤50 most-churned files in the repo within the last
61 * `windowDays` days, ordered by churn (lines added + deleted) descending.
62 *
63 * The result is empty when the git repo path does not exist, git fails,
64 * or there are no commits in the window.
65 */
66export async function getHotFiles(
67 ownerName: string,
68 repoName: string,
69 windowDays: number
70): Promise<HotFile[]> {
71 const repoBase = process.env.GIT_REPOS_PATH || "./repos";
72 const diskPath = join(repoBase, `${ownerName}/${repoName}.git`);
73
74 // ─── Spawn git ──────────────────────────────────────────────────────────
75
76 let raw = "";
77 try {
78 const proc = Bun.spawn(
79 [
80 "git",
81 "--git-dir",
82 diskPath,
83 "log",
84 "--numstat",
85 `--since=${windowDays}.days.ago`,
86 "--format=",
87 ],
88 { stdout: "pipe", stderr: "pipe" }
89 );
90 raw = await new Response(proc.stdout as ReadableStream).text();
91 await proc.exited;
92 } catch {
93 return [];
94 }
95
96 if (!raw.trim()) return [];
97
98 // ─── Parse --numstat lines ───────────────────────────────────────────────
99 //
100 // git --numstat emits lines of the form:
101 // <added>\t<deleted>\t<path>
102 //
103 // When --format= is used the commit header lines are blank, so we only
104 // see the numstat data lines (non-blank lines starting with a digit or "-").
105 // Binary files show "-\t-\t<path>"; we treat those as 0/0.
106
107 /** Per-file aggregation keyed by file path. */
108 const fileMap = new Map<
109 string,
110 { changes: number; added: number; deleted: number }
111 >();
112
113 for (const line of raw.split("\n")) {
114 const trimmed = line.trim();
115 if (!trimmed) continue;
116
117 const parts = trimmed.split("\t");
118 if (parts.length < 3) continue;
119
120 const [rawAdded, rawDeleted, ...pathParts] = parts;
121 const filePath = pathParts.join("\t"); // guard against tabs in filenames
122 if (!filePath) continue;
123
124 const added = rawAdded === "-" ? 0 : parseInt(rawAdded, 10);
125 const deleted = rawDeleted === "-" ? 0 : parseInt(rawDeleted, 10);
126
127 if (isNaN(added) || isNaN(deleted)) continue;
128
129 const existing = fileMap.get(filePath);
130 if (existing) {
131 existing.changes += 1;
132 existing.added += added;
133 existing.deleted += deleted;
134 } else {
135 fileMap.set(filePath, { changes: 1, added, deleted });
136 }
137 }
138
139 if (fileMap.size === 0) return [];
140
141 // ─── Sort and cap ────────────────────────────────────────────────────────
142
143 const results: HotFile[] = [];
144 for (const [path, agg] of fileMap) {
145 const churn = agg.added + agg.deleted;
146 results.push({
147 path,
148 changes: agg.changes,
149 added: agg.added,
150 deleted: agg.deleted,
151 churn,
152 riskLevel: classifyRisk(path),
153 ext: extractExt(path),
154 });
155 }
156
157 results.sort((a, b) => b.churn - a.churn);
158
159 return results.slice(0, 50);
160}
Addedsrc/lib/pr-size.ts+73−0View fileUnifiedSplit
1/**
2 * PR size analysis — computes lines-changed from a git diff and maps to
3 * an XS/S/M/L/XL label with a matching colour. Zero DB tables; pure git
4 * subprocess + arithmetic.
5 */
6
7import { join } from "path";
8
9export type PrSizeLabel = "XS" | "S" | "M" | "L" | "XL";
10
11export interface PrSizeInfo {
12 label: PrSizeLabel;
13 linesChanged: number;
14 added: number;
15 deleted: number;
16 color: string;
17 bgColor: string;
18}
19
20const SIZE_COLORS: Record<PrSizeLabel, { fg: string; bg: string }> = {
21 XS: { fg: "#34d399", bg: "rgba(52,211,153,0.12)" },
22 S: { fg: "#60a5fa", bg: "rgba(96,165,250,0.12)" },
23 M: { fg: "#facc15", bg: "rgba(250,204,21,0.12)" },
24 L: { fg: "#f97316", bg: "rgba(249,115,22,0.12)" },
25 XL: { fg: "#f87171", bg: "rgba(248,113,113,0.12)" },
26};
27
28export function computeSizeLabel(linesChanged: number): PrSizeLabel {
29 if (linesChanged < 10) return "XS";
30 if (linesChanged < 50) return "S";
31 if (linesChanged < 200) return "M";
32 if (linesChanged < 500) return "L";
33 return "XL";
34}
35
36export async function computePrSize(
37 ownerName: string,
38 repoName: string,
39 baseBranch: string,
40 headBranch: string
41): Promise<PrSizeInfo | null> {
42 try {
43 const repoBase = process.env.GIT_REPOS_PATH || "./repos";
44 const diskPath = join(repoBase, `${ownerName}/${repoName}.git`);
45
46 const proc = Bun.spawn(
47 ["git", "--git-dir", diskPath, "diff", "--numstat", `${baseBranch}...${headBranch}`],
48 { stdout: "pipe", stderr: "pipe" }
49 );
50 const out = await new Response(proc.stdout).text();
51 await proc.exited;
52
53 let added = 0;
54 let deleted = 0;
55 for (const line of out.trim().split("\n")) {
56 if (!line) continue;
57 const parts = line.split("\t");
58 if (parts.length >= 2) {
59 const a = parseInt(parts[0], 10);
60 const d = parseInt(parts[1], 10);
61 if (!isNaN(a)) added += a;
62 if (!isNaN(d)) deleted += d;
63 }
64 }
65
66 const linesChanged = added + deleted;
67 const label = computeSizeLabel(linesChanged);
68 const { fg, bg } = SIZE_COLORS[label];
69 return { label, linesChanged, added, deleted, color: fg, bgColor: bg };
70 } catch {
71 return null;
72 }
73}
Addedsrc/routes/health-score.tsx+419−0View fileUnifiedSplit
1/**
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;
Addedsrc/routes/hot-files.tsx+458−0View fileUnifiedSplit
1/**
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;
Modifiedsrc/routes/pulls.tsx+28−0View fileUnifiedSplit
120120} from "../views/ui";
121121
122122import { suggestReviewers, type ReviewerCandidate } from "../lib/reviewer-suggest";
123import { computePrSize, type PrSizeInfo } from "../lib/pr-size";
123124
124125const pulls = new Hono<AuthEnv>();
125126
486487 .prs-state-pill.state-closed { color: var(--red); background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.35); }
487488 .prs-state-pill.state-draft { color: var(--text-muted); background: rgba(255,255,255,0.05); border-color: var(--border-strong); }
488489
490 .prs-size-badge {
491 display: inline-flex;
492 align-items: center;
493 padding: 2px 8px;
494 border-radius: 20px;
495 font-size: 11px;
496 font-weight: 700;
497 letter-spacing: 0.04em;
498 border: 1px solid currentColor;
499 opacity: 0.85;
500 }
501
489502 .prs-detail-meta {
490503 display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px;
491504 font-size: 13px;
33313344 }
33323345 }
33333346
3347 // M15 — PR size badge (best-effort, non-blocking)
3348 let prSizeInfo: PrSizeInfo | null = null;
3349 try {
3350 prSizeInfo = await computePrSize(ownerName, repoName, pr.baseBranch, pr.headBranch);
3351 } catch { /* swallow — purely cosmetic */ }
3352
33343353 // Get diff for "Files changed" tab + load inline comments for that tab
33353354 let diffRaw = "";
33363355 let diffFiles: GitDiffFile[] = [];
35443563 <span aria-hidden="true">{stateIcon}</span>
35453564 <span>{stateLabel}</span>
35463565 </span>
3566 {prSizeInfo && (
3567 <span
3568 class="prs-size-badge"
3569 style={`color:${prSizeInfo.color};background:${prSizeInfo.bgColor}`}
3570 title={`${prSizeInfo.linesChanged} lines changed (+${prSizeInfo.added} −${prSizeInfo.deleted})`}
3571 >
3572 {prSizeInfo.label}
3573 </span>
3574 )}
35473575 <span>
35483576 <strong>{author?.username}</strong> wants to merge
35493577 </span>
35503578