Commit28bc555unknown_key
feat(BLOCK-J): J32 PR size distribution metric
feat(BLOCK-J): J32 PR size distribution metric Adds `/:owner/:repo/insights/pr-size` — classifies PRs into five size tiers (XS/S/M/L/XL by lines changed) with KPI cards (median/mean/p90/ small-PR ratio), a colour-coded histogram, and a largest-PRs table with +a/-d breakdown. New `diffNumstat` helper in repository.ts runs `git diff --numstat base..head`. Pure rollup in `src/lib/pr-size.ts` reuses parseWindow/VALID_WINDOWS from J25. Per-PR numstat failures degrade to 0 lines rather than crashing the page. 29 new tests (1448 passing total). https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
7 files changed+1082−128bc555dd052e1e732faba74da02eb7a8a3ba9dd
7 changed files+1082−1
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
@@ -153,6 +153,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
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. |
154154| 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. |
155155| Repository size audit | ✅ | J31 — `GET /:owner/:repo/insights/size[?top=N&ref=<branch>]` renders "where are the bytes?" for the default branch (or an explicit ref): five KPI cards (file count / total / largest / median / mean), a five-bucket size-class histogram (tiny <1KB / small <100KB / medium <1MB / large <10MB / xlarge ≥10MB), a top-level directory breakdown with percentage shares, and the largest N files (default 25, capped at 200) linked through to the blob viewer. Pure `topLevelDir`, `classifyFileSize`, `summariseSize` (total/mean/median/largest/smallest over valid entries, drops NaN/negative/non-string-path input), `bucketBySize`, `topLargestFiles` (non-mutating, stable alphabetical tie-break, optional `minBytes` floor), `summariseByTopDir` (groups by first path segment, `.` is the root bucket sorting last on ties), `buildSizeReport` one-shot in `src/lib/repo-size.ts`. Reuses `listTreeRecursive` from J30 + `formatBytes`/`formatPercent` from `language-stats.ts` so the route stays a thin shell. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; git failures degrade to an empty-state report — never 500. |
156| PR size distribution metric | ✅ | J32 — `GET /:owner/:repo/insights/pr-size[?window=7\|30\|90\|365\|0&top=N]` scores every PR in the window against the classic five-tier size taxonomy (XS ≤10 / S ≤50 / M ≤250 / L ≤1000 / XL >1000 lines changed). Renders eight KPI cards (total / merged / open / median / mean / p90 / largest / small-PR ratio), a five-class histogram with traffic-light borders (green→red), and the largest N PRs with file count + `+a/-d` breakdown + a colour-coded size-class badge. New `diffNumstat(owner, name, base, head)` helper in `src/git/repository.ts` runs `git diff --numstat base..head` and sums additions/deletions/files (binaries count as 0). Pure rollup in `src/lib/pr-size.ts`: `PR_SIZE_CLASSES` (inclusive-below boundaries, Infinity tail), `classifyPrSize` (NaN/negative → xs), `computePrSizeStats` (anchors window on `mergedAt` for merged PRs so recent merges with old `createdAt` land in recent windows; drops unparseable dates; clamps negative/NaN additions and deletions to 0), `summarisePrSizes` (inclusive-method p50/p90, merged-count, non-draft-open-count, small-PR ratio rounded to 1 decimal), `bucketPrSizes` (always returns all five buckets so UI can render zero-count cells), `topLargestPrs` (non-mutating; PR-number-desc tie-break; defaults bogus limits to 10), `buildPrSizeReport` one-shot. Reuses `parseWindow` / `VALID_WINDOWS` / `DEFAULT_WINDOW_DAYS` from J25's response-time.ts. Per-PR numstat errors degrade to 0 lines (PR still renders in XS bucket) rather than crashing the page. PR fetch capped at 500; topN capped at 50. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers. |
156157| 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 |
157158| 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`. |
158159| 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. |
@@ -556,6 +557,9 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
556557- `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}`.
557558- `src/lib/repo-size.ts` (Block J31) — pure size-audit helpers. `DEFAULT_TOP_N=25`. `SIZE_CLASSES` is the five-tier taxonomy (tiny<1KB, small<100KB, medium<1MB, large<10MB, xlarge≥10MB) with inclusive-below boundaries + human labels. `topLevelDir(path)` extracts the first segment (returns `"."` for root files, tolerates leading `/` + non-string input). `classifyFileSize(size)` bucketizes (NaN/negative → tiny). `summariseSize(entries)` returns `{totalFiles, countedFiles, totalBytes, averageBytes, medianBytes, largestBytes, smallestBytes}` — `totalFiles` is the raw input count, `countedFiles` is the post-validation count. `bucketBySize` always returns all five buckets so the UI can render zero-count cells. `topLargestFiles(entries, {limit, minBytes})` is non-mutating, sorts bytes-desc with path-asc stable tie-break, defaults invalid limits to `DEFAULT_TOP_N`, and populates each result's `topDir`. `summariseByTopDir` groups by first segment, sorts bytes-desc with the root (`.`) bucket sinking to the bottom on ties. `buildSizeReport({entries, topN, minBytesForLargest})` one-shot. `__internal` re-exports.
558559- `src/routes/repo-size.tsx` (Block J31) — serves `GET /:owner/:repo/insights/size`. softAuth; private repos 404 for non-owner viewers. Resolves ref via `getDefaultBranch` unless `?ref=` is supplied (sanitised against whitespace / `..` / git-refspec metacharacters). Calls `listTreeRecursive` → `buildSizeReport`. `top=N` capped at 200; `min=B` capped at 1 GiB; both default to sane values on garbage input. Largest-files table links each path through to `/:owner/:repo/blob/<ref>/<path>` with proper encodeURIComponent per segment. Empty trees render an empty-state panel. Linked from the Insights page header. Must be mounted before `insightsRoutes` so the static `/insights/size` path wins over any future `/insights/:id` dynamic route.
560- `src/lib/pr-size.ts` (Block J32) — pure PR size-distribution rollup. `PR_SIZE_CLASSES` five-tier taxonomy (XS ≤10 / S ≤50 / M ≤250 / L ≤1000 / XL >1000 lines changed, inclusive-below boundaries). `classifyPrSize(linesChanged)` (NaN/negative → xs). `computePrSizeStats(prs, windowDays, now)` anchors window on `mergedAt` for merged PRs; drops unparseable dates; clamps negative/NaN additions+deletions to 0. `summarisePrSizes` — p50/p90/mean via inclusive-method percentile interpolation, separate merged/non-draft-open counters, `smallPrRatio` (% XS+S) rounded to 1 decimal. `bucketPrSizes` always returns all five buckets. `topLargestPrs` non-mutating, PR-number-desc tie-break, defaults bogus limits to `DEFAULT_TOP_N=10`. `buildPrSizeReport` one-shot. Re-exports `parseWindow`/`VALID_WINDOWS`/`DEFAULT_WINDOW_DAYS` from J25. `__internal` re-exports.
561- `src/routes/pr-size.tsx` (Block J32) — serves `GET /:owner/:repo/insights/pr-size[?window=…&top=N]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 500 PRs, runs `diffNumstat` per PR in parallel (errors → 0 lines, still renders in XS), feeds into `buildPrSizeReport`. Eight KPI cards, five-class histogram with traffic-light borders, largest-PRs table with +a/-d breakdown + colour-coded size-class badge. topN capped at 50. Linked from the Insights page header.
562- `src/git/repository.ts` (Block J32 additions) — `diffNumstat(owner, name, base, head)` runs `git diff --numstat base..head`, sums additions/deletions/files (binary files count as 0). Returns null on git failure.
559563
560564### 4.7 Views (locked contracts)
561565- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -590,7 +594,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
590594```bash
591595bun install
592596bun dev # hot reload
593bun test # 1419 tests currently pass
597bun test # 1448 tests currently pass
594598bun run db:migrate
595599```
596600
Addedsrc/__tests__/pr-size.test.ts+452−0View fileUnifiedSplit
@@ -0,0 +1,452 @@
1/**
2 * Block J32 — PR size distribution metric. Pure rollup tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 PR_SIZE_CLASSES,
8 DEFAULT_TOP_N,
9 DEFAULT_WINDOW_DAYS,
10 VALID_WINDOWS,
11 parseWindow,
12 classifyPrSize,
13 computePrSizeStats,
14 summarisePrSizes,
15 bucketPrSizes,
16 topLargestPrs,
17 buildPrSizeReport,
18 __internal,
19 type PrSizeInput,
20 type PrSizeStat,
21} from "../lib/pr-size";
22
23const DAY = 24 * 60 * 60 * 1000;
24
25describe("pr-size — re-exports from J25", () => {
26 it("parseWindow accepts canonical windows", () => {
27 expect(parseWindow("7")).toBe(7);
28 expect(parseWindow(undefined)).toBe(DEFAULT_WINDOW_DAYS);
29 });
30 it("VALID_WINDOWS includes the default", () => {
31 expect(VALID_WINDOWS).toContain(DEFAULT_WINDOW_DAYS);
32 });
33});
34
35describe("pr-size — classifyPrSize", () => {
36 it("maps to the five classes by boundary", () => {
37 expect(classifyPrSize(0)).toBe("xs");
38 expect(classifyPrSize(10)).toBe("xs"); // inclusive
39 expect(classifyPrSize(11)).toBe("s");
40 expect(classifyPrSize(50)).toBe("s"); // inclusive
41 expect(classifyPrSize(51)).toBe("m");
42 expect(classifyPrSize(250)).toBe("m");
43 expect(classifyPrSize(251)).toBe("l");
44 expect(classifyPrSize(1000)).toBe("l");
45 expect(classifyPrSize(1001)).toBe("xl");
46 expect(classifyPrSize(99_999)).toBe("xl");
47 });
48 it("defaults bogus values to xs", () => {
49 expect(classifyPrSize(Number.NaN)).toBe("xs");
50 expect(classifyPrSize(-5)).toBe("xs");
51 });
52 it("has exactly five ordered classes", () => {
53 expect(PR_SIZE_CLASSES).toHaveLength(5);
54 expect(PR_SIZE_CLASSES.map((c) => c.key)).toEqual([
55 "xs",
56 "s",
57 "m",
58 "l",
59 "xl",
60 ]);
61 });
62});
63
64describe("pr-size — computePrSizeStats + window", () => {
65 const now = new Date("2025-04-01T00:00:00Z").getTime();
66 const prs: PrSizeInput[] = [
67 {
68 id: "m",
69 number: 1,
70 title: "merged recent",
71 state: "merged",
72 createdAt: new Date(now - 60 * DAY),
73 mergedAt: new Date(now - 5 * DAY), // anchors on mergedAt
74 additions: 30,
75 deletions: 10,
76 files: 3,
77 },
78 {
79 id: "o",
80 number: 2,
81 title: "open recent",
82 state: "open",
83 createdAt: new Date(now - 3 * DAY),
84 additions: 300,
85 deletions: 50,
86 files: 10,
87 },
88 {
89 id: "old",
90 number: 3,
91 title: "old open",
92 state: "open",
93 createdAt: new Date(now - 90 * DAY),
94 additions: 5,
95 deletions: 5,
96 files: 1,
97 },
98 {
99 id: "bogus",
100 number: 4,
101 title: "bad date",
102 state: "open",
103 createdAt: "not-a-date",
104 additions: 10,
105 deletions: 10,
106 files: 1,
107 },
108 ];
109
110 it("filters to the window", () => {
111 const out = computePrSizeStats(prs, 30, now);
112 expect(out.map((s) => s.id).sort()).toEqual(["m", "o"]);
113 });
114
115 it("computes linesChanged + sizeClass", () => {
116 const out = computePrSizeStats(prs, 0, now);
117 const m = out.find((s) => s.id === "m")!;
118 const o = out.find((s) => s.id === "o")!;
119 expect(m.linesChanged).toBe(40);
120 expect(m.sizeClass).toBe("s");
121 expect(o.linesChanged).toBe(350);
122 expect(o.sizeClass).toBe("l");
123 });
124
125 it("window=0 keeps everything parseable", () => {
126 const out = computePrSizeStats(prs, 0, now);
127 expect(out.map((s) => s.id).sort()).toEqual(["m", "o", "old"]);
128 });
129
130 it("drops PRs with unparseable createdAt", () => {
131 const out = computePrSizeStats(prs, 0, now);
132 expect(out.some((s) => s.id === "bogus")).toBe(false);
133 });
134
135 it("anchors merged PRs on mergedAt so recent merges with old createdAt land in window", () => {
136 const far: PrSizeInput = {
137 id: "z",
138 number: 99,
139 title: "ancient PR, recent merge",
140 state: "merged",
141 createdAt: new Date(now - 365 * DAY),
142 mergedAt: new Date(now - 2 * DAY),
143 additions: 100,
144 deletions: 0,
145 files: 1,
146 };
147 const out = computePrSizeStats([far], 7, now);
148 expect(out).toHaveLength(1);
149 });
150
151 it("treats negative / NaN line counts as zero", () => {
152 const pr: PrSizeInput = {
153 id: "neg",
154 number: 1,
155 title: "garbage numbers",
156 state: "open",
157 createdAt: new Date(now),
158 additions: -10,
159 deletions: Number.NaN,
160 files: 2,
161 };
162 const [stat] = computePrSizeStats([pr], 0, now);
163 expect(stat!.linesChanged).toBe(0);
164 expect(stat!.sizeClass).toBe("xs");
165 });
166});
167
168describe("pr-size — summarisePrSizes", () => {
169 it("zero stats", () => {
170 const s = summarisePrSizes([]);
171 expect(s.total).toBe(0);
172 expect(s.medianLines).toBe(0);
173 expect(s.p90Lines).toBe(0);
174 expect(s.smallPrRatio).toBe(0);
175 });
176
177 it("computes percentiles over a uniform series", () => {
178 const stats: PrSizeStat[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => ({
179 id: String(n),
180 number: n,
181 title: "t",
182 state: "merged",
183 isDraft: false,
184 createdAt: new Date(),
185 additions: n,
186 deletions: 0,
187 files: 1,
188 linesChanged: n,
189 sizeClass: "xs",
190 }));
191 const s = summarisePrSizes(stats);
192 expect(s.total).toBe(10);
193 expect(s.medianLines).toBe(6); // round(5.5)
194 expect(s.meanLines).toBe(6); // round(5.5)
195 expect(s.p90Lines).toBe(9); // round(9.1)
196 expect(s.largestLines).toBe(10);
197 expect(s.smallestLines).toBe(1);
198 expect(s.smallPrRatio).toBe(100); // all xs
199 });
200
201 it("classifies merged / open / draft separately", () => {
202 const mk = (
203 over: Partial<PrSizeStat> & { id: string }
204 ): PrSizeStat => ({
205 id: over.id,
206 number: 1,
207 title: "t",
208 state: "open",
209 isDraft: false,
210 createdAt: new Date(),
211 additions: 5,
212 deletions: 5,
213 files: 1,
214 linesChanged: 10,
215 sizeClass: "xs",
216 ...over,
217 });
218 const stats = [
219 mk({ id: "merged", state: "merged" }),
220 mk({ id: "open", state: "open", isDraft: false }),
221 mk({ id: "draft", state: "open", isDraft: true }),
222 mk({ id: "closed", state: "closed" }),
223 ];
224 const s = summarisePrSizes(stats);
225 expect(s.merged).toBe(1);
226 expect(s.open).toBe(1); // draft excluded
227 });
228
229 it("smallPrRatio rounds to one decimal", () => {
230 // 1 small PR, 2 large PRs → ratio = 33.3%
231 const mk = (linesChanged: number, cls: any): PrSizeStat => ({
232 id: String(linesChanged),
233 number: linesChanged,
234 title: "t",
235 state: "merged",
236 isDraft: false,
237 createdAt: new Date(),
238 additions: linesChanged,
239 deletions: 0,
240 files: 1,
241 linesChanged,
242 sizeClass: cls,
243 });
244 const s = summarisePrSizes([
245 mk(5, "xs"),
246 mk(500, "l"),
247 mk(500, "l"),
248 ]);
249 expect(s.smallPrRatio).toBe(33.3);
250 });
251});
252
253describe("pr-size — bucketPrSizes", () => {
254 it("distributes into the five class buckets with zero-count defaults", () => {
255 const mk = (
256 id: string,
257 linesChanged: number,
258 sizeClass: any
259 ): PrSizeStat => ({
260 id,
261 number: 1,
262 title: "t",
263 state: "merged",
264 isDraft: false,
265 createdAt: new Date(),
266 additions: linesChanged,
267 deletions: 0,
268 files: 1,
269 linesChanged,
270 sizeClass,
271 });
272 const stats = [
273 mk("a", 5, "xs"),
274 mk("b", 20, "s"),
275 mk("c", 100, "m"),
276 mk("d", 500, "l"),
277 mk("e", 2000, "xl"),
278 mk("f", 2, "xs"),
279 ];
280 const b = bucketPrSizes(stats);
281 const by = Object.fromEntries(b.map((x) => [x.key, x]));
282 expect(by.xs!.count).toBe(2);
283 expect(by.s!.count).toBe(1);
284 expect(by.m!.count).toBe(1);
285 expect(by.l!.count).toBe(1);
286 expect(by.xl!.count).toBe(1);
287 expect(by.xs!.bytes).toBe(7);
288 });
289
290 it("returns all five buckets for empty input", () => {
291 const b = bucketPrSizes([]);
292 expect(b).toHaveLength(5);
293 expect(b.every((x) => x.count === 0 && x.bytes === 0)).toBe(true);
294 });
295});
296
297describe("pr-size — topLargestPrs", () => {
298 const mk = (
299 id: string,
300 linesChanged: number,
301 number: number
302 ): PrSizeStat => ({
303 id,
304 number,
305 title: "t",
306 state: "merged",
307 isDraft: false,
308 createdAt: new Date(),
309 additions: linesChanged,
310 deletions: 0,
311 files: 1,
312 linesChanged,
313 sizeClass: "xs",
314 });
315
316 it("returns sorted-desc by linesChanged", () => {
317 const out = topLargestPrs([
318 mk("a", 10, 1),
319 mk("b", 1000, 2),
320 mk("c", 50, 3),
321 ]);
322 expect(out.map((s) => s.id)).toEqual(["b", "c", "a"]);
323 });
324
325 it("tie-breaks by PR number descending", () => {
326 const out = topLargestPrs([
327 mk("older", 100, 1),
328 mk("newer", 100, 2),
329 ]);
330 expect(out[0]!.id).toBe("newer");
331 expect(out[1]!.id).toBe("older");
332 });
333
334 it("honours limit", () => {
335 const stats = Array.from({ length: 50 }, (_, i) => mk(String(i), i, i));
336 expect(topLargestPrs(stats, 5)).toHaveLength(5);
337 });
338
339 it("defaults invalid limits to DEFAULT_TOP_N", () => {
340 const stats = Array.from({ length: 50 }, (_, i) => mk(String(i), i, i));
341 expect(topLargestPrs(stats, 0)).toHaveLength(DEFAULT_TOP_N);
342 expect(topLargestPrs(stats, -5)).toHaveLength(DEFAULT_TOP_N);
343 });
344
345 it("never mutates input", () => {
346 const stats = [mk("a", 10, 1), mk("b", 1000, 2)];
347 const copy = [...stats];
348 topLargestPrs(stats);
349 expect(stats).toEqual(copy);
350 });
351});
352
353describe("pr-size — buildPrSizeReport", () => {
354 const now = new Date("2025-04-01T00:00:00Z").getTime();
355 const prs: PrSizeInput[] = [
356 {
357 id: "a",
358 number: 1,
359 title: "tiny",
360 state: "merged",
361 createdAt: new Date(now - DAY),
362 mergedAt: new Date(now - DAY),
363 additions: 2,
364 deletions: 1,
365 files: 1,
366 },
367 {
368 id: "b",
369 number: 2,
370 title: "huge",
371 state: "merged",
372 createdAt: new Date(now - 2 * DAY),
373 mergedAt: new Date(now - 2 * DAY),
374 additions: 800,
375 deletions: 400,
376 files: 50,
377 },
378 {
379 id: "c",
380 number: 3,
381 title: "open",
382 state: "open",
383 createdAt: new Date(now - 3 * DAY),
384 additions: 40,
385 deletions: 0,
386 files: 2,
387 },
388 ];
389
390 it("builds a full report", () => {
391 const r = buildPrSizeReport({ prs, windowDays: 30, now });
392 expect(r.windowDays).toBe(30);
393 expect(r.now).toBe(now);
394 expect(r.perPr).toHaveLength(3);
395 expect(r.summary.total).toBe(3);
396 expect(r.summary.merged).toBe(2);
397 expect(r.summary.open).toBe(1);
398 expect(r.buckets).toHaveLength(5);
399 expect(r.largest[0]!.id).toBe("b");
400 });
401
402 it("defaults now to Date.now when omitted", () => {
403 const before = Date.now();
404 const r = buildPrSizeReport({ prs: [], windowDays: 30 });
405 const after = Date.now();
406 expect(r.now).toBeGreaterThanOrEqual(before);
407 expect(r.now).toBeLessThanOrEqual(after);
408 });
409
410 it("defaults windowDays to DEFAULT_WINDOW_DAYS when omitted", () => {
411 const r = buildPrSizeReport({ prs: [] });
412 expect(r.windowDays).toBe(DEFAULT_WINDOW_DAYS);
413 });
414
415 it("honours topN", () => {
416 const r = buildPrSizeReport({ prs, windowDays: 30, now, topN: 1 });
417 expect(r.largest).toHaveLength(1);
418 expect(r.largest[0]!.id).toBe("b");
419 });
420});
421
422describe("pr-size — routes", () => {
423 it("GET /:o/:r/insights/pr-size returns 200 or 404 (never 500)", async () => {
424 const { default: app } = await import("../app");
425 const res = await app.request("/alice/repo/insights/pr-size");
426 expect([200, 404]).toContain(res.status);
427 });
428 it("ignores bogus window values", async () => {
429 const { default: app } = await import("../app");
430 const res = await app.request(
431 "/alice/repo/insights/pr-size?window=abc&top=xyz"
432 );
433 expect([200, 404]).toContain(res.status);
434 });
435});
436
437describe("pr-size — __internal parity", () => {
438 it("re-exports every helper", () => {
439 expect(__internal.PR_SIZE_CLASSES).toBe(PR_SIZE_CLASSES);
440 expect(__internal.DEFAULT_TOP_N).toBe(DEFAULT_TOP_N);
441 expect(__internal.classifyPrSize).toBe(classifyPrSize);
442 expect(__internal.computePrSizeStats).toBe(computePrSizeStats);
443 expect(__internal.summarisePrSizes).toBe(summarisePrSizes);
444 expect(__internal.bucketPrSizes).toBe(bucketPrSizes);
445 expect(__internal.topLargestPrs).toBe(topLargestPrs);
446 expect(__internal.buildPrSizeReport).toBe(buildPrSizeReport);
447 expect(typeof __internal.toTime).toBe("function");
448 expect(typeof __internal.anchorTime).toBe("function");
449 expect(typeof __internal.percentile).toBe("function");
450 expect(typeof __internal.safeLines).toBe("function");
451 });
452});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -90,6 +90,7 @@ import issueSimilarityRoutes from "./routes/issue-similarity";
9090import prLeadTimeRoutes from "./routes/pr-lead-time";
9191import languageRoutes from "./routes/languages";
9292import repoSizeRoutes from "./routes/repo-size";
93import prSizeRoutes from "./routes/pr-size";
9394import webRoutes from "./routes/web";
9495
9596const app = new Hono();
@@ -331,6 +332,9 @@ app.route("/", languageRoutes);
331332// wins over any future dynamic `/insights/:id` route.
332333app.route("/", repoSizeRoutes);
333334
335// PR size distribution — /:owner/:repo/insights/pr-size (Block J32)
336app.route("/", prSizeRoutes);
337
334338// Insights + milestones
335339app.route("/", insightsRoutes);
336340
Modifiedsrc/git/repository.ts+33−0View fileUnifiedSplit
@@ -701,3 +701,36 @@ export async function aheadBehind(
701701 if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
702702 return { ahead, behind };
703703}
704
705/**
706 * Total additions + deletions + file count between two refs, via
707 * `git diff --numstat base..head`. Binary files count as 0 lines.
708 * Returns null on any git failure — the caller decides how to degrade.
709 */
710export async function diffNumstat(
711 owner: string,
712 name: string,
713 base: string,
714 head: string
715): Promise<{ additions: number; deletions: number; files: number } | null> {
716 const path = repoPath(owner, name);
717 const { stdout, exitCode } = await exec(
718 ["git", "diff", "--numstat", `${base}..${head}`],
719 { cwd: path }
720 );
721 if (exitCode !== 0) return null;
722 let additions = 0;
723 let deletions = 0;
724 let files = 0;
725 for (const line of stdout.split("\n")) {
726 if (!line.trim()) continue;
727 const parts = line.split("\t");
728 if (parts.length < 3) continue;
729 files++;
730 const add = parts[0] === "-" ? 0 : Number.parseInt(parts[0]!, 10);
731 const del = parts[1] === "-" ? 0 : Number.parseInt(parts[1]!, 10);
732 if (Number.isFinite(add)) additions += add;
733 if (Number.isFinite(del)) deletions += del;
734 }
735 return { additions, deletions, files };
736}
Addedsrc/lib/pr-size.ts+246−0View fileUnifiedSplit
@@ -0,0 +1,246 @@
1/**
2 * Block J32 — PR size distribution metric.
3 *
4 * Pure rollup of "how big are our PRs?". Given a set of `{additions,
5 * deletions, files}` records (optionally inside a time window), classifies
6 * each PR into five well-known size classes (XS/S/M/L/XL), computes
7 * p50/p90/mean/largest/smallest over the window, and emits a bucket
8 * histogram + the N largest open PRs.
9 *
10 * Size thresholds follow the common "≤10 / ≤50 / ≤250 / ≤1000 / >1000"
11 * heuristic. A PR's size is `additions + deletions` (binaries contribute
12 * 0 lines — the numstat parser treats `-` as zero).
13 */
14
15export {
16 DEFAULT_WINDOW_DAYS,
17 VALID_WINDOWS,
18 parseWindow,
19} from "./response-time";
20
21import { DEFAULT_WINDOW_DAYS } from "./response-time";
22
23/** Inclusive-below boundaries: a PR with lines === max lands in the NEXT class. */
24export const PR_SIZE_CLASSES = [
25 { key: "xs", label: "XS", max: 10, description: "≤ 10 lines" },
26 { key: "s", label: "S", max: 50, description: "11 – 50 lines" },
27 { key: "m", label: "M", max: 250, description: "51 – 250 lines" },
28 { key: "l", label: "L", max: 1000, description: "251 – 1000 lines" },
29 {
30 key: "xl",
31 label: "XL",
32 max: Number.POSITIVE_INFINITY,
33 description: "> 1000 lines",
34 },
35] as const;
36
37export type PrSizeClassKey = (typeof PR_SIZE_CLASSES)[number]["key"];
38
39export const DEFAULT_TOP_N = 10;
40
41export interface PrSizeInput {
42 id: string;
43 number: number;
44 title: string;
45 state: string; // open, closed, merged
46 isDraft?: boolean;
47 createdAt: Date | string;
48 mergedAt?: Date | string | null;
49 closedAt?: Date | string | null;
50 additions: number;
51 deletions: number;
52 files: number;
53}
54
55export interface PrSizeStat extends PrSizeInput {
56 linesChanged: number;
57 sizeClass: PrSizeClassKey;
58}
59
60export interface PrSizeSummary {
61 total: number;
62 merged: number;
63 open: number;
64 medianLines: number;
65 meanLines: number;
66 p90Lines: number;
67 largestLines: number;
68 smallestLines: number;
69 /** % of PRs classified `xs` or `s`, rounded to one decimal. */
70 smallPrRatio: number;
71}
72
73export interface PrSizeBucket {
74 key: PrSizeClassKey;
75 label: string;
76 description: string;
77 count: number;
78 bytes: number; // total lines changed in this bucket
79}
80
81export interface PrSizeReport {
82 windowDays: number;
83 now: number;
84 perPr: PrSizeStat[];
85 summary: PrSizeSummary;
86 buckets: PrSizeBucket[];
87 /** `N` largest PRs in the window, descending. */
88 largest: PrSizeStat[];
89}
90
91function toTime(value: Date | string | null | undefined): number | null {
92 if (!value) return null;
93 const t = value instanceof Date ? value.getTime() : Date.parse(value);
94 return Number.isFinite(t) ? t : null;
95}
96
97export function classifyPrSize(linesChanged: number): PrSizeClassKey {
98 if (!Number.isFinite(linesChanged) || linesChanged < 0) return "xs";
99 for (const c of PR_SIZE_CLASSES) {
100 if (linesChanged <= c.max) return c.key;
101 }
102 return "xl";
103}
104
105function safeLines(additions: number, deletions: number): number {
106 const a = Number.isFinite(additions) && additions > 0 ? additions : 0;
107 const d = Number.isFinite(deletions) && deletions > 0 ? deletions : 0;
108 return a + d;
109}
110
111/** Window anchor: mergedAt for merged PRs, createdAt otherwise. */
112function anchorTime(pr: PrSizeInput): number | null {
113 if (pr.state === "merged" && pr.mergedAt) {
114 return toTime(pr.mergedAt);
115 }
116 return toTime(pr.createdAt);
117}
118
119export function computePrSizeStats(
120 prs: readonly PrSizeInput[],
121 windowDays: number,
122 now: number = Date.now()
123): PrSizeStat[] {
124 const cutoff = windowDays > 0 ? now - windowDays * 24 * 60 * 60 * 1000 : null;
125 const out: PrSizeStat[] = [];
126 for (const pr of prs) {
127 const anchor = anchorTime(pr);
128 if (anchor === null) continue;
129 if (cutoff !== null && anchor < cutoff) continue;
130 const linesChanged = safeLines(pr.additions, pr.deletions);
131 out.push({
132 ...pr,
133 linesChanged,
134 sizeClass: classifyPrSize(linesChanged),
135 });
136 }
137 return out;
138}
139
140function percentile(sortedAsc: readonly number[], p: number): number {
141 const n = sortedAsc.length;
142 if (n === 0) return 0;
143 if (n === 1) return sortedAsc[0]!;
144 const rank = (p / 100) * (n - 1);
145 const lo = Math.floor(rank);
146 const hi = Math.ceil(rank);
147 if (lo === hi) return sortedAsc[lo]!;
148 const frac = rank - lo;
149 return Math.round(sortedAsc[lo]! + frac * (sortedAsc[hi]! - sortedAsc[lo]!));
150}
151
152export function summarisePrSizes(stats: readonly PrSizeStat[]): PrSizeSummary {
153 const sizes = stats.map((s) => s.linesChanged).sort((a, b) => a - b);
154 const n = sizes.length;
155 const total = sizes.reduce((acc, v) => acc + v, 0);
156 const merged = stats.filter((s) => s.state === "merged").length;
157 const open = stats.filter(
158 (s) => s.state === "open" && !s.isDraft
159 ).length;
160 const smallCount = stats.filter(
161 (s) => s.sizeClass === "xs" || s.sizeClass === "s"
162 ).length;
163
164 return {
165 total: n,
166 merged,
167 open,
168 medianLines: n === 0 ? 0 : percentile(sizes, 50),
169 meanLines: n === 0 ? 0 : Math.round(total / n),
170 p90Lines: n === 0 ? 0 : percentile(sizes, 90),
171 largestLines: n === 0 ? 0 : sizes[n - 1]!,
172 smallestLines: n === 0 ? 0 : sizes[0]!,
173 smallPrRatio: n === 0 ? 0 : Math.round((smallCount / n) * 1000) / 10,
174 };
175}
176
177export function bucketPrSizes(stats: readonly PrSizeStat[]): PrSizeBucket[] {
178 const out: PrSizeBucket[] = PR_SIZE_CLASSES.map((c) => ({
179 key: c.key,
180 label: c.label,
181 description: c.description,
182 count: 0,
183 bytes: 0,
184 }));
185 for (const s of stats) {
186 const b = out.find((x) => x.key === s.sizeClass)!;
187 b.count++;
188 b.bytes += s.linesChanged;
189 }
190 return out;
191}
192
193export function topLargestPrs(
194 stats: readonly PrSizeStat[],
195 limit: number = DEFAULT_TOP_N
196): PrSizeStat[] {
197 const n =
198 Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : DEFAULT_TOP_N;
199 return stats
200 .slice()
201 .sort((a, b) => {
202 if (a.linesChanged !== b.linesChanged) {
203 return b.linesChanged - a.linesChanged;
204 }
205 return b.number - a.number;
206 })
207 .slice(0, n);
208}
209
210export interface BuildPrSizeReportOptions {
211 prs: readonly PrSizeInput[];
212 windowDays?: number;
213 now?: number;
214 topN?: number;
215}
216
217export function buildPrSizeReport(
218 opts: BuildPrSizeReportOptions
219): PrSizeReport {
220 const now = opts.now ?? Date.now();
221 const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
222 const perPr = computePrSizeStats(opts.prs, windowDays, now);
223 return {
224 windowDays,
225 now,
226 perPr,
227 summary: summarisePrSizes(perPr),
228 buckets: bucketPrSizes(perPr),
229 largest: topLargestPrs(perPr, opts.topN ?? DEFAULT_TOP_N),
230 };
231}
232
233export const __internal = {
234 PR_SIZE_CLASSES,
235 DEFAULT_TOP_N,
236 classifyPrSize,
237 computePrSizeStats,
238 summarisePrSizes,
239 bucketPrSizes,
240 topLargestPrs,
241 buildPrSizeReport,
242 toTime,
243 anchorTime,
244 percentile,
245 safeLines,
246};
Modifiedsrc/routes/insights.tsx+6−0View fileUnifiedSplit
@@ -182,6 +182,12 @@ insights.get("/:owner/:repo/insights", async (c) => {
182182 >
183183 Size audit →
184184 </a>
185 <a
186 href={`/${owner}/${repo}/insights/pr-size`}
187 style="font-size: 12px; color: var(--accent)"
188 >
189 PR size →
190 </a>
185191 </div>
186192 </div>
187193
Addedsrc/routes/pr-size.tsx+336−0View fileUnifiedSplit
@@ -0,0 +1,336 @@
1/**
2 * Block J32 — PR size distribution metric.
3 *
4 * GET /:owner/:repo/insights/pr-size[?window=7|30|90|365|0&top=N]
5 *
6 * Renders five KPI cards (total / median / mean / p90 / small-PR ratio),
7 * a five-class histogram (XS / S / M / L / XL), and the largest N PRs
8 * in the window. Git numstat is computed per-PR against base..head via
9 * `diffNumstat`, capped to 500 PRs per request so one repo can't pin a
10 * whole server.
11 *
12 * softAuth; private repos 404 for non-owner viewers. Git failures on a
13 * single PR yield zero lines changed for that PR (it still renders in
14 * the XS bucket) rather than taking the whole page out.
15 */
16
17import { Hono } from "hono";
18import { and, desc, eq } from "drizzle-orm";
19import { db } from "../db";
20import { pullRequests, 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 { diffNumstat } from "../git/repository";
26import {
27 DEFAULT_TOP_N,
28 VALID_WINDOWS,
29 buildPrSizeReport,
30 parseWindow,
31 type PrSizeInput,
32} from "../lib/pr-size";
33import { formatPercent } from "../lib/language-stats";
34
35const MAX_PRS = 500;
36const MAX_TOP_N = 50;
37
38const prSizeRoutes = new Hono<AuthEnv>();
39
40prSizeRoutes.use("*", softAuth);
41
42async function resolveRepo(ownerName: string, repoName: string) {
43 try {
44 const [owner] = await db
45 .select()
46 .from(users)
47 .where(eq(users.username, ownerName))
48 .limit(1);
49 if (!owner) return null;
50 const [repo] = await db
51 .select()
52 .from(repositories)
53 .where(
54 and(
55 eq(repositories.ownerId, owner.id),
56 eq(repositories.name, repoName)
57 )
58 )
59 .limit(1);
60 if (!repo) return null;
61 return { owner, repo };
62 } catch {
63 return null;
64 }
65}
66
67function parseTopN(raw: string | undefined): number {
68 if (!raw) return DEFAULT_TOP_N;
69 const n = Number(raw);
70 if (!Number.isFinite(n) || n <= 0) return DEFAULT_TOP_N;
71 return Math.min(Math.floor(n), MAX_TOP_N);
72}
73
74const SIZE_CLASS_COLORS: Record<string, string> = {
75 xs: "#4caf50",
76 s: "#8bc34a",
77 m: "#ffc107",
78 l: "#ff9800",
79 xl: "#f44336",
80};
81
82prSizeRoutes.get("/:owner/:repo/insights/pr-size", async (c) => {
83 const { owner: ownerName, repo: repoName } = c.req.param();
84 const user = c.get("user");
85 const windowDays = parseWindow(c.req.query("window"));
86 const topN = parseTopN(c.req.query("top"));
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 prRows: {
112 id: string;
113 number: number;
114 title: string;
115 state: string;
116 isDraft: boolean;
117 baseBranch: string;
118 headBranch: string;
119 createdAt: Date;
120 mergedAt: Date | null;
121 closedAt: Date | null;
122 }[] = [];
123 try {
124 prRows = await db
125 .select({
126 id: pullRequests.id,
127 number: pullRequests.number,
128 title: pullRequests.title,
129 state: pullRequests.state,
130 isDraft: pullRequests.isDraft,
131 baseBranch: pullRequests.baseBranch,
132 headBranch: pullRequests.headBranch,
133 createdAt: pullRequests.createdAt,
134 mergedAt: pullRequests.mergedAt,
135 closedAt: pullRequests.closedAt,
136 })
137 .from(pullRequests)
138 .where(eq(pullRequests.repositoryId, resolved.repo.id))
139 .orderBy(desc(pullRequests.createdAt))
140 .limit(MAX_PRS);
141 } catch {
142 // empty → empty report
143 }
144
145 // Diff each PR's base..head in parallel but bounded.
146 const diffs = await Promise.all(
147 prRows.map(async (pr) => {
148 try {
149 const r = await diffNumstat(
150 ownerName,
151 repoName,
152 pr.baseBranch,
153 pr.headBranch
154 );
155 return r ?? { additions: 0, deletions: 0, files: 0 };
156 } catch {
157 return { additions: 0, deletions: 0, files: 0 };
158 }
159 })
160 );
161
162 const inputs: PrSizeInput[] = prRows.map((r, i) => ({
163 id: r.id,
164 number: r.number,
165 title: r.title,
166 state: r.state,
167 isDraft: r.isDraft,
168 createdAt: r.createdAt,
169 mergedAt: r.mergedAt,
170 closedAt: r.closedAt,
171 additions: diffs[i]!.additions,
172 deletions: diffs[i]!.deletions,
173 files: diffs[i]!.files,
174 }));
175
176 const report = buildPrSizeReport({
177 prs: inputs,
178 windowDays,
179 topN,
180 });
181
182 const windowLabel = windowDays === 0 ? "All time" : `Last ${windowDays} days`;
183 const empty = report.summary.total === 0;
184
185 const kpi = (label: string, value: string) => (
186 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
187 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
188 {label}
189 </div>
190 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
191 {value}
192 </div>
193 </div>
194 );
195
196 return c.html(
197 <Layout title={`PR size — ${ownerName}/${repoName}`} user={user}>
198 <RepoHeader owner={ownerName} repo={repoName} />
199 <div style="max-width: 920px">
200 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
201 <h2 style="margin: 0">PR size distribution</h2>
202 <form
203 method="GET"
204 action={`/${ownerName}/${repoName}/insights/pr-size`}
205 style="display: flex; gap: 6px; align-items: center"
206 >
207 <label for="window" style="font-size: 12px; color: var(--text-muted)">
208 Window:
209 </label>
210 <select
211 id="window"
212 name="window"
213 onchange="this.form.submit()"
214 style="padding: 4px 8px; font-size: 12px"
215 >
216 {VALID_WINDOWS.map((w) => (
217 <option value={String(w)} selected={w === windowDays}>
218 {w === 0 ? "All time" : `Last ${w} days`}
219 </option>
220 ))}
221 </select>
222 </form>
223 </div>
224 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
225 <strong>{windowLabel}</strong>. Size = additions + deletions
226 (binaries counted as 0). Merged PRs are anchored on their merge
227 date, unmerged PRs on creation date. Classes: XS ≤10, S ≤50, M
228 ≤250, L ≤1000, XL >1000.
229 </p>
230
231 {empty ? (
232 <div class="empty-state">
233 <h3>No PRs in window</h3>
234 <p>Try widening the time range.</p>
235 </div>
236 ) : (
237 <>
238 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px">
239 {kpi("Total", String(report.summary.total))}
240 {kpi("Merged", String(report.summary.merged))}
241 {kpi("Open", String(report.summary.open))}
242 {kpi("Median", `${report.summary.medianLines} lines`)}
243 {kpi("Mean", `${report.summary.meanLines} lines`)}
244 {kpi("p90", `${report.summary.p90Lines} lines`)}
245 {kpi("Largest", `${report.summary.largestLines} lines`)}
246 {kpi("Small-PR ratio", formatPercent(report.summary.smallPrRatio))}
247 </div>
248
249 <h3 style="margin-bottom: 10px">Size distribution</h3>
250 <div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 24px">
251 {report.buckets.map((b) => (
252 <div
253 style={`border: 1px solid var(--border); border-left: 4px solid ${SIZE_CLASS_COLORS[b.key]}; border-radius: var(--radius); padding: 12px; text-align: center`}
254 >
255 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
256 {b.label}
257 </div>
258 <div style="font-size: 22px; font-weight: 600">
259 {b.count}
260 </div>
261 <div style="font-size: 11px; color: var(--text-muted); margin-top: 2px">
262 {b.description}
263 </div>
264 </div>
265 ))}
266 </div>
267
268 <h3 style="margin-bottom: 10px">
269 Largest PRs ({report.largest.length})
270 </h3>
271 {report.largest.length === 0 ? (
272 <div class="empty-state">
273 <p>No PRs matched the filter.</p>
274 </div>
275 ) : (
276 <table style="width: 100%; border-collapse: collapse">
277 <thead>
278 <tr>
279 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
280 PR
281 </th>
282 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
283 Files
284 </th>
285 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
286 +/-
287 </th>
288 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
289 Size
290 </th>
291 <th style="text-align: center; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 60px">
292 Class
293 </th>
294 </tr>
295 </thead>
296 <tbody>
297 {report.largest.map((p) => (
298 <tr>
299 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
300 <a href={`/${ownerName}/${repoName}/pulls/${p.number}`}>
301 <span style="color: var(--text-muted)">
302 #{p.number}
303 </span>{" "}
304 {p.title}
305 </a>
306 </td>
307 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
308 {p.files}
309 </td>
310 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
311 <span style="color: var(--green)">+{p.additions}</span>{" "}
312 <span style="color: var(--red)">-{p.deletions}</span>
313 </td>
314 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
315 {p.linesChanged}
316 </td>
317 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: center">
318 <span
319 style={`display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #fff; background: ${SIZE_CLASS_COLORS[p.sizeClass]}`}
320 >
321 {p.sizeClass.toUpperCase()}
322 </span>
323 </td>
324 </tr>
325 ))}
326 </tbody>
327 </table>
328 )}
329 </>
330 )}
331 </div>
332 </Layout>
333 );
334});
335
336export default prSizeRoutes;
0337