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

feat(BLOCK-J): J27 branch staleness / age report

feat(BLOCK-J): J27 branch staleness / age report

- src/lib/branch-age.ts pure helpers: parseThreshold/parseSort, computeDaysOld,
  classifyBranchAge (fresh/aging/stale/abandoned 30/60/90 day thresholds),
  computeBranchRow (merged = !default && ahead=0), bucketBranches,
  filterByThreshold, summariseBranches (median/mean/oldest, default excluded),
  sortBranchRows (non-mutating, 5 sort keys, null daysOld sinks),
  buildBranchReport, categoryLabel/thresholdLabel/sortLabel
- src/git/repository.ts aheadBehind(base, head) via rev-list --left-right --count
- src/routes/branch-age.tsx GET /:o/:r/branches/age with threshold + sort
  selectors, 6 KPI cards, 4 bucket cards, full branches table (ahead/behind/
  last-commit-age/status). softAuth; private repos 404 for non-owner viewers
- Link from repo settings under the default-branch select
- 42 new tests: parseThreshold/parseSort allow-lists, computeDaysOld edge cases
  (null, unparseable, future clamp, floor), classifyBranchAge all buckets,
  computeBranchRow (negative coercion, merged default exclusion, null tipDate),
  bucketBranches/filterByThreshold/summariseBranches/sortBranchRows
  (non-mutation, null-sink, stable name tie-break), buildBranchReport one-shot,
  labels, route smoke tests
- Full suite: 1241 -> 1283 pass
Claude committed on April 15, 2026Parent: 611431d
7 files changed+12482c02a55bebd40e3b62168dfd180a8a88ce8a0f422
7 changed files+1248−2
ModifiedBUILD_BIBLE.md+5−1View fileUnifiedSplit
148148| Branch rename with cascades | ✅ | J24 — `GET/POST /:owner/:repo/settings/branches` (owner-only). Pure `validateBranchName` / `planRename` / `shouldRewriteProtectionPattern` in `src/lib/branch-rename.ts` enforce `git check-ref-format(1)` (rejects `..`, `@{`, bare `@`, leading `-`, leading/trailing `.` or `/`, `//`, `.lock` suffix, whitespace, `~^:?*[\`, control chars). Git plumbing `renameBranch` (creates `refs/heads/<to>` → deletes `refs/heads/<from>` with rollback on partial failure) + `setHeadBranch` live in `src/git/repository.ts`. After the ref move the route cascades to `repositories.default_branch`, `pull_requests.base_branch` / `head_branch`, `merge_queue_entries.base_branch`, and exact-match `branch_protection.pattern` rows (globs untouched), with every rename audited as `branch.rename`. History is preserved — only the ref name changes. |
149149| Time-to-first-response metric | ✅ | J25 — `GET /:owner/:repo/insights/response-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest response times, a four-bucket distribution (≤1h, 1h–1d, 1d–1w, >1w), and the oldest-first list of open issues still waiting for a reply. Pure `computeTimeToFirstResponse` (ignores comments by the issue author, clamps negative deltas, skips unparseable dates), `computeIssueStats` (window filter), `summariseResponseTimes` (inclusive-method percentiles), `bucketResponseTimes`, `formatDuration` (`ms`/`s`/`m`/`h m`/`d h`), `buildResponseReport` one-shot, `parseWindow` (allow-list `VALID_WINDOWS`) in `src/lib/response-time.ts`. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; DB failure degrades to empty report — never 500. |
150150| Audit log CSV export | ✅ | J26 — `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv` stream the audit log as RFC 4180 CSV with `Content-Disposition: attachment`. Pure `csvCell` / `csvRow` / `csvDocument` / `formatAuditCsv` / `auditCsvFilename` helpers in `src/lib/audit-csv.ts` implement CRLF termination, `"…"` wrapping + `""` escaping when cells contain `,"\n\r`, and a CSV-injection guard that prefixes any cell starting with `=+-@\t\r` with a single-quote so spreadsheet formula engines don't evaluate attacker-supplied content. Header row is `id,when,actor,action,targetType,targetId,ip,userAgent,metadata`. Same visibility rules as the HTML pages (requireAuth + owner-only for repo variant). Filename is `audit-<scope>-<ISO>.csv`. `Cache-Control: private, no-store`. |
151| 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. |
151152| 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 |
152153| 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`. |
153154| 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. |
539540- `src/routes/response-time.tsx` (Block J25) — serves `GET /:owner/:repo/insights/response-time[?window=…]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 2000 issues + all their comments via two queries (`inArray`), runs the pure report, renders eight KPI cards (total / responded / unreplied / median / mean / p90 / fastest / slowest), four bucket cards, and the top 25 oldest-unreplied open issues with a "waiting" duration. `resolveRepo` wraps DB in try/catch → never 500. Insights page header now links to it alongside Pulse.
540541- `src/lib/audit-csv.ts` (Block J26) — pure CSV export helpers. `csvCell(value)` handles null/undefined → empty, `Date` → ISO (invalid Date → empty), RFC 4180 quoting (`,"\n\r` triggers `"..."` wrap; internal `"` doubled to `""`), and a CSV-injection guard that prefixes a cell starting with `=+-@\t\r` with a single quote (before any wrapping). `csvRow(cells)` CRLF-terminates. `csvDocument(rows)` concatenates. `AUDIT_CSV_COLUMNS = [id, when, actor, action, targetType, targetId, ip, userAgent, metadata]`. `formatAuditCsv(rows)` emits header + data rows; `normaliseCreated` accepts `Date` or ISO string and falls back to the raw string when unparseable. `auditCsvFilename(scope, now?)` emits `audit-<sanitised-scope>-<ISO>.csv` (all non-alnum in scope → `-`, empty → `audit`). `__internal` re-exports for tests.
541542- `src/routes/audit.tsx` (Block J26 integration) — adds `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv`, both `requireAuth`. Same filter + `LIMIT = 200` as the HTML pages. Repo variant 404s when the repo doesn't exist and 403s (`text/plain`) when the viewer isn't the owner. Response is `text/csv; charset=utf-8` with `Content-Disposition: attachment; filename="…"` and `Cache-Control: private, no-store`. HTML pages now show a "Download CSV" button next to the heading.
543- `src/lib/branch-age.ts` (Block J27) — pure branch-staleness helpers. `DAY_MS`, `VALID_THRESHOLDS = [0,30,60,90,180]`, `VALID_SORTS = [age-desc,age-asc,name,ahead-desc,behind-desc]`, `parseThreshold` + `parseSort` (allow-list validators). `computeDaysOld(tipDate, now)` returns floor-days or null for missing/unparseable (future timestamps clamp to 0). `classifyBranchAge(days)` four-bucket taxonomy (`fresh` <30d, `aging` 30–59, `stale` 60–89, `abandoned` ≥90 or null). `computeBranchRow(input, now)` folds the classification in + sets `merged = !isDefault && ahead===0`. `bucketBranches` excludes default. `filterByThreshold(rows, t)` drops default + null-daysOld when `t>0`. `summariseBranches` computes `total / nonDefault / merged / unmerged / withoutTip / oldestName / oldestDaysOld / averageAgeDays / medianAgeDays` (default excluded from everything). `sortBranchRows` is non-mutating, name-tiebreak stable, null daysOld sinks to bottom. `buildBranchReport` one-shot. `categoryLabel` / `thresholdLabel` / `sortLabel` UI copy. `__internal` re-exports.
544- `src/routes/branch-age.tsx` (Block J27) — serves `GET /:owner/:repo/branches/age`. softAuth; private repos 404 for non-owner viewers. Fetches `listBranches`, `getDefaultBranch`, and per-branch `getCommit` + `aheadBehind(defaultBranch, branch)`. Rows with git failures drop to null tipDate (category becomes `abandoned`) — never crashes. Renders six KPI cards, four bucket cards with age-colour borders, and a branches table with Ahead / Behind / Last-commit-age / Status columns. Threshold + sort selectors auto-submit. Linked from repo settings under the default-branch select.
545- `src/git/repository.ts` (Block J27 additions) — `aheadBehind(owner, name, base, head)` runs `git rev-list --left-right --count <base>...<head>`. Parses `<behind>\t<ahead>` pair. Returns null on exit failure, non-two-part output, or non-finite numbers. Non-cached — callers fan out per-branch at request time.
542546
543547### 4.7 Views (locked contracts)
544548- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
573577```bash
574578bun install
575579bun dev # hot reload
576bun test # 1241 tests currently pass
580bun test # 1283 tests currently pass
577581bun run db:migrate
578582```
579583
Addedsrc/__tests__/branch-age.test.ts+477−0View fileUnifiedSplit
1/**
2 * Block J27 — Branch staleness / age report. Pure rollup tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 DAY_MS,
8 VALID_THRESHOLDS,
9 VALID_SORTS,
10 DEFAULT_THRESHOLD,
11 DEFAULT_SORT,
12 parseThreshold,
13 parseSort,
14 computeDaysOld,
15 classifyBranchAge,
16 computeBranchRow,
17 bucketBranches,
18 filterByThreshold,
19 summariseBranches,
20 sortBranchRows,
21 buildBranchReport,
22 categoryLabel,
23 thresholdLabel,
24 sortLabel,
25 __internal,
26 type BranchInputRow,
27 type BranchReportRow,
28} from "../lib/branch-age";
29
30const NOW = new Date("2025-04-15T00:00:00Z").getTime();
31
32function row(overrides: Partial<BranchReportRow> = {}): BranchReportRow {
33 return {
34 name: "feat/x",
35 tipSha: "abc",
36 tipDate: new Date(NOW - 10 * DAY_MS),
37 tipAuthor: "alice",
38 tipMessage: "msg",
39 ahead: 0,
40 behind: 0,
41 isDefault: false,
42 daysOld: 10,
43 category: "fresh",
44 merged: true,
45 ...overrides,
46 };
47}
48
49describe("branch-age — parseThreshold", () => {
50 it("returns default for null/undefined/empty", () => {
51 expect(parseThreshold(null)).toBe(DEFAULT_THRESHOLD);
52 expect(parseThreshold(undefined)).toBe(DEFAULT_THRESHOLD);
53 expect(parseThreshold("")).toBe(DEFAULT_THRESHOLD);
54 });
55 it("accepts the allow-listed values", () => {
56 for (const v of VALID_THRESHOLDS) {
57 expect(parseThreshold(String(v))).toBe(v);
58 }
59 });
60 it("rejects non-listed numbers", () => {
61 expect(parseThreshold("14")).toBe(DEFAULT_THRESHOLD);
62 expect(parseThreshold("-5")).toBe(DEFAULT_THRESHOLD);
63 });
64 it("rejects garbage", () => {
65 expect(parseThreshold("hello")).toBe(DEFAULT_THRESHOLD);
66 });
67});
68
69describe("branch-age — parseSort", () => {
70 it("returns default for unknown/bad input", () => {
71 expect(parseSort(null)).toBe(DEFAULT_SORT);
72 expect(parseSort("")).toBe(DEFAULT_SORT);
73 expect(parseSort("weird")).toBe(DEFAULT_SORT);
74 expect(parseSort(42)).toBe(DEFAULT_SORT);
75 });
76 it("accepts all VALID_SORTS", () => {
77 for (const s of VALID_SORTS) {
78 expect(parseSort(s)).toBe(s);
79 }
80 });
81});
82
83describe("branch-age — computeDaysOld", () => {
84 it("null when missing/unparseable", () => {
85 expect(computeDaysOld(null, NOW)).toBeNull();
86 expect(computeDaysOld(undefined, NOW)).toBeNull();
87 expect(computeDaysOld("not-a-date", NOW)).toBeNull();
88 expect(computeDaysOld(new Date("invalid"), NOW)).toBeNull();
89 expect(computeDaysOld(42 as unknown as Date, NOW)).toBeNull();
90 });
91 it("accepts Date and ISO string", () => {
92 expect(computeDaysOld(new Date(NOW - 10 * DAY_MS), NOW)).toBe(10);
93 expect(
94 computeDaysOld(new Date(NOW - 10 * DAY_MS).toISOString(), NOW)
95 ).toBe(10);
96 });
97 it("clamps future timestamps to 0", () => {
98 expect(computeDaysOld(new Date(NOW + DAY_MS), NOW)).toBe(0);
99 });
100 it("uses floor for partial days", () => {
101 expect(computeDaysOld(new Date(NOW - (10 * DAY_MS + 3600 * 1000)), NOW)).toBe(
102 10
103 );
104 });
105});
106
107describe("branch-age — classifyBranchAge", () => {
108 it("buckets correctly", () => {
109 expect(classifyBranchAge(0)).toBe("fresh");
110 expect(classifyBranchAge(29)).toBe("fresh");
111 expect(classifyBranchAge(30)).toBe("aging");
112 expect(classifyBranchAge(59)).toBe("aging");
113 expect(classifyBranchAge(60)).toBe("stale");
114 expect(classifyBranchAge(89)).toBe("stale");
115 expect(classifyBranchAge(90)).toBe("abandoned");
116 expect(classifyBranchAge(365)).toBe("abandoned");
117 });
118 it("null → abandoned", () => {
119 expect(classifyBranchAge(null)).toBe("abandoned");
120 });
121});
122
123describe("branch-age — computeBranchRow", () => {
124 const input: BranchInputRow = {
125 name: "feat/x",
126 tipSha: "abc",
127 tipDate: new Date(NOW - 45 * DAY_MS),
128 tipAuthor: "alice",
129 tipMessage: "hi",
130 ahead: 3,
131 behind: 1,
132 isDefault: false,
133 };
134
135 it("populates daysOld + category + merged", () => {
136 const r = computeBranchRow(input, NOW);
137 expect(r.daysOld).toBe(45);
138 expect(r.category).toBe("aging");
139 expect(r.merged).toBe(false);
140 });
141
142 it("merged = true when ahead=0 and not default", () => {
143 const r = computeBranchRow({ ...input, ahead: 0 }, NOW);
144 expect(r.merged).toBe(true);
145 });
146
147 it("default branch is never flagged as merged", () => {
148 const r = computeBranchRow({ ...input, ahead: 0, isDefault: true }, NOW);
149 expect(r.merged).toBe(false);
150 });
151
152 it("coerces negative ahead/behind to 0", () => {
153 const r = computeBranchRow({ ...input, ahead: -5, behind: -2 }, NOW);
154 expect(r.ahead).toBe(0);
155 expect(r.behind).toBe(0);
156 });
157
158 it("null tipDate still yields a row with category=abandoned", () => {
159 const r = computeBranchRow({ ...input, tipDate: null }, NOW);
160 expect(r.daysOld).toBeNull();
161 expect(r.category).toBe("abandoned");
162 });
163});
164
165describe("branch-age — bucketBranches", () => {
166 it("ignores default branch + distributes others", () => {
167 const rows = [
168 row({ name: "d", isDefault: true, category: "fresh" }),
169 row({ name: "a", category: "fresh" }),
170 row({ name: "b", category: "aging" }),
171 row({ name: "c", category: "stale" }),
172 row({ name: "e", category: "abandoned" }),
173 row({ name: "f", category: "abandoned" }),
174 ];
175 const b = bucketBranches(rows);
176 expect(b).toEqual({ fresh: 1, aging: 1, stale: 1, abandoned: 2 });
177 });
178 it("empty input → all zeros", () => {
179 expect(bucketBranches([])).toEqual({
180 fresh: 0,
181 aging: 0,
182 stale: 0,
183 abandoned: 0,
184 });
185 });
186});
187
188describe("branch-age — filterByThreshold", () => {
189 const rows = [
190 row({ name: "main", isDefault: true, daysOld: 1 }),
191 row({ name: "a", daysOld: 10 }),
192 row({ name: "b", daysOld: 45 }),
193 row({ name: "c", daysOld: 120 }),
194 row({ name: "d", daysOld: null }),
195 ];
196
197 it("threshold 0 returns everything", () => {
198 expect(filterByThreshold(rows, 0).map((r) => r.name)).toEqual([
199 "main",
200 "a",
201 "b",
202 "c",
203 "d",
204 ]);
205 });
206
207 it("threshold 30 drops fresh + null + default", () => {
208 expect(filterByThreshold(rows, 30).map((r) => r.name)).toEqual(["b", "c"]);
209 });
210
211 it("threshold 90 drops everything except ≥90", () => {
212 expect(filterByThreshold(rows, 90).map((r) => r.name)).toEqual(["c"]);
213 });
214});
215
216describe("branch-age — summariseBranches", () => {
217 it("empty", () => {
218 const s = summariseBranches([]);
219 expect(s.total).toBe(0);
220 expect(s.nonDefault).toBe(0);
221 expect(s.merged).toBe(0);
222 expect(s.unmerged).toBe(0);
223 expect(s.oldestName).toBeNull();
224 expect(s.oldestDaysOld).toBeNull();
225 expect(s.averageAgeDays).toBeNull();
226 expect(s.medianAgeDays).toBeNull();
227 });
228
229 it("excludes default from the aggregates", () => {
230 const rows = [
231 row({ name: "main", isDefault: true, daysOld: 500 }),
232 row({ name: "a", daysOld: 10, merged: true, ahead: 0 }),
233 row({ name: "b", daysOld: 20, merged: false, ahead: 2 }),
234 ];
235 const s = summariseBranches(rows);
236 expect(s.total).toBe(3);
237 expect(s.nonDefault).toBe(2);
238 expect(s.merged).toBe(1);
239 expect(s.unmerged).toBe(1);
240 expect(s.oldestName).toBe("b");
241 expect(s.oldestDaysOld).toBe(20);
242 expect(s.averageAgeDays).toBe(15);
243 expect(s.medianAgeDays).toBe(15);
244 });
245
246 it("counts rows without a tipDate separately", () => {
247 const rows = [
248 row({ name: "a", daysOld: null }),
249 row({ name: "b", daysOld: 10 }),
250 ];
251 const s = summariseBranches(rows);
252 expect(s.withoutTip).toBe(1);
253 expect(s.oldestName).toBe("b");
254 expect(s.oldestDaysOld).toBe(10);
255 });
256});
257
258describe("branch-age — sortBranchRows", () => {
259 const base: BranchReportRow[] = [
260 row({ name: "feat/b", daysOld: 10, ahead: 1, behind: 0 }),
261 row({ name: "feat/a", daysOld: 100, ahead: 5, behind: 3 }),
262 row({ name: "feat/c", daysOld: null, ahead: 2, behind: 10 }),
263 ];
264
265 it("name sorts alphabetically", () => {
266 expect(sortBranchRows(base, "name").map((r) => r.name)).toEqual([
267 "feat/a",
268 "feat/b",
269 "feat/c",
270 ]);
271 });
272
273 it("age-desc sinks null to the bottom", () => {
274 expect(sortBranchRows(base, "age-desc").map((r) => r.name)).toEqual([
275 "feat/a",
276 "feat/b",
277 "feat/c",
278 ]);
279 });
280
281 it("age-asc sinks null to the bottom", () => {
282 expect(sortBranchRows(base, "age-asc").map((r) => r.name)).toEqual([
283 "feat/b",
284 "feat/a",
285 "feat/c",
286 ]);
287 });
288
289 it("ahead-desc", () => {
290 expect(sortBranchRows(base, "ahead-desc").map((r) => r.name)).toEqual([
291 "feat/a",
292 "feat/c",
293 "feat/b",
294 ]);
295 });
296
297 it("behind-desc", () => {
298 expect(sortBranchRows(base, "behind-desc").map((r) => r.name)).toEqual([
299 "feat/c",
300 "feat/a",
301 "feat/b",
302 ]);
303 });
304
305 it("never mutates input", () => {
306 const snap = base.map((r) => r.name).join(",");
307 sortBranchRows(base, "name");
308 sortBranchRows(base, "age-desc");
309 expect(base.map((r) => r.name).join(",")).toBe(snap);
310 });
311
312 it("uses stable name tie-break", () => {
313 const rows: BranchReportRow[] = [
314 row({ name: "z", ahead: 5 }),
315 row({ name: "a", ahead: 5 }),
316 ];
317 expect(sortBranchRows(rows, "ahead-desc").map((r) => r.name)).toEqual([
318 "a",
319 "z",
320 ]);
321 });
322});
323
324describe("branch-age — buildBranchReport", () => {
325 const inputs: BranchInputRow[] = [
326 {
327 name: "main",
328 tipSha: "1",
329 tipDate: new Date(NOW - 1 * DAY_MS),
330 tipAuthor: "alice",
331 tipMessage: "latest",
332 ahead: 0,
333 behind: 0,
334 isDefault: true,
335 },
336 {
337 name: "feat/a",
338 tipSha: "2",
339 tipDate: new Date(NOW - 10 * DAY_MS),
340 tipAuthor: "alice",
341 tipMessage: "feat",
342 ahead: 3,
343 behind: 1,
344 isDefault: false,
345 },
346 {
347 name: "feat/b",
348 tipSha: "3",
349 tipDate: new Date(NOW - 120 * DAY_MS),
350 tipAuthor: "bob",
351 tipMessage: "old",
352 ahead: 0,
353 behind: 5,
354 isDefault: false,
355 },
356 {
357 name: "feat/c",
358 tipSha: "4",
359 tipDate: null,
360 tipAuthor: null,
361 tipMessage: null,
362 ahead: 2,
363 behind: 2,
364 isDefault: false,
365 },
366 ];
367
368 it("builds the one-shot report", () => {
369 const r = buildBranchReport({
370 branches: inputs,
371 defaultBranch: "main",
372 now: NOW,
373 });
374 expect(r.now).toBe(NOW);
375 expect(r.threshold).toBe(DEFAULT_THRESHOLD);
376 expect(r.sort).toBe(DEFAULT_SORT);
377 expect(r.defaultBranch).toBe("main");
378 expect(r.rows).toHaveLength(4);
379 expect(r.buckets).toEqual({ fresh: 1, aging: 0, stale: 0, abandoned: 2 });
380 expect(r.summary.nonDefault).toBe(3);
381 expect(r.summary.merged).toBe(1); // feat/b (ahead=0)
382 expect(r.summary.unmerged).toBe(2);
383 expect(r.summary.oldestName).toBe("feat/b");
384 expect(r.summary.oldestDaysOld).toBe(120);
385 });
386
387 it("applies threshold + sort", () => {
388 const r = buildBranchReport({
389 branches: inputs,
390 defaultBranch: "main",
391 now: NOW,
392 threshold: 30,
393 sort: "age-desc",
394 });
395 expect(r.filtered.map((row) => row.name)).toEqual(["feat/b"]);
396 });
397
398 it("threshold=0 keeps every sorted row", () => {
399 const r = buildBranchReport({
400 branches: inputs,
401 defaultBranch: "main",
402 now: NOW,
403 threshold: 0,
404 sort: "name",
405 });
406 expect(r.filtered.map((row) => row.name)).toEqual([
407 "feat/a",
408 "feat/b",
409 "feat/c",
410 "main",
411 ]);
412 });
413
414 it("defaults `now` to Date.now when omitted", () => {
415 const before = Date.now();
416 const r = buildBranchReport({ branches: [], defaultBranch: null });
417 const after = Date.now();
418 expect(r.now).toBeGreaterThanOrEqual(before);
419 expect(r.now).toBeLessThanOrEqual(after);
420 });
421});
422
423describe("branch-age — labels", () => {
424 it("categoryLabel covers all categories", () => {
425 expect(categoryLabel("fresh")).toBe("Fresh");
426 expect(categoryLabel("aging")).toBe("Aging");
427 expect(categoryLabel("stale")).toBe("Stale");
428 expect(categoryLabel("abandoned")).toBe("Abandoned");
429 });
430 it("thresholdLabel", () => {
431 expect(thresholdLabel(0)).toBe("All branches");
432 expect(thresholdLabel(30)).toBe("≥ 30 days old");
433 expect(thresholdLabel(180)).toBe("≥ 180 days old");
434 });
435 it("sortLabel covers every sort", () => {
436 expect(sortLabel("age-desc")).toBe("Oldest first");
437 expect(sortLabel("age-asc")).toBe("Newest first");
438 expect(sortLabel("name")).toBe("Name A–Z");
439 expect(sortLabel("ahead-desc")).toBe("Most ahead");
440 expect(sortLabel("behind-desc")).toBe("Most behind");
441 });
442});
443
444describe("branch-age — routes", () => {
445 it("GET /:o/:r/branches/age returns 2xx or 404 (never 500)", async () => {
446 const { default: app } = await import("../app");
447 const res = await app.request("/alice/repo/branches/age");
448 expect([200, 404]).toContain(res.status);
449 });
450
451 it("ignores bogus thresholds + sort keys", async () => {
452 const { default: app } = await import("../app");
453 const res = await app.request(
454 "/alice/repo/branches/age?threshold=xyz&sort=weird"
455 );
456 expect([200, 404]).toContain(res.status);
457 });
458});
459
460describe("branch-age — __internal parity", () => {
461 it("re-exports", () => {
462 expect(__internal.DAY_MS).toBe(DAY_MS);
463 expect(__internal.parseThreshold).toBe(parseThreshold);
464 expect(__internal.parseSort).toBe(parseSort);
465 expect(__internal.computeDaysOld).toBe(computeDaysOld);
466 expect(__internal.classifyBranchAge).toBe(classifyBranchAge);
467 expect(__internal.computeBranchRow).toBe(computeBranchRow);
468 expect(__internal.bucketBranches).toBe(bucketBranches);
469 expect(__internal.filterByThreshold).toBe(filterByThreshold);
470 expect(__internal.summariseBranches).toBe(summariseBranches);
471 expect(__internal.sortBranchRows).toBe(sortBranchRows);
472 expect(__internal.buildBranchReport).toBe(buildBranchReport);
473 expect(__internal.categoryLabel).toBe(categoryLabel);
474 expect(__internal.thresholdLabel).toBe(thresholdLabel);
475 expect(__internal.sortLabel).toBe(sortLabel);
476 });
477});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
8585import codeSuggestionsRoutes from "./routes/code-suggestions";
8686import branchRenameRoutes from "./routes/branch-rename";
8787import responseTimeRoutes from "./routes/response-time";
88import branchAgeRoutes from "./routes/branch-age";
8889import webRoutes from "./routes/web";
8990
9091const app = new Hono();
305306// path wins over any future `/insights/:id` dynamic route that might appear.
306307app.route("/", responseTimeRoutes);
307308
309// Branch staleness / age report — /:owner/:repo/branches/age (Block J27)
310// Must be mounted BEFORE webRoutes so the static `/branches/age` path wins
311// over the `/:owner/:repo/tree/:branch` dynamic web catch-all.
312app.route("/", branchAgeRoutes);
313
308314// Insights + milestones
309315app.route("/", insightsRoutes);
310316
Modifiedsrc/git/repository.ts+34−0View fileUnifiedSplit
627627 return blob?.content || null;
628628 });
629629}
630
631/**
632 * Count commits ahead and behind: commits on `head` not on `base` (ahead) and
633 * commits on `base` not on `head` (behind). Returns null when either ref is
634 * missing or git errors out.
635 *
636 * Uses `git rev-list --left-right --count <base>...<head>`; git emits
637 * `<behind>\t<ahead>` (left is `base`, right is `head`).
638 */
639export async function aheadBehind(
640 owner: string,
641 name: string,
642 base: string,
643 head: string
644): Promise<{ ahead: number; behind: number } | null> {
645 const path = repoPath(owner, name);
646 const { stdout, exitCode } = await exec(
647 [
648 "git",
649 "rev-list",
650 "--left-right",
651 "--count",
652 `${base}...${head}`,
653 ],
654 { cwd: path }
655 );
656 if (exitCode !== 0) return null;
657 const parts = stdout.trim().split(/\s+/);
658 if (parts.length !== 2) return null;
659 const behind = Number.parseInt(parts[0]!, 10);
660 const ahead = Number.parseInt(parts[1]!, 10);
661 if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
662 return { ahead, behind };
663}
Addedsrc/lib/branch-age.ts+363−0View fileUnifiedSplit
1/**
2 * Block J27 — Branch staleness / age report. Pure rollup helpers.
3 *
4 * Given a set of branches with tip-commit metadata + ahead/behind counts
5 * vs the default branch, produces a per-branch view with age classification,
6 * bucket counts, and summary statistics. IO-free so the route can
7 * orchestrate git subprocess calls and feed the pre-fetched rows here.
8 */
9
10export const DAY_MS = 24 * 60 * 60 * 1000;
11
12/** Canonical thresholds (in days) exposed to the UI select. */
13export const VALID_THRESHOLDS = [0, 30, 60, 90, 180] as const;
14export type Threshold = (typeof VALID_THRESHOLDS)[number];
15
16export const DEFAULT_THRESHOLD: Threshold = 0;
17
18export type BranchSort =
19 | "age-desc"
20 | "age-asc"
21 | "name"
22 | "ahead-desc"
23 | "behind-desc";
24
25export const VALID_SORTS: readonly BranchSort[] = [
26 "age-desc",
27 "age-asc",
28 "name",
29 "ahead-desc",
30 "behind-desc",
31];
32
33export const DEFAULT_SORT: BranchSort = "age-desc";
34
35/** Raw input row: tip commit + ahead/behind relative to default. */
36export interface BranchInputRow {
37 name: string;
38 tipSha: string;
39 tipDate: Date | string | null;
40 tipAuthor: string | null;
41 tipMessage: string | null;
42 /** Commits on this branch not on default. */
43 ahead: number;
44 /** Commits on default not on this branch. */
45 behind: number;
46 /** True when this branch is the repo default. */
47 isDefault: boolean;
48}
49
50export type BranchAgeCategory = "fresh" | "aging" | "stale" | "abandoned";
51
52export interface BranchReportRow {
53 name: string;
54 tipSha: string;
55 tipDate: Date | null;
56 tipAuthor: string | null;
57 tipMessage: string | null;
58 ahead: number;
59 behind: number;
60 isDefault: boolean;
61 daysOld: number | null;
62 category: BranchAgeCategory;
63 merged: boolean;
64}
65
66export interface BranchBuckets {
67 fresh: number;
68 aging: number;
69 stale: number;
70 abandoned: number;
71}
72
73export interface BranchSummary {
74 total: number;
75 /** Excludes the default branch from the count. */
76 nonDefault: number;
77 merged: number;
78 unmerged: number;
79 withoutTip: number;
80 oldestName: string | null;
81 oldestDaysOld: number | null;
82 averageAgeDays: number | null;
83 medianAgeDays: number | null;
84}
85
86export interface BranchReport {
87 now: number;
88 threshold: Threshold;
89 sort: BranchSort;
90 defaultBranch: string | null;
91 rows: BranchReportRow[];
92 filtered: BranchReportRow[];
93 buckets: BranchBuckets;
94 summary: BranchSummary;
95}
96
97/** Accept `number | string | null | undefined` on the query; coerce to allow-listed Threshold. */
98export function parseThreshold(raw: unknown): Threshold {
99 if (raw === undefined || raw === null) return DEFAULT_THRESHOLD;
100 const s = String(raw).trim();
101 if (s === "") return DEFAULT_THRESHOLD;
102 const n = Number.parseInt(s, 10);
103 if (!Number.isFinite(n)) return DEFAULT_THRESHOLD;
104 if ((VALID_THRESHOLDS as readonly number[]).includes(n)) return n as Threshold;
105 return DEFAULT_THRESHOLD;
106}
107
108export function parseSort(raw: unknown): BranchSort {
109 if (typeof raw !== "string") return DEFAULT_SORT;
110 const t = raw.trim() as BranchSort;
111 return VALID_SORTS.includes(t) ? t : DEFAULT_SORT;
112}
113
114function toDate(v: Date | string | null | undefined): Date | null {
115 if (v === null || v === undefined) return null;
116 if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
117 if (typeof v !== "string") return null;
118 const d = new Date(v);
119 return Number.isNaN(d.getTime()) ? null : d;
120}
121
122/** How old in whole days. `null` when `tipDate` is missing/unparseable. */
123export function computeDaysOld(
124 tipDate: Date | string | null | undefined,
125 now: number
126): number | null {
127 const d = toDate(tipDate);
128 if (!d) return null;
129 const ms = now - d.getTime();
130 if (ms < 0) return 0;
131 return Math.floor(ms / DAY_MS);
132}
133
134/**
135 * Four categories:
136 * <30 days → fresh
137 * 30–59 → aging
138 * 60–89 → stale
139 * ≥90 → abandoned
140 *
141 * Branches with no tipDate → `abandoned` (we can't verify they're alive).
142 */
143export function classifyBranchAge(
144 daysOld: number | null
145): BranchAgeCategory {
146 if (daysOld === null) return "abandoned";
147 if (daysOld < 30) return "fresh";
148 if (daysOld < 60) return "aging";
149 if (daysOld < 90) return "stale";
150 return "abandoned";
151}
152
153export function computeBranchRow(
154 input: BranchInputRow,
155 now: number
156): BranchReportRow {
157 const daysOld = computeDaysOld(input.tipDate, now);
158 return {
159 name: input.name,
160 tipSha: input.tipSha,
161 tipDate: toDate(input.tipDate),
162 tipAuthor: input.tipAuthor,
163 tipMessage: input.tipMessage,
164 ahead: Math.max(0, input.ahead | 0),
165 behind: Math.max(0, input.behind | 0),
166 isDefault: input.isDefault,
167 daysOld,
168 category: classifyBranchAge(daysOld),
169 // "merged" means no commits ahead of default AND not the default itself.
170 merged: !input.isDefault && (input.ahead | 0) === 0,
171 };
172}
173
174export function bucketBranches(rows: readonly BranchReportRow[]): BranchBuckets {
175 const out: BranchBuckets = { fresh: 0, aging: 0, stale: 0, abandoned: 0 };
176 for (const r of rows) {
177 if (r.isDefault) continue; // default branch never enters the buckets
178 out[r.category]++;
179 }
180 return out;
181}
182
183export function filterByThreshold(
184 rows: readonly BranchReportRow[],
185 threshold: Threshold
186): BranchReportRow[] {
187 if (threshold === 0) return rows.slice();
188 return rows.filter(
189 (r) => !r.isDefault && r.daysOld !== null && r.daysOld >= threshold
190 );
191}
192
193function percentile(sorted: readonly number[], p: number): number | null {
194 if (sorted.length === 0) return null;
195 if (sorted.length === 1) return sorted[0]!;
196 const rank = (p / 100) * (sorted.length - 1);
197 const lo = Math.floor(rank);
198 const hi = Math.ceil(rank);
199 if (lo === hi) return sorted[lo]!;
200 const w = rank - lo;
201 return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * w;
202}
203
204export function summariseBranches(
205 rows: readonly BranchReportRow[]
206): BranchSummary {
207 const nonDefault = rows.filter((r) => !r.isDefault);
208 const withAge = nonDefault
209 .filter((r): r is BranchReportRow & { daysOld: number } => r.daysOld !== null);
210 const sortedAges = withAge.map((r) => r.daysOld).sort((a, b) => a - b);
211 const sum = sortedAges.reduce((a, b) => a + b, 0);
212 const avg = sortedAges.length > 0 ? Math.round(sum / sortedAges.length) : null;
213 const med = percentile(sortedAges, 50);
214 let oldestName: string | null = null;
215 let oldestDays: number | null = null;
216 for (const r of withAge) {
217 if (oldestDays === null || r.daysOld > oldestDays) {
218 oldestDays = r.daysOld;
219 oldestName = r.name;
220 }
221 }
222 return {
223 total: rows.length,
224 nonDefault: nonDefault.length,
225 merged: nonDefault.filter((r) => r.merged).length,
226 unmerged: nonDefault.filter((r) => !r.merged).length,
227 withoutTip: nonDefault.filter((r) => r.daysOld === null).length,
228 oldestName,
229 oldestDaysOld: oldestDays,
230 averageAgeDays: avg,
231 medianAgeDays: med === null ? null : Math.round(med),
232 };
233}
234
235function cmpString(a: string, b: string): number {
236 if (a < b) return -1;
237 if (a > b) return 1;
238 return 0;
239}
240
241export function sortBranchRows(
242 rows: readonly BranchReportRow[],
243 sort: BranchSort
244): BranchReportRow[] {
245 const out = rows.slice();
246 switch (sort) {
247 case "name":
248 out.sort((a, b) => cmpString(a.name, b.name));
249 break;
250 case "age-asc":
251 out.sort((a, b) => {
252 // null daysOld sinks to the bottom
253 const av = a.daysOld ?? Number.POSITIVE_INFINITY;
254 const bv = b.daysOld ?? Number.POSITIVE_INFINITY;
255 if (av !== bv) return av - bv;
256 return cmpString(a.name, b.name);
257 });
258 break;
259 case "age-desc":
260 out.sort((a, b) => {
261 const av = a.daysOld ?? -1;
262 const bv = b.daysOld ?? -1;
263 if (av !== bv) return bv - av;
264 return cmpString(a.name, b.name);
265 });
266 break;
267 case "ahead-desc":
268 out.sort((a, b) => {
269 if (a.ahead !== b.ahead) return b.ahead - a.ahead;
270 return cmpString(a.name, b.name);
271 });
272 break;
273 case "behind-desc":
274 out.sort((a, b) => {
275 if (a.behind !== b.behind) return b.behind - a.behind;
276 return cmpString(a.name, b.name);
277 });
278 break;
279 }
280 return out;
281}
282
283export interface BuildReportOptions {
284 branches: readonly BranchInputRow[];
285 defaultBranch: string | null;
286 threshold?: Threshold;
287 sort?: BranchSort;
288 now?: number;
289}
290
291/** One-shot report builder used by the route. */
292export function buildBranchReport(opts: BuildReportOptions): BranchReport {
293 const now = opts.now ?? Date.now();
294 const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
295 const sort = opts.sort ?? DEFAULT_SORT;
296 const rows = opts.branches.map((b) => computeBranchRow(b, now));
297 const sorted = sortBranchRows(rows, sort);
298 const filtered = filterByThreshold(sorted, threshold);
299 return {
300 now,
301 threshold,
302 sort,
303 defaultBranch: opts.defaultBranch,
304 rows: sorted,
305 filtered,
306 buckets: bucketBranches(rows),
307 summary: summariseBranches(rows),
308 };
309}
310
311/** Short human label for category pills. */
312export function categoryLabel(c: BranchAgeCategory): string {
313 switch (c) {
314 case "fresh":
315 return "Fresh";
316 case "aging":
317 return "Aging";
318 case "stale":
319 return "Stale";
320 case "abandoned":
321 return "Abandoned";
322 }
323}
324
325export function thresholdLabel(t: Threshold): string {
326 return t === 0 ? "All branches" : `≥ ${t} days old`;
327}
328
329export function sortLabel(s: BranchSort): string {
330 switch (s) {
331 case "age-desc":
332 return "Oldest first";
333 case "age-asc":
334 return "Newest first";
335 case "name":
336 return "Name A–Z";
337 case "ahead-desc":
338 return "Most ahead";
339 case "behind-desc":
340 return "Most behind";
341 }
342}
343
344export const __internal = {
345 DAY_MS,
346 VALID_THRESHOLDS,
347 VALID_SORTS,
348 DEFAULT_THRESHOLD,
349 DEFAULT_SORT,
350 parseThreshold,
351 parseSort,
352 computeDaysOld,
353 classifyBranchAge,
354 computeBranchRow,
355 bucketBranches,
356 filterByThreshold,
357 summariseBranches,
358 sortBranchRows,
359 buildBranchReport,
360 categoryLabel,
361 thresholdLabel,
362 sortLabel,
363};
Addedsrc/routes/branch-age.tsx+359−0View fileUnifiedSplit
1/**
2 * Block J27 — Branch staleness / age report.
3 *
4 * GET /:owner/:repo/branches/age[?threshold=0|30|60|90|180][&sort=…]
5 *
6 * softAuth, read-only. Walks every branch in the repo, fetches the tip commit
7 * via `getCommit`, computes ahead/behind vs the default branch via
8 * `aheadBehind`, and renders a per-branch table + buckets + summary KPIs.
9 * Fail-soft: git errors degrade to empty report → page still renders 200.
10 */
11
12import { Hono } from "hono";
13import { eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import {
21 listBranches,
22 getDefaultBranch,
23 getCommit,
24 aheadBehind,
25} from "../git/repository";
26import {
27 buildBranchReport,
28 parseThreshold,
29 parseSort,
30 thresholdLabel,
31 sortLabel,
32 categoryLabel,
33 VALID_THRESHOLDS,
34 VALID_SORTS,
35 type BranchInputRow,
36 type BranchAgeCategory,
37} from "../lib/branch-age";
38
39const branchAgeRoutes = new Hono<AuthEnv>();
40
41branchAgeRoutes.use("*", softAuth);
42
43async function resolveRepo(ownerName: string, repoName: string) {
44 try {
45 const [owner] = await db
46 .select()
47 .from(users)
48 .where(eq(users.username, ownerName))
49 .limit(1);
50 if (!owner) return null;
51 const [repo] = await db
52 .select()
53 .from(repositories)
54 .where(
55 and(
56 eq(repositories.ownerId, owner.id),
57 eq(repositories.name, repoName)
58 )
59 )
60 .limit(1);
61 if (!repo) return null;
62 return { owner, repo };
63 } catch {
64 return null;
65 }
66}
67
68function ageColor(c: BranchAgeCategory): string {
69 switch (c) {
70 case "fresh":
71 return "var(--green, #3fb950)";
72 case "aging":
73 return "var(--yellow, #d29922)";
74 case "stale":
75 return "var(--orange, #db6d28)";
76 case "abandoned":
77 return "var(--red, #f85149)";
78 }
79}
80
81function formatDaysOld(days: number | null): string {
82 if (days === null) return "\u2014";
83 if (days === 0) return "today";
84 if (days === 1) return "1 day";
85 if (days < 30) return `${days} days`;
86 const months = Math.floor(days / 30);
87 if (months < 12) return `${months} mo`;
88 const years = (days / 365).toFixed(1);
89 return `${years} yr`;
90}
91
92branchAgeRoutes.get("/:owner/:repo/branches/age", async (c) => {
93 const { owner: ownerName, repo: repoName } = c.req.param();
94 const user = c.get("user");
95 const threshold = parseThreshold(c.req.query("threshold"));
96 const sort = parseSort(c.req.query("sort"));
97
98 const resolved = await resolveRepo(ownerName, repoName);
99 if (!resolved) {
100 return c.html(
101 <Layout title="Not Found" user={user}>
102 <div class="empty-state">
103 <h2>Repository not found</h2>
104 </div>
105 </Layout>,
106 404
107 );
108 }
109
110 // Private-repo visibility: only the owner can see the metric.
111 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
112 return c.html(
113 <Layout title="Not Found" user={user}>
114 <div class="empty-state">
115 <h2>Repository not found</h2>
116 </div>
117 </Layout>,
118 404
119 );
120 }
121
122 let branches: string[] = [];
123 let defaultBranch: string | null = null;
124 try {
125 [branches, defaultBranch] = await Promise.all([
126 listBranches(ownerName, repoName),
127 getDefaultBranch(ownerName, repoName),
128 ]);
129 } catch {
130 branches = [];
131 }
132
133 const inputs: BranchInputRow[] = [];
134 for (const b of branches) {
135 try {
136 const commit = await getCommit(ownerName, repoName, b);
137 let ahead = 0;
138 let behind = 0;
139 const isDefault = b === defaultBranch;
140 if (!isDefault && defaultBranch) {
141 const ab = await aheadBehind(ownerName, repoName, defaultBranch, b);
142 if (ab) {
143 ahead = ab.ahead;
144 behind = ab.behind;
145 }
146 }
147 inputs.push({
148 name: b,
149 tipSha: commit?.sha ?? "",
150 tipDate: commit?.date ?? null,
151 tipAuthor: commit?.author ?? null,
152 tipMessage: commit?.message ?? null,
153 ahead,
154 behind,
155 isDefault,
156 });
157 } catch {
158 inputs.push({
159 name: b,
160 tipSha: "",
161 tipDate: null,
162 tipAuthor: null,
163 tipMessage: null,
164 ahead: 0,
165 behind: 0,
166 isDefault: b === defaultBranch,
167 });
168 }
169 }
170
171 const report = buildBranchReport({
172 branches: inputs,
173 defaultBranch,
174 threshold,
175 sort,
176 });
177
178 const kpi = (label: string, value: string) => (
179 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
180 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
181 {label}
182 </div>
183 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
184 {value}
185 </div>
186 </div>
187 );
188
189 return c.html(
190 <Layout title={`Branch age — ${ownerName}/${repoName}`} user={user}>
191 <RepoHeader owner={ownerName} repo={repoName} />
192 <div style="max-width: 1000px">
193 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
194 <h2 style="margin: 0">Branch staleness</h2>
195 <form
196 method="GET"
197 action={`/${ownerName}/${repoName}/branches/age`}
198 style="display: flex; gap: 8px; align-items: center"
199 >
200 <label for="threshold" style="font-size: 12px; color: var(--text-muted)">
201 Threshold:
202 </label>
203 <select
204 id="threshold"
205 name="threshold"
206 onchange="this.form.submit()"
207 style="padding: 4px 8px; font-size: 12px"
208 >
209 {VALID_THRESHOLDS.map((t) => (
210 <option value={String(t)} selected={t === threshold}>
211 {thresholdLabel(t)}
212 </option>
213 ))}
214 </select>
215 <label for="sort" style="font-size: 12px; color: var(--text-muted)">
216 Sort:
217 </label>
218 <select
219 id="sort"
220 name="sort"
221 onchange="this.form.submit()"
222 style="padding: 4px 8px; font-size: 12px"
223 >
224 {VALID_SORTS.map((s) => (
225 <option value={s} selected={s === sort}>
226 {sortLabel(s)}
227 </option>
228 ))}
229 </select>
230 </form>
231 </div>
232
233 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
234 Branch age = days since the tip commit. "Merged" means no commits ahead
235 of <code>{report.defaultBranch ?? "the default branch"}</code>. Default
236 branch is excluded from bucket counts.
237 </p>
238
239 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
240 {kpi("Total branches", String(report.summary.total))}
241 {kpi("Non-default", String(report.summary.nonDefault))}
242 {kpi("Merged", String(report.summary.merged))}
243 {kpi("Unmerged", String(report.summary.unmerged))}
244 {kpi(
245 "Median age",
246 report.summary.medianAgeDays === null
247 ? "\u2014"
248 : formatDaysOld(report.summary.medianAgeDays)
249 )}
250 {kpi(
251 "Oldest",
252 report.summary.oldestDaysOld === null
253 ? "\u2014"
254 : formatDaysOld(report.summary.oldestDaysOld)
255 )}
256 </div>
257
258 <h3 style="margin-bottom: 10px">Distribution</h3>
259 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
260 {(["fresh", "aging", "stale", "abandoned"] as BranchAgeCategory[]).map(
261 (cat) => (
262 <div
263 style={`border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center; border-left: 4px solid ${ageColor(cat)}`}
264 >
265 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
266 {categoryLabel(cat)}
267 </div>
268 <div style="font-size: 18px; font-weight: 600">
269 {report.buckets[cat]}
270 </div>
271 </div>
272 )
273 )}
274 </div>
275
276 <h3 style="margin-bottom: 10px">
277 Branches ({report.filtered.length}
278 {report.filtered.length !== report.rows.length
279 ? ` of ${report.rows.length}`
280 : ""}
281 )
282 </h3>
283 {report.filtered.length === 0 ? (
284 <div class="empty-state">
285 <p>No branches match this threshold.</p>
286 </div>
287 ) : (
288 <table style="width: 100%; border-collapse: collapse">
289 <thead>
290 <tr>
291 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
292 Branch
293 </th>
294 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
295 Ahead
296 </th>
297 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
298 Behind
299 </th>
300 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 160px">
301 Last commit
302 </th>
303 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
304 Status
305 </th>
306 </tr>
307 </thead>
308 <tbody>
309 {report.filtered.map((r) => (
310 <tr>
311 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
312 <a
313 href={`/${ownerName}/${repoName}/tree/${encodeURIComponent(r.name)}`}
314 style={`font-family: var(--font-mono); font-weight: ${r.isDefault ? 600 : 400}`}
315 >
316 {r.name}
317 </a>
318 {r.isDefault && (
319 <span style="margin-left: 8px; font-size: 10px; padding: 2px 6px; background: var(--accent); color: white; border-radius: 10px">
320 default
321 </span>
322 )}
323 {r.tipAuthor && (
324 <span style="margin-left: 8px; font-size: 11px; color: var(--text-muted)">
325 by {r.tipAuthor}
326 </span>
327 )}
328 </td>
329 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
330 {r.ahead}
331 </td>
332 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
333 {r.behind}
334 </td>
335 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px">
336 <span style={`color: ${ageColor(r.category)}`}>
337 {formatDaysOld(r.daysOld)}
338 </span>
339 </td>
340 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-size: 11px">
341 {r.isDefault ? (
342 <span style="color: var(--text-muted)">default</span>
343 ) : r.merged ? (
344 <span style="color: var(--green, #3fb950)">merged</span>
345 ) : (
346 <span style="color: var(--text-muted)">open</span>
347 )}
348 </td>
349 </tr>
350 ))}
351 </tbody>
352 </table>
353 )}
354 </div>
355 </Layout>
356 );
357});
358
359export default branchAgeRoutes;
Modifiedsrc/routes/repo-settings.tsx+4−1View fileUnifiedSplit
9595 ))
9696 )}
9797 </select>
98 <div style="margin-top: 6px; font-size: 12px">
98 <div style="margin-top: 6px; font-size: 12px; display: flex; gap: 12px">
9999 <a href={`/${ownerName}/${repoName}/settings/branches`}>
100100 Manage branches (rename, …)
101101 </a>
102 <a href={`/${ownerName}/${repoName}/branches/age`}>
103 Branch staleness report →
104 </a>
102105 </div>
103106 </div>
104107 <div class="form-group">
105108