Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

branch-age.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

branch-age.test.tsBlame477 lines · 1 contributor
c02a55bClaude1/**
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});