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

feat(BLOCK-J): J30 repository language breakdown

feat(BLOCK-J): J30 repository language breakdown

Adds a GitHub-parity `/:owner/:repo/languages` page: stacked percentage
bar, colour legend, per-language table of files + bytes + share. Pure
`src/lib/language-stats.ts` maps ~80 extensions + a filename map
(Dockerfile / Makefile / Gemfile / CMakeLists.txt / …) to languages,
ignores vendored prefixes + lockfiles by default, caps per-file sizes,
and folds small languages into an "Other" bucket. `listTreeRecursive`
added to `src/git/repository.ts` (`git ls-tree -r -l -z`, null-
delimited, skips symlinks + submodules, cached, degrades to `[]`).

Linked from the Insights page header alongside Response time + Lead
time + Pulse. softAuth; private repos 404 for non-owner viewers; git
failures degrade to an empty-state report — never 500.

40 new tests (1386 passing total).

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 8c5346c
7 files changed+1243107efa08332cc1742afc08581d841648ad5558c11
7 changed files+1243−1
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
151151| Branch staleness / age report | ✅ | J27 — `GET /:owner/:repo/branches/age[?threshold=0\|30\|60\|90\|180][&sort=age-desc\|age-asc\|name\|ahead-desc\|behind-desc]` walks every branch, fetches the tip commit + ahead/behind counts vs the default branch via `aheadBehind(base, head)` (new `git rev-list --left-right --count` helper in `src/git/repository.ts`), and renders KPI cards (total / non-default / merged / unmerged / median age / oldest), a four-bucket distribution (Fresh <30d, Aging 30–59d, Stale 60–89d, Abandoned ≥90d or missing tip), and a branch table with ahead/behind/last-commit/status columns. Pure `parseThreshold` / `parseSort` / `computeDaysOld` / `classifyBranchAge` / `computeBranchRow` (merged = !default && ahead=0) / `bucketBranches` / `filterByThreshold` / `summariseBranches` (avg + median, excludes default) / `sortBranchRows` (non-mutating, null daysOld sinks) / `buildBranchReport` / `categoryLabel` / `thresholdLabel` / `sortLabel` in `src/lib/branch-age.ts`. softAuth; private repos 404 for non-owner viewers; git failures degrade to empty report — never 500. Linked from repo settings. |
152152| Issue duplicate suggestions | ✅ | J28 — `GET /:owner/:repo/issues/similar.json?q=<title>[&limit][&state=open\|closed]` returns ranked matches as JSON for new-issue-form inline suggestions. `GET /:owner/:repo/issues/:n/similar` is a standalone HTML page ranking related issues against the target title. Pure token-Jaccard ranker in `src/lib/issue-similarity.ts`: `tokeniseTitle` lowercases + strips non-`\p{L}\p{N}_-` + drops stopwords + drops tokens <2 chars; `jaccard` is Unicode-safe `|A∩B| / |A∪B|`; `rankCandidates` sorts score-desc with createdAt-desc + number-desc tie-breaks, honours `minScore` (default 0.15) / `limit` (default 5) / `excludeId` / `excludeNumber` / `state`. `formatSimilarityPercent` clamps to [0,1] and emits `"47%"`. Candidates capped at last 500 issues per repo. softAuth; private repos 404 for non-owner viewers. |
153153| PR lead-time metric | ✅ | J29 — `GET /:owner/:repo/insights/lead-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest PR lead times (created→merged), a four-bucket merge-time distribution, and the oldest still-open non-draft PRs. Pure `computeLeadTime` / `computePrStats` (anchors the window on `mergedAt` for merged PRs, `createdAt` otherwise, so a PR merged yesterday with origin 60 days ago still lands in a 30-day window) / `summariseLeadTimes` (inclusive-method percentiles; separate counters for merged / openNonDraft / openDraft / closedUnmerged) / `bucketLeadTimes` / `buildLeadTimeReport` in `src/lib/pr-lead-time.ts`. Reuses `parseWindow` + `formatDuration` + `VALID_WINDOWS` from Block J25's `response-time.ts` to stay DRY. Linked from the Insights page header alongside Response time + Pulse. softAuth; private repos 404 for non-owner viewers. |
154| Repository language breakdown | ✅ | J30 — `GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]` renders a GitHub-parity stacked percentage bar + legend + per-language table (bytes / file count / share) against the repo's default branch (or an explicit `ref`). Pure `detectLanguage` (extension map ~80 entries + filename map for Dockerfile/Makefile/Rakefile/CMakeLists.txt/meson.build/etc., case-insensitive, dotfiles are null, last-extension wins), `isVendoredOrGenerated` (prefixes `node_modules/`, `vendor/`, `dist/`, `build/`, `.next/`, `.nuxt/`, `coverage/`, `.git/`, `target/`, `bin/` + lockfile basenames `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`, `Cargo.lock`, `composer.lock`, `Gemfile.lock`, `poetry.lock`), `computeLanguageStats` (vendored opt-out, per-file `maxFileSize` cap so a 500MB blob can't bias the pie, `minLanguageBytes` fold, zero/negative sizes skipped), `foldIntoOther` (idempotent rebucketing, Other always sorts last), `formatBytes` (B/KB/MB/GB/TB with 10+ threshold for dropping decimals), `formatPercent` (clamp [0,100], NaN → "0%"), `buildLanguageReport` one-shot, plus `LANGUAGE_COLORS` GitHub-ish palette in `src/lib/language-stats.ts`. New `listTreeRecursive(owner, name, ref)` helper in `src/git/repository.ts` uses `git ls-tree -r -l -z` (null-delimited for safety), skips trees/submodules and mode `120000` symlinks so their target-length doesn't pollute sizes, cached under `${owner}/${name}:tree-recursive:${ref}`, returns `[]` on git failure. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers. |
154155| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
155156| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
156157| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
549550- `src/routes/issue-similarity.tsx` (Block J28) — serves `GET /:owner/:repo/issues/similar.json` (JSON suggestions, up to `MAX_RESULT_LIMIT=20`) and `GET /:owner/:repo/issues/:number/similar` (HTML "related issues" page). Fetches up to `CANDIDATE_LIMIT=500` issues ordered by createdAt-desc. softAuth; private repos 404 for non-owner viewers; DB errors → empty candidates → empty matches. Must be mounted before `issueRoutes` so `/issues/similar.json` doesn't get eaten by `/issues/:number`.
550551- `src/lib/pr-lead-time.ts` (Block J29) — pure PR lead-time rollup. Re-exports `DEFAULT_WINDOW_DAYS`, `VALID_WINDOWS`, `parseWindow`, `formatDuration` from Block J25. `computeLeadTime({createdAt, mergedAt})` returns ms or null (null when not merged / unparseable; clamps negatives to 0). `computePrStats(prs, windowDays, now)` uses `mergedAt` as the window anchor for merged PRs so PRs with ancient `createdAt` but recent merges still appear in recent-window reports; populates `leadMs` + `inFlightMs` (only for open non-merged). `summariseLeadTimes` computes p50 / mean / p90 / fastest / slowest via inclusive-method interpolation + separate counters for `merged / openNonDraft / openDraft / closedUnmerged` so drafts don't pollute open-count KPIs. `bucketLeadTimes` uses ≤1h / ≤1d / ≤1w / >1w. `buildLeadTimeReport` one-shot, `oldestOpenIds` sorted oldest-first and excludes drafts. `__internal` re-exports.
551552- `src/routes/pr-lead-time.tsx` (Block J29) — serves `GET /:owner/:repo/insights/lead-time[?window=…]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 2000 PRs in a single Drizzle query, runs the pure report, renders nine KPI cards (total / merged / open-non-draft / drafts / median / mean / p90 / fastest / slowest), four bucket cards, and the top 25 oldest open PRs with an "in-flight" duration. DB failure → empty report → still 200. Linked from the Insights page header.
553- `src/lib/language-stats.ts` (Block J30) — pure language-breakdown helpers. `EXTENSION_MAP` (~80 entries) maps extensions (ts/tsx/js/mjs/py/rb/go/rs/java/kt/swift/c/cpp/cs/php/scala/clj/ex/hs/lua/ml/dart/sh/ps1/sql/html/css/scss/vue/svelte/astro/md/mdx/yaml/json/toml/proto/graphql/zig/nim/…) to language names. `FILENAME_MAP` handles extensionless filenames (`dockerfile`, `makefile`, `gnumakefile`, `rakefile`, `gemfile`, `jenkinsfile`, `cmakelists.txt`, `meson.build`, `build.gradle`, `build.gradle.kts`, `pom.xml`, `package.json`, `tsconfig.json`). `LANGUAGE_COLORS` is a GitHub-ish palette; unmapped languages render with `DEFAULT_LANGUAGE_COLOR='#888888'`. `VENDORED_PREFIXES = [node_modules/, vendor/, dist/, build/, .next/, .nuxt/, coverage/, .git/, target/, bin/]` and `GENERATED_SUFFIXES` covers the full set of common lockfiles. `detectLanguage(path)` tries filename first then extension (case-insensitive; dotfiles return null since `.env`/`.gitignore` have no extension in the real-extension sense). `isVendoredOrGenerated(path)` matches both top-level and nested prefixes + lockfile basenames. `computeLanguageStats(entries, {ignoreVendored=true, maxFileSize, minLanguageBytes})` returns `{totalBytes, totalFiles, countedFiles, buckets: Array<{language, bytes, fileCount, percent, color}>, primary}` sorted bytes-desc with language-name alphabetical tie-break and "Other" always last. `foldIntoOther(report, thresholdPercent)` is idempotent + never mutates. `formatBytes` uses B/KB/MB/GB/TB with the 10+ threshold for dropping decimal places. `formatPercent(p, digits=1)` clamps to [0,100] and returns "0%" for NaN. `buildLanguageReport({entries, foldUnderPercent, …})` is the one-shot. `__internal` re-exports.
554- `src/routes/languages.tsx` (Block J30) — serves `GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]`. softAuth; private repos 404 for non-owner viewers. Resolves the analyzed ref via `getDefaultBranch` unless `?ref=` is supplied (validated against whitespace / `..` / git-refspec metacharacters). Runs `listTreeRecursive``buildLanguageReport`, renders a stacked percentage bar + colour legend + per-language table (files / size / share). Git failures or missing default branch degrade to an empty-state panel — never 500. Include-vendored checkbox + "Fold <N%" selector auto-submit. Linked from the Insights page header.
555- `src/git/repository.ts` (Block J30 additions) — `listTreeRecursive(owner, name, ref)` runs `git ls-tree -r -l -z <ref>` (null-delimited so paths containing newlines don't break parsing), skips non-blobs (trees, submodules), skips mode `120000` symlinks so their target-length isn't counted as file size, returns `Array<{path, size}>` (`[]` on any git failure). Cached under `${owner}/${name}:tree-recursive:${ref}`.
552556
553557### 4.7 Views (locked contracts)
554558- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
583587```bash
584588bun install
585589bun dev # hot reload
586bun test # 1346 tests currently pass
590bun test # 1386 tests currently pass
587591bun run db:migrate
588592```
589593
Addedsrc/__tests__/language-stats.test.ts+460−0View fileUnifiedSplit
1/**
2 * Block J30 — Repository language breakdown. Pure helper tests.
3 *
4 * Exercises:
5 * - detectLanguage (extension map, filename map, case-insensitivity, null paths)
6 * - isVendoredOrGenerated (prefixes, nested prefixes, lockfile basenames)
7 * - computeLanguageStats (vendoring, size caps, min-bytes fold, unclassified)
8 * - foldIntoOther (idempotency, threshold, sort stability)
9 * - formatBytes / formatPercent (boundaries + NaN / negative handling)
10 * - buildLanguageReport (one-shot equivalence)
11 * - Route smoke test
12 * - __internal parity
13 */
14
15import { describe, it, expect } from "bun:test";
16import {
17 EXTENSION_MAP,
18 FILENAME_MAP,
19 LANGUAGE_COLORS,
20 DEFAULT_LANGUAGE_COLOR,
21 VENDORED_PREFIXES,
22 GENERATED_SUFFIXES,
23 detectLanguage,
24 isVendoredOrGenerated,
25 computeLanguageStats,
26 foldIntoOther,
27 formatBytes,
28 formatPercent,
29 buildLanguageReport,
30 __internal,
31 type LanguageFileEntry,
32 type LanguageReport,
33} from "../lib/language-stats";
34
35describe("language-stats — detectLanguage", () => {
36 it("resolves common extensions", () => {
37 expect(detectLanguage("src/app.ts")).toBe("TypeScript");
38 expect(detectLanguage("src/app.tsx")).toBe("TypeScript");
39 expect(detectLanguage("lib/foo.js")).toBe("JavaScript");
40 expect(detectLanguage("lib/foo.mjs")).toBe("JavaScript");
41 expect(detectLanguage("lib/foo.cjs")).toBe("JavaScript");
42 expect(detectLanguage("scripts/build.py")).toBe("Python");
43 expect(detectLanguage("pkg/main.go")).toBe("Go");
44 expect(detectLanguage("src/lib.rs")).toBe("Rust");
45 expect(detectLanguage("App.java")).toBe("Java");
46 expect(detectLanguage("App.kt")).toBe("Kotlin");
47 expect(detectLanguage("README.md")).toBe("Markdown");
48 expect(detectLanguage("docs/index.mdx")).toBe("MDX");
49 expect(detectLanguage("config.yaml")).toBe("YAML");
50 expect(detectLanguage("config.yml")).toBe("YAML");
51 expect(detectLanguage("package.json")).toBe("JSON");
52 expect(detectLanguage("query.gql")).toBe("GraphQL");
53 expect(detectLanguage("schema.graphql")).toBe("GraphQL");
54 expect(detectLanguage("style.scss")).toBe("SCSS");
55 expect(detectLanguage("index.html")).toBe("HTML");
56 expect(detectLanguage("hello.zig")).toBe("Zig");
57 expect(detectLanguage("hello.dart")).toBe("Dart");
58 });
59
60 it("resolves filename-only languages", () => {
61 expect(detectLanguage("Dockerfile")).toBe("Dockerfile");
62 expect(detectLanguage("docker/Dockerfile")).toBe("Dockerfile");
63 expect(detectLanguage("Makefile")).toBe("Makefile");
64 expect(detectLanguage("GNUmakefile")).toBe("Makefile");
65 expect(detectLanguage("Rakefile")).toBe("Ruby");
66 expect(detectLanguage("Gemfile")).toBe("Ruby");
67 expect(detectLanguage("Jenkinsfile")).toBe("Groovy");
68 expect(detectLanguage("CMakeLists.txt")).toBe("CMake");
69 expect(detectLanguage("meson.build")).toBe("Meson");
70 });
71
72 it("is case-insensitive for both maps", () => {
73 expect(detectLanguage("src/FOO.TS")).toBe("TypeScript");
74 expect(detectLanguage("DOCKERFILE")).toBe("Dockerfile");
75 expect(detectLanguage("makefile")).toBe("Makefile");
76 });
77
78 it("returns null for unrecognised files", () => {
79 expect(detectLanguage("unknown.xyz")).toBeNull();
80 expect(detectLanguage("no-extension")).toBeNull();
81 expect(detectLanguage("")).toBeNull();
82 });
83
84 it("guards non-string input", () => {
85 // @ts-expect-error — intentionally wrong
86 expect(detectLanguage(null)).toBeNull();
87 // @ts-expect-error
88 expect(detectLanguage(undefined)).toBeNull();
89 // @ts-expect-error
90 expect(detectLanguage(42)).toBeNull();
91 });
92
93 it("treats dotfiles (leading-dot basenames) as extensionless when not in filename map", () => {
94 // `.env`, `.gitignore` etc. have no extension by our rule.
95 expect(detectLanguage(".gitignore")).toBeNull();
96 expect(detectLanguage(".env")).toBeNull();
97 });
98
99 it("picks the last extension only", () => {
100 expect(detectLanguage("archive.tar.gz")).toBeNull();
101 expect(detectLanguage("index.d.ts")).toBe("TypeScript");
102 expect(detectLanguage("component.test.tsx")).toBe("TypeScript");
103 });
104});
105
106describe("language-stats — isVendoredOrGenerated", () => {
107 it("flags top-level vendored prefixes", () => {
108 for (const pref of VENDORED_PREFIXES) {
109 expect(isVendoredOrGenerated(pref + "any/file.ts")).toBe(true);
110 }
111 });
112 it("flags nested vendored prefixes", () => {
113 expect(isVendoredOrGenerated("pkg/node_modules/foo/bar.js")).toBe(true);
114 expect(isVendoredOrGenerated("apps/web/dist/main.js")).toBe(true);
115 });
116 it("flags lockfile basenames anywhere", () => {
117 for (const sfx of GENERATED_SUFFIXES) {
118 expect(isVendoredOrGenerated(sfx)).toBe(true);
119 expect(isVendoredOrGenerated(`some/nested/${sfx}`)).toBe(true);
120 }
121 });
122 it("does not flag ordinary source files", () => {
123 expect(isVendoredOrGenerated("src/app.ts")).toBe(false);
124 expect(isVendoredOrGenerated("lib/package.json")).toBe(false);
125 expect(isVendoredOrGenerated("README.md")).toBe(false);
126 });
127 it("tolerates a leading slash", () => {
128 expect(isVendoredOrGenerated("/node_modules/foo.js")).toBe(true);
129 });
130 it("guards non-string input", () => {
131 // @ts-expect-error
132 expect(isVendoredOrGenerated(null)).toBe(false);
133 // @ts-expect-error
134 expect(isVendoredOrGenerated(undefined)).toBe(false);
135 });
136});
137
138describe("language-stats — computeLanguageStats (basic rollup)", () => {
139 const entries: LanguageFileEntry[] = [
140 { path: "src/a.ts", size: 100 },
141 { path: "src/b.ts", size: 300 },
142 { path: "src/c.py", size: 200 },
143 { path: "README.md", size: 50 },
144 { path: "unknown.xyz", size: 9999 }, // dropped
145 ];
146
147 it("aggregates bytes per language and computes percentages", () => {
148 const r = computeLanguageStats(entries);
149 expect(r.totalFiles).toBe(5);
150 expect(r.countedFiles).toBe(4); // unknown dropped
151 expect(r.totalBytes).toBe(100 + 300 + 200 + 50);
152
153 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
154 const py = r.buckets.find((b) => b.language === "Python")!;
155 const md = r.buckets.find((b) => b.language === "Markdown")!;
156 expect(ts.bytes).toBe(400);
157 expect(ts.fileCount).toBe(2);
158 expect(py.bytes).toBe(200);
159 expect(md.bytes).toBe(50);
160
161 const totalPct = r.buckets.reduce((acc, b) => acc + b.percent, 0);
162 expect(Math.round(totalPct)).toBe(100);
163
164 // Sorted by bytes desc — TS comes first.
165 expect(r.buckets[0]!.language).toBe("TypeScript");
166 expect(r.primary?.language).toBe("TypeScript");
167 });
168
169 it("assigns colours from the map", () => {
170 const r = computeLanguageStats(entries);
171 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
172 expect(ts.color).toBe(LANGUAGE_COLORS.get("TypeScript"));
173 });
174
175 it("falls back to the default colour for unmapped languages", () => {
176 const out = computeLanguageStats([
177 { path: "hi.pl", size: 100 }, // Perl — not in LANGUAGE_COLORS
178 ]);
179 const perl = out.buckets.find((b) => b.language === "Perl")!;
180 expect(perl.color).toBe(DEFAULT_LANGUAGE_COLOR);
181 });
182
183 it("ignores vendored paths by default", () => {
184 const r = computeLanguageStats([
185 { path: "src/a.ts", size: 100 },
186 { path: "node_modules/foo/bar.js", size: 10_000 },
187 { path: "dist/main.js", size: 20_000 },
188 { path: "package-lock.json", size: 5_000 },
189 ]);
190 expect(r.totalBytes).toBe(100);
191 expect(r.buckets).toHaveLength(1);
192 expect(r.buckets[0]!.language).toBe("TypeScript");
193 });
194
195 it("counts vendored paths when ignoreVendored=false", () => {
196 const r = computeLanguageStats(
197 [
198 { path: "src/a.ts", size: 100 },
199 { path: "node_modules/foo/bar.js", size: 10_000 },
200 ],
201 { ignoreVendored: false }
202 );
203 expect(r.totalBytes).toBe(10_100);
204 const js = r.buckets.find((b) => b.language === "JavaScript")!;
205 expect(js.bytes).toBe(10_000);
206 });
207
208 it("caps per-file size via maxFileSize", () => {
209 const r = computeLanguageStats(
210 [
211 { path: "src/small.ts", size: 100 },
212 { path: "src/huge.ts", size: 50_000 },
213 ],
214 { maxFileSize: 1_000 }
215 );
216 const ts = r.buckets.find((b) => b.language === "TypeScript")!;
217 // small=100 + huge capped at 1_000 = 1_100
218 expect(ts.bytes).toBe(1_100);
219 });
220
221 it("skips zero-size files and negative sizes", () => {
222 const r = computeLanguageStats([
223 { path: "src/a.ts", size: 0 },
224 { path: "src/b.ts", size: -5 },
225 { path: "src/c.ts", size: 10 },
226 ]);
227 expect(r.countedFiles).toBe(1);
228 expect(r.totalBytes).toBe(10);
229 });
230
231 it("skips entries with non-string path or bogus size", () => {
232 const r = computeLanguageStats([
233 // @ts-expect-error
234 { path: 42, size: 1 },
235 { path: "src/a.ts", size: Number.NaN },
236 { path: "src/b.ts", size: 10 },
237 ]);
238 expect(r.totalBytes).toBe(10);
239 });
240
241 it("folds languages below minLanguageBytes into Other", () => {
242 const r = computeLanguageStats(
243 [
244 { path: "src/big.ts", size: 10_000 },
245 { path: "tiny.py", size: 50 },
246 { path: "small.go", size: 100 },
247 ],
248 { minLanguageBytes: 200 }
249 );
250 const other = r.buckets.find((b) => b.language === "Other")!;
251 expect(other.bytes).toBe(150);
252 expect(other.fileCount).toBe(2);
253 expect(other.color).toBe(LANGUAGE_COLORS.get("Other"));
254 // Other must sort last.
255 expect(r.buckets.at(-1)!.language).toBe("Other");
256 // primary must skip Other.
257 expect(r.primary?.language).toBe("TypeScript");
258 });
259
260 it("returns empty report when nothing classifies", () => {
261 const r = computeLanguageStats([
262 { path: "unknown.xyz", size: 10 },
263 ]);
264 expect(r.totalBytes).toBe(0);
265 expect(r.buckets).toHaveLength(0);
266 expect(r.primary).toBeNull();
267 });
268
269 it("handles an empty input", () => {
270 const r = computeLanguageStats([]);
271 expect(r.totalFiles).toBe(0);
272 expect(r.countedFiles).toBe(0);
273 expect(r.totalBytes).toBe(0);
274 expect(r.buckets).toEqual([]);
275 expect(r.primary).toBeNull();
276 });
277
278 it("breaks bucket ties by language name", () => {
279 const r = computeLanguageStats([
280 { path: "a.py", size: 100 },
281 { path: "a.rb", size: 100 },
282 { path: "a.go", size: 100 },
283 ]);
284 // All equal — alphabetical.
285 expect(r.buckets.map((b) => b.language)).toEqual([
286 "Go",
287 "Python",
288 "Ruby",
289 ]);
290 });
291});
292
293describe("language-stats — foldIntoOther", () => {
294 function mkReport(pairs: Array<[string, number]>): LanguageReport {
295 const total = pairs.reduce((acc, [, n]) => acc + n, 0);
296 return {
297 totalBytes: total,
298 totalFiles: pairs.length,
299 countedFiles: pairs.length,
300 buckets: pairs.map(([language, bytes]) => ({
301 language,
302 bytes,
303 fileCount: 1,
304 percent: total === 0 ? 0 : (bytes / total) * 100,
305 color: LANGUAGE_COLORS.get(language) ?? DEFAULT_LANGUAGE_COLOR,
306 })),
307 primary: null,
308 };
309 }
310
311 it("folds buckets below the threshold into Other", () => {
312 const r = mkReport([
313 ["TypeScript", 900],
314 ["Python", 80],
315 ["Ruby", 20],
316 ]);
317 const out = foldIntoOther(r, 10); // 10%
318 // Only TS survives (Python=8%, Ruby=2%).
319 expect(out.buckets.map((b) => b.language)).toEqual(["TypeScript", "Other"]);
320 const other = out.buckets.find((b) => b.language === "Other")!;
321 expect(other.bytes).toBe(100);
322 expect(other.fileCount).toBe(2);
323 expect(out.primary?.language).toBe("TypeScript");
324 });
325
326 it("is idempotent when called twice", () => {
327 const r = mkReport([
328 ["TypeScript", 900],
329 ["Python", 80],
330 ["Ruby", 20],
331 ]);
332 const once = foldIntoOther(r, 10);
333 const twice = foldIntoOther(once, 10);
334 expect(twice.buckets.map((b) => `${b.language}:${b.bytes}`)).toEqual(
335 once.buckets.map((b) => `${b.language}:${b.bytes}`)
336 );
337 });
338
339 it("threshold <=0 is a no-op", () => {
340 const r = mkReport([
341 ["TypeScript", 900],
342 ["Python", 100],
343 ]);
344 expect(foldIntoOther(r, 0)).toBe(r);
345 expect(foldIntoOther(r, -5)).toBe(r);
346 });
347
348 it("Other always sorts last after folding", () => {
349 const r = mkReport([
350 ["TypeScript", 500],
351 ["Python", 500],
352 ["Ruby", 1],
353 ]);
354 const out = foldIntoOther(r, 1);
355 expect(out.buckets.at(-1)!.language).toBe("Other");
356 });
357});
358
359describe("language-stats — formatBytes", () => {
360 it("formats bytes, KB, MB, GB, TB", () => {
361 expect(formatBytes(0)).toBe("0 B");
362 expect(formatBytes(512)).toBe("512 B");
363 expect(formatBytes(1024)).toBe("1.0 KB");
364 expect(formatBytes(1536)).toBe("1.5 KB");
365 expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
366 expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
367 expect(formatBytes(1024 ** 4)).toBe("1.0 TB");
368 });
369 it("drops decimals past 10 in a unit", () => {
370 expect(formatBytes(10 * 1024)).toBe("10 KB");
371 expect(formatBytes(1024 * 1024 * 15)).toBe("15 MB");
372 });
373 it("handles negative and non-finite input", () => {
374 expect(formatBytes(-1)).toBe("0 B");
375 expect(formatBytes(Number.NaN)).toBe("0 B");
376 expect(formatBytes(Number.POSITIVE_INFINITY)).toBe("0 B");
377 });
378});
379
380describe("language-stats — formatPercent", () => {
381 it("formats to given digits and clamps to [0,100]", () => {
382 expect(formatPercent(50)).toBe("50.0%");
383 expect(formatPercent(50, 0)).toBe("50%");
384 expect(formatPercent(50, 2)).toBe("50.00%");
385 expect(formatPercent(-5)).toBe("0.0%");
386 expect(formatPercent(200)).toBe("100.0%");
387 });
388 it("handles NaN", () => {
389 expect(formatPercent(Number.NaN)).toBe("0%");
390 });
391});
392
393describe("language-stats — buildLanguageReport", () => {
394 const entries: LanguageFileEntry[] = [
395 { path: "src/a.ts", size: 1_000 },
396 { path: "main.py", size: 100 },
397 { path: "tiny.rb", size: 5 },
398 ];
399
400 it("matches computeLanguageStats when foldUnderPercent is 0", () => {
401 const a = computeLanguageStats(entries);
402 const b = buildLanguageReport({ entries });
403 expect(a.buckets.map((x) => x.language)).toEqual(
404 b.buckets.map((x) => x.language)
405 );
406 expect(a.totalBytes).toBe(b.totalBytes);
407 });
408
409 it("applies foldUnderPercent after rollup", () => {
410 const r = buildLanguageReport({ entries, foldUnderPercent: 2 });
411 // Ruby=5B out of 1105 ≈ 0.45% < 2% → folds into Other
412 expect(r.buckets.some((b) => b.language === "Ruby")).toBe(false);
413 expect(r.buckets.some((b) => b.language === "Other")).toBe(true);
414 });
415
416 it("respects ignoreVendored=false", () => {
417 const r = buildLanguageReport({
418 entries: [
419 { path: "src/a.ts", size: 100 },
420 { path: "node_modules/foo/bar.js", size: 10_000 },
421 ],
422 ignoreVendored: false,
423 });
424 expect(r.buckets.some((b) => b.language === "JavaScript")).toBe(true);
425 });
426});
427
428describe("language-stats — routes", () => {
429 it("GET /:o/:r/languages returns 200 or 404 (never 500)", async () => {
430 const { default: app } = await import("../app");
431 const res = await app.request("/alice/repo/languages");
432 expect([200, 404]).toContain(res.status);
433 });
434 it("ignores bogus query params", async () => {
435 const { default: app } = await import("../app");
436 const res = await app.request(
437 "/alice/repo/languages?include_vendored=yes&fold=abc"
438 );
439 expect([200, 404]).toContain(res.status);
440 });
441});
442
443describe("language-stats — __internal parity", () => {
444 it("re-exports every helper", () => {
445 expect(__internal.EXTENSION_MAP).toBe(EXTENSION_MAP);
446 expect(__internal.FILENAME_MAP).toBe(FILENAME_MAP);
447 expect(__internal.LANGUAGE_COLORS).toBe(LANGUAGE_COLORS);
448 expect(__internal.DEFAULT_LANGUAGE_COLOR).toBe(DEFAULT_LANGUAGE_COLOR);
449 expect(__internal.VENDORED_PREFIXES).toBe(VENDORED_PREFIXES);
450 expect(__internal.GENERATED_SUFFIXES).toBe(GENERATED_SUFFIXES);
451 expect(__internal.detectLanguage).toBe(detectLanguage);
452 expect(__internal.isVendoredOrGenerated).toBe(isVendoredOrGenerated);
453 expect(__internal.computeLanguageStats).toBe(computeLanguageStats);
454 expect(__internal.foldIntoOther).toBe(foldIntoOther);
455 expect(__internal.formatBytes).toBe(formatBytes);
456 expect(__internal.formatPercent).toBe(formatPercent);
457 expect(__internal.buildLanguageReport).toBe(buildLanguageReport);
458 expect(typeof __internal.colorFor).toBe("function");
459 });
460});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
8888import branchAgeRoutes from "./routes/branch-age";
8989import issueSimilarityRoutes from "./routes/issue-similarity";
9090import prLeadTimeRoutes from "./routes/pr-lead-time";
91import languageRoutes from "./routes/languages";
9192import webRoutes from "./routes/web";
9293
9394const app = new Hono();
321322// Mounted alongside response-time before insightsRoutes.
322323app.route("/", prLeadTimeRoutes);
323324
325// Repository language breakdown — /:owner/:repo/languages (Block J30)
326app.route("/", languageRoutes);
327
324328// Insights + milestones
325329app.route("/", insightsRoutes);
326330
Modifiedsrc/git/repository.ts+40−0View fileUnifiedSplit
628628 });
629629}
630630
631/**
632 * Recursive flat tree listing at `ref` — returns every blob (file) path +
633 * size in bytes. Useful for language breakdowns and large-file audits.
634 * Skips symlinks and submodules. Returns `[]` on any git failure.
635 */
636export async function listTreeRecursive(
637 owner: string,
638 name: string,
639 ref: string
640): Promise<Array<{ path: string; size: number }>> {
641 return cached(
642 gitCache as any,
643 `${owner}/${name}:tree-recursive:${ref}`,
644 async () => {
645 const path = repoPath(owner, name);
646 const { stdout, exitCode } = await exec(
647 ["git", "ls-tree", "-r", "-l", "-z", ref],
648 { cwd: path }
649 );
650 if (exitCode !== 0) return [];
651 const entries: Array<{ path: string; size: number }> = [];
652 for (const line of stdout.split("\0")) {
653 if (!line) continue;
654 // Format: <mode> SP <type> SP <sha> SP <size> TAB <name>
655 const match = line.match(
656 /^(\d+)\s+(blob|tree|commit)\s+[0-9a-f]+\s+(-|\d+)\t(.+)$/
657 );
658 if (!match) continue;
659 if (match[2] !== "blob") continue; // skip submodules / trees
660 // Mode 120000 is a symlink — skip so size doesn't skew stats.
661 if (match[1] === "120000") continue;
662 const sz = match[3] === "-" ? 0 : Number.parseInt(match[3]!, 10);
663 if (!Number.isFinite(sz) || sz < 0) continue;
664 entries.push({ path: match[4]!, size: sz });
665 }
666 return entries;
667 }
668 );
669}
670
631671/**
632672 * Count commits ahead and behind: commits on `head` not on `base` (ahead) and
633673 * commits on `base` not on `head` (behind). Returns null when either ref is
Addedsrc/lib/language-stats.ts+449−0View fileUnifiedSplit
1/**
2 * Block J30 — Repository language breakdown.
3 *
4 * Pure helpers that map file paths to a language (via extension + a handful
5 * of filename-only special cases like `Dockerfile`, `Makefile`), compute
6 * per-language byte totals + percentages, and can fold low-share languages
7 * into an "Other" bucket.
8 *
9 * We also ship a compact `LANGUAGE_COLORS` map so the route can render a
10 * stacked bar matching common colour conventions (GitHub-ish).
11 *
12 * This deliberately lives outside any linguist-style heuristic soup —
13 * we don't auto-detect by content, we trust extensions + filenames. It's
14 * lightweight and good enough for the breakdown UI.
15 */
16
17/**
18 * Extension → language. Keys are without leading dot, lowercased.
19 * A deliberately opinionated subset — no one-off exotic languages.
20 */
21export const EXTENSION_MAP: ReadonlyMap<string, string> = new Map(
22 Object.entries({
23 ts: "TypeScript",
24 tsx: "TypeScript",
25 js: "JavaScript",
26 jsx: "JavaScript",
27 mjs: "JavaScript",
28 cjs: "JavaScript",
29 py: "Python",
30 rb: "Ruby",
31 go: "Go",
32 rs: "Rust",
33 java: "Java",
34 kt: "Kotlin",
35 kts: "Kotlin",
36 swift: "Swift",
37 c: "C",
38 h: "C",
39 cpp: "C++",
40 cxx: "C++",
41 cc: "C++",
42 hpp: "C++",
43 hh: "C++",
44 cs: "C#",
45 php: "PHP",
46 pl: "Perl",
47 pm: "Perl",
48 r: "R",
49 scala: "Scala",
50 clj: "Clojure",
51 ex: "Elixir",
52 exs: "Elixir",
53 erl: "Erlang",
54 hs: "Haskell",
55 lua: "Lua",
56 ml: "OCaml",
57 mli: "OCaml",
58 dart: "Dart",
59 sh: "Shell",
60 bash: "Shell",
61 zsh: "Shell",
62 fish: "Shell",
63 ps1: "PowerShell",
64 sql: "SQL",
65 html: "HTML",
66 htm: "HTML",
67 css: "CSS",
68 scss: "SCSS",
69 sass: "Sass",
70 less: "Less",
71 vue: "Vue",
72 svelte: "Svelte",
73 astro: "Astro",
74 md: "Markdown",
75 mdx: "MDX",
76 rst: "reStructuredText",
77 tex: "TeX",
78 yaml: "YAML",
79 yml: "YAML",
80 json: "JSON",
81 json5: "JSON",
82 toml: "TOML",
83 xml: "XML",
84 ini: "INI",
85 conf: "INI",
86 proto: "Protocol Buffers",
87 graphql: "GraphQL",
88 gql: "GraphQL",
89 zig: "Zig",
90 nim: "Nim",
91 vim: "Vim script",
92 vb: "Visual Basic",
93 asm: "Assembly",
94 s: "Assembly",
95 dockerfile: "Dockerfile",
96 })
97);
98
99/** Filename → language for files that traditionally have no extension. */
100export const FILENAME_MAP: ReadonlyMap<string, string> = new Map(
101 Object.entries({
102 dockerfile: "Dockerfile",
103 makefile: "Makefile",
104 gnumakefile: "Makefile",
105 rakefile: "Ruby",
106 gemfile: "Ruby",
107 procfile: "Procfile",
108 jenkinsfile: "Groovy",
109 "cmakelists.txt": "CMake",
110 "meson.build": "Meson",
111 "build.gradle": "Groovy",
112 "build.gradle.kts": "Kotlin",
113 "pom.xml": "XML",
114 "package.json": "JSON",
115 "tsconfig.json": "JSON",
116 })
117);
118
119/**
120 * GitHub-ish language colours. Omitted entries render with a neutral grey.
121 * Only widely-used languages get a brand colour here.
122 */
123export const LANGUAGE_COLORS: ReadonlyMap<string, string> = new Map(
124 Object.entries({
125 TypeScript: "#3178c6",
126 JavaScript: "#f1e05a",
127 Python: "#3572A5",
128 Ruby: "#701516",
129 Go: "#00ADD8",
130 Rust: "#dea584",
131 Java: "#b07219",
132 Kotlin: "#A97BFF",
133 Swift: "#F05138",
134 C: "#555555",
135 "C++": "#f34b7d",
136 "C#": "#178600",
137 PHP: "#4F5D95",
138 Scala: "#c22d40",
139 Clojure: "#db5855",
140 Elixir: "#6e4a7e",
141 Haskell: "#5e5086",
142 Lua: "#000080",
143 Shell: "#89e051",
144 PowerShell: "#012456",
145 HTML: "#e34c26",
146 CSS: "#563d7c",
147 SCSS: "#c6538c",
148 Vue: "#41b883",
149 Svelte: "#ff3e00",
150 Astro: "#ff5d01",
151 Markdown: "#083fa1",
152 YAML: "#cb171e",
153 JSON: "#292929",
154 TOML: "#9c4221",
155 GraphQL: "#e10098",
156 Dockerfile: "#384d54",
157 Makefile: "#427819",
158 SQL: "#e38c00",
159 Dart: "#00B4AB",
160 Zig: "#ec915c",
161 Other: "#888888",
162 })
163);
164
165/** Fallback colour when a language has no LANGUAGE_COLORS entry. */
166export const DEFAULT_LANGUAGE_COLOR = "#888888";
167
168/**
169 * Paths we never want in the breakdown: vendored + generated + lock files.
170 * Callers can opt out by passing `ignoreVendored: false`.
171 */
172export const VENDORED_PREFIXES: readonly string[] = [
173 "node_modules/",
174 "vendor/",
175 "dist/",
176 "build/",
177 ".next/",
178 ".nuxt/",
179 "coverage/",
180 ".git/",
181 "target/",
182 "bin/",
183];
184
185export const GENERATED_SUFFIXES: readonly string[] = [
186 "package-lock.json",
187 "yarn.lock",
188 "pnpm-lock.yaml",
189 "bun.lockb",
190 "Cargo.lock",
191 "composer.lock",
192 "Gemfile.lock",
193 "poetry.lock",
194];
195
196export interface LanguageFileEntry {
197 path: string;
198 size: number;
199}
200
201export interface LanguageBucket {
202 language: string;
203 bytes: number;
204 fileCount: number;
205 percent: number;
206 color: string;
207}
208
209export interface LanguageReport {
210 totalBytes: number;
211 totalFiles: number;
212 countedFiles: number;
213 /** Non-"Other" per-language rollup, sorted by bytes desc. */
214 buckets: LanguageBucket[];
215 primary: LanguageBucket | null;
216}
217
218function lastSegment(p: string): string {
219 const i = p.lastIndexOf("/");
220 return i >= 0 ? p.slice(i + 1) : p;
221}
222
223function extensionOf(basename: string): string | null {
224 const i = basename.lastIndexOf(".");
225 if (i <= 0) return null;
226 return basename.slice(i + 1).toLowerCase();
227}
228
229/**
230 * Return the language for a path, or null if it can't be classified.
231 * Tries `FILENAME_MAP` on the basename first (for extensionless files
232 * like `Dockerfile`), then `EXTENSION_MAP`.
233 */
234export function detectLanguage(path: string): string | null {
235 if (typeof path !== "string" || path.length === 0) return null;
236 const base = lastSegment(path).toLowerCase();
237 const byName = FILENAME_MAP.get(base);
238 if (byName) return byName;
239 const ext = extensionOf(base);
240 if (!ext) return null;
241 return EXTENSION_MAP.get(ext) ?? null;
242}
243
244export function isVendoredOrGenerated(path: string): boolean {
245 if (typeof path !== "string") return false;
246 const normal = path.startsWith("/") ? path.slice(1) : path;
247 for (const pref of VENDORED_PREFIXES) {
248 if (normal.startsWith(pref) || normal.includes("/" + pref)) return true;
249 }
250 const base = lastSegment(normal);
251 for (const sfx of GENERATED_SUFFIXES) {
252 if (base === sfx) return true;
253 }
254 return false;
255}
256
257export interface StatsOptions {
258 /** Exclude `node_modules/`, `dist/`, lock files, etc. Default true. */
259 ignoreVendored?: boolean;
260 /** Cap the per-file size we'll count (prevents a 500MB blob biasing the pie). */
261 maxFileSize?: number;
262 /** Minimum bytes for a language to avoid being folded into "Other". Default 0 (= keep all). */
263 minLanguageBytes?: number;
264}
265
266function colorFor(lang: string): string {
267 return LANGUAGE_COLORS.get(lang) ?? DEFAULT_LANGUAGE_COLOR;
268}
269
270/**
271 * Walk the entries, bucket by detected language, compute percentages.
272 * Paths that can't be classified are ignored (they don't count against
273 * the total). Returns a sorted `buckets` array.
274 */
275export function computeLanguageStats(
276 entries: readonly LanguageFileEntry[],
277 opts: StatsOptions = {}
278): LanguageReport {
279 const ignoreVendored = opts.ignoreVendored !== false;
280 const maxFileSize =
281 opts.maxFileSize !== undefined && opts.maxFileSize > 0
282 ? opts.maxFileSize
283 : Number.POSITIVE_INFINITY;
284 const minLanguageBytes = opts.minLanguageBytes ?? 0;
285
286 let totalFiles = 0;
287 let countedFiles = 0;
288 const byLang = new Map<string, { bytes: number; fileCount: number }>();
289
290 for (const e of entries) {
291 totalFiles++;
292 if (!e || typeof e.path !== "string") continue;
293 if (ignoreVendored && isVendoredOrGenerated(e.path)) continue;
294 const lang = detectLanguage(e.path);
295 if (!lang) continue;
296 const sz = Math.min(
297 Math.max(0, Number.isFinite(e.size) ? e.size : 0),
298 maxFileSize
299 );
300 if (sz === 0) continue;
301 countedFiles++;
302 const agg = byLang.get(lang) ?? { bytes: 0, fileCount: 0 };
303 agg.bytes += sz;
304 agg.fileCount++;
305 byLang.set(lang, agg);
306 }
307
308 const totalBytes = Array.from(byLang.values()).reduce(
309 (acc, v) => acc + v.bytes,
310 0
311 );
312
313 const buckets: LanguageBucket[] = [];
314 let otherBytes = 0;
315 let otherFiles = 0;
316 for (const [language, { bytes, fileCount }] of byLang) {
317 if (bytes < minLanguageBytes) {
318 otherBytes += bytes;
319 otherFiles += fileCount;
320 continue;
321 }
322 buckets.push({
323 language,
324 bytes,
325 fileCount,
326 percent: totalBytes === 0 ? 0 : (bytes / totalBytes) * 100,
327 color: colorFor(language),
328 });
329 }
330 if (otherBytes > 0) {
331 buckets.push({
332 language: "Other",
333 bytes: otherBytes,
334 fileCount: otherFiles,
335 percent: totalBytes === 0 ? 0 : (otherBytes / totalBytes) * 100,
336 color: colorFor("Other"),
337 });
338 }
339
340 buckets.sort((a, b) => {
341 if (a.language === "Other" && b.language !== "Other") return 1;
342 if (b.language === "Other" && a.language !== "Other") return -1;
343 if (a.bytes !== b.bytes) return b.bytes - a.bytes;
344 return a.language.localeCompare(b.language);
345 });
346
347 const primary = buckets.find((b) => b.language !== "Other") ?? null;
348 return {
349 totalBytes,
350 totalFiles,
351 countedFiles,
352 buckets,
353 primary,
354 };
355}
356
357/**
358 * Fold languages under `thresholdPercent` into an "Other" bucket.
359 * Only rebucketing — re-running on the output is idempotent.
360 */
361export function foldIntoOther(
362 report: LanguageReport,
363 thresholdPercent: number
364): LanguageReport {
365 if (thresholdPercent <= 0) return report;
366 const keep: LanguageBucket[] = [];
367 let otherBytes = 0;
368 let otherFiles = 0;
369 for (const b of report.buckets) {
370 if (b.language === "Other" || b.percent < thresholdPercent) {
371 otherBytes += b.bytes;
372 otherFiles += b.fileCount;
373 } else {
374 keep.push(b);
375 }
376 }
377 if (otherBytes > 0) {
378 keep.push({
379 language: "Other",
380 bytes: otherBytes,
381 fileCount: otherFiles,
382 percent:
383 report.totalBytes === 0 ? 0 : (otherBytes / report.totalBytes) * 100,
384 color: colorFor("Other"),
385 });
386 }
387 keep.sort((a, b) => {
388 if (a.language === "Other" && b.language !== "Other") return 1;
389 if (b.language === "Other" && a.language !== "Other") return -1;
390 if (a.bytes !== b.bytes) return b.bytes - a.bytes;
391 return a.language.localeCompare(b.language);
392 });
393 return {
394 ...report,
395 buckets: keep,
396 primary: keep.find((b) => b.language !== "Other") ?? null,
397 };
398}
399
400const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
401
402export function formatBytes(n: number): string {
403 if (!Number.isFinite(n) || n < 0) return "0 B";
404 if (n < 1024) return `${n} B`;
405 let v = n;
406 let u = 0;
407 while (v >= 1024 && u < SIZE_UNITS.length - 1) {
408 v /= 1024;
409 u++;
410 }
411 return `${v.toFixed(v >= 10 || u === 0 ? 0 : 1)} ${SIZE_UNITS[u]}`;
412}
413
414export function formatPercent(p: number, digits = 1): string {
415 if (!Number.isFinite(p)) return "0%";
416 const v = Math.max(0, Math.min(100, p));
417 return `${v.toFixed(digits)}%`;
418}
419
420export interface BuildReportOptions extends StatsOptions {
421 entries: readonly LanguageFileEntry[];
422 /** After rollup, fold any language under this percentage into Other. */
423 foldUnderPercent?: number;
424}
425
426export function buildLanguageReport(opts: BuildReportOptions): LanguageReport {
427 const base = computeLanguageStats(opts.entries, opts);
428 if (opts.foldUnderPercent && opts.foldUnderPercent > 0) {
429 return foldIntoOther(base, opts.foldUnderPercent);
430 }
431 return base;
432}
433
434export const __internal = {
435 EXTENSION_MAP,
436 FILENAME_MAP,
437 LANGUAGE_COLORS,
438 DEFAULT_LANGUAGE_COLOR,
439 VENDORED_PREFIXES,
440 GENERATED_SUFFIXES,
441 detectLanguage,
442 isVendoredOrGenerated,
443 computeLanguageStats,
444 foldIntoOther,
445 formatBytes,
446 formatPercent,
447 buildLanguageReport,
448 colorFor,
449};
Modifiedsrc/routes/insights.tsx+6−0View fileUnifiedSplit
170170 >
171171 Pulse &rarr;
172172 </a>
173 <a
174 href={`/${owner}/${repo}/languages`}
175 style="font-size: 12px; color: var(--accent)"
176 >
177 Languages &rarr;
178 </a>
173179 </div>
174180 </div>
175181
Addedsrc/routes/languages.tsx+279−0View fileUnifiedSplit
1/**
2 * Block J30 — Repository language breakdown.
3 *
4 * GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]
5 *
6 * Renders a GitHub-parity language breakdown: a stacked percentage bar +
7 * a per-language table (bytes, files, share). Uses `listTreeRecursive`
8 * against the repo's default branch (or an explicit `ref`) to get file
9 * sizes, then runs the pure `buildLanguageReport` helper.
10 *
11 * Defaults: vendored/generated files (node_modules, dist, lockfiles, etc.)
12 * are excluded. Pass `?vendored=1` to include them.
13 *
14 * softAuth + try/catch-wrapped resolveRepo — never 500s.
15 */
16
17import { Hono } from "hono";
18import { and, eq } from "drizzle-orm";
19import { db } from "../db";
20import { repositories, users } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { getDefaultBranch, listTreeRecursive } from "../git/repository";
26import {
27 buildLanguageReport,
28 formatBytes,
29 formatPercent,
30 type LanguageFileEntry,
31 type LanguageReport,
32} from "../lib/language-stats";
33
34const languageRoutes = new Hono<AuthEnv>();
35
36languageRoutes.use("*", softAuth);
37
38async function resolveRepo(ownerName: string, repoName: string) {
39 try {
40 const [owner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!owner) return null;
46 const [repo] = await db
47 .select()
48 .from(repositories)
49 .where(
50 and(
51 eq(repositories.ownerId, owner.id),
52 eq(repositories.name, repoName)
53 )
54 )
55 .limit(1);
56 if (!repo) return null;
57 return { owner, repo };
58 } catch {
59 return null;
60 }
61}
62
63function parseFold(raw: string | undefined): number {
64 if (!raw) return 0;
65 const n = Number(raw);
66 if (!Number.isFinite(n) || n <= 0 || n >= 100) return 0;
67 return n;
68}
69
70/** Minimal ref sanity check — branches/tags shouldn't contain whitespace or `..`. */
71function sanitiseRef(raw: string | undefined): string | null {
72 if (!raw) return null;
73 const trimmed = raw.trim();
74 if (!trimmed || trimmed.length > 200) return null;
75 if (/\.\./.test(trimmed)) return null;
76 if (/[\s~^:?*[\\]/.test(trimmed)) return null;
77 return trimmed;
78}
79
80languageRoutes.get("/:owner/:repo/languages", async (c) => {
81 const { owner: ownerName, repo: repoName } = c.req.param();
82 const user = c.get("user");
83
84 const includeVendored = c.req.query("vendored") === "1";
85 const foldUnderPercent = parseFold(c.req.query("fold"));
86 const refParam = sanitiseRef(c.req.query("ref"));
87
88 const resolved = await resolveRepo(ownerName, repoName);
89 if (!resolved) {
90 return c.html(
91 <Layout title="Not Found" user={user}>
92 <div class="empty-state">
93 <h2>Repository not found</h2>
94 </div>
95 </Layout>,
96 404
97 );
98 }
99
100 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
101 return c.html(
102 <Layout title="Not Found" user={user}>
103 <div class="empty-state">
104 <h2>Repository not found</h2>
105 </div>
106 </Layout>,
107 404
108 );
109 }
110
111 let ref: string | null = refParam;
112 if (!ref) {
113 try {
114 ref = await getDefaultBranch(ownerName, repoName);
115 } catch {
116 ref = null;
117 }
118 }
119
120 let entries: LanguageFileEntry[] = [];
121 if (ref) {
122 try {
123 entries = await listTreeRecursive(ownerName, repoName, ref);
124 } catch {
125 entries = [];
126 }
127 }
128
129 const report: LanguageReport = buildLanguageReport({
130 entries,
131 ignoreVendored: !includeVendored,
132 foldUnderPercent,
133 });
134
135 const empty = report.buckets.length === 0;
136
137 return c.html(
138 <Layout title={`Languages — ${ownerName}/${repoName}`} user={user}>
139 <RepoHeader owner={ownerName} repo={repoName} />
140 <div style="max-width: 920px">
141 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
142 <h2 style="margin: 0">Languages</h2>
143 <form
144 method="GET"
145 action={`/${ownerName}/${repoName}/languages`}
146 style="display: flex; gap: 10px; align-items: center; font-size: 12px"
147 >
148 <label style="display: flex; align-items: center; gap: 4px">
149 <input
150 type="checkbox"
151 name="vendored"
152 value="1"
153 checked={includeVendored}
154 onchange="this.form.submit()"
155 />
156 Include vendored
157 </label>
158 <label style="display: flex; align-items: center; gap: 4px">
159 Fold &lt;
160 <select
161 name="fold"
162 onchange="this.form.submit()"
163 style="padding: 2px 6px; font-size: 12px"
164 >
165 {[0, 0.5, 1, 2, 5].map((n) => (
166 <option
167 value={String(n)}
168 selected={Math.abs(n - foldUnderPercent) < 0.001}
169 >
170 {n === 0 ? "Off" : `${n}%`}
171 </option>
172 ))}
173 </select>
174 </label>
175 {refParam ? (
176 <input type="hidden" name="ref" value={refParam} />
177 ) : null}
178 </form>
179 </div>
180 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
181 {ref ? (
182 <>
183 Analyzed <code>{ref}</code> —{" "}
184 <strong>{report.countedFiles.toLocaleString()}</strong> of{" "}
185 {report.totalFiles.toLocaleString()} files (
186 {formatBytes(report.totalBytes)} total).{" "}
187 {includeVendored
188 ? "Including vendored + lock files."
189 : "Vendored directories and lock files excluded."}
190 </>
191 ) : (
192 <>No default branch detected — repository may be empty.</>
193 )}
194 </p>
195
196 {empty ? (
197 <div class="empty-state">
198 <h3>No classifiable files</h3>
199 <p>
200 {ref
201 ? "Either the repo is empty, only contains vendored files, or the file types aren't recognised."
202 : "Push some code to see the language breakdown."}
203 </p>
204 </div>
205 ) : (
206 <>
207 <div
208 style="display: flex; width: 100%; height: 12px; border-radius: 6px; overflow: hidden; margin-bottom: 6px; background: var(--bg-secondary)"
209 aria-label="Language breakdown"
210 role="img"
211 >
212 {report.buckets.map((b) => (
213 <div
214 title={`${b.language} — ${formatPercent(b.percent)}`}
215 style={`width: ${b.percent.toFixed(4)}%; background: ${b.color}; height: 100%`}
216 />
217 ))}
218 </div>
219 <div style="display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 20px; font-size: 12px">
220 {report.buckets.map((b) => (
221 <span style="display: inline-flex; align-items: center; gap: 6px">
222 <span
223 style={`display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: ${b.color}`}
224 />
225 <strong>{b.language}</strong>
226 <span style="color: var(--text-muted)">
227 {formatPercent(b.percent)}
228 </span>
229 </span>
230 ))}
231 </div>
232
233 <table style="width: 100%; border-collapse: collapse">
234 <thead>
235 <tr>
236 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
237 Language
238 </th>
239 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
240 Files
241 </th>
242 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
243 Size
244 </th>
245 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
246 Share
247 </th>
248 </tr>
249 </thead>
250 <tbody>
251 {report.buckets.map((b) => (
252 <tr>
253 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
254 <span
255 style={`display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${b.color}; margin-right: 8px; vertical-align: middle`}
256 />
257 {b.language}
258 </td>
259 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
260 {b.fileCount.toLocaleString()}
261 </td>
262 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
263 {formatBytes(b.bytes)}
264 </td>
265 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
266 {formatPercent(b.percent)}
267 </td>
268 </tr>
269 ))}
270 </tbody>
271 </table>
272 </>
273 )}
274 </div>
275 </Layout>
276 );
277});
278
279export default languageRoutes;
0280