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.
| c02a55b | 1 | /** |
| 2 | * Block J27 — Branch staleness / age report. Pure rollup tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 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 | ||
| 30 | const NOW = new Date("2025-04-15T00:00:00Z").getTime(); | |
| 31 | ||
| 32 | function 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 | ||
| 49 | describe("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 | ||
| 69 | describe("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 | ||
| 83 | describe("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 | ||
| 107 | describe("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 | ||
| 123 | describe("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 | ||
| 165 | describe("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 | ||
| 188 | describe("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 | ||
| 216 | describe("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 | ||
| 258 | describe("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 | ||
| 324 | describe("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 | ||
| 423 | describe("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 | ||
| 444 | describe("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 | ||
| 460 | describe("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 | }); |