Blame · Line-by-line history
pr-lead-time.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.
| 8c5346c | 1 | /** |
| 2 | * Block J29 — PR lead-time metric. Pure rollup tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 7 | DEFAULT_WINDOW_DAYS, | |
| 8 | VALID_WINDOWS, | |
| 9 | parseWindow, | |
| 10 | formatDuration, | |
| 11 | computeLeadTime, | |
| 12 | computePrStats, | |
| 13 | summariseLeadTimes, | |
| 14 | bucketLeadTimes, | |
| 15 | buildLeadTimeReport, | |
| 16 | __internal, | |
| 17 | type PrLeadTimeInput, | |
| 18 | type PrLeadTimeStat, | |
| 19 | } from "../lib/pr-lead-time"; | |
| 20 | ||
| 21 | const HOUR = 60 * 60 * 1000; | |
| 22 | const DAY = 24 * HOUR; | |
| 23 | ||
| 24 | describe("pr-lead-time — re-exports from J25", () => { | |
| 25 | it("parseWindow accepts the canonical windows", () => { | |
| 26 | expect(parseWindow("0")).toBe(0); | |
| 27 | expect(parseWindow("7")).toBe(7); | |
| 28 | expect(parseWindow("30")).toBe(30); | |
| 29 | expect(parseWindow(undefined)).toBe(DEFAULT_WINDOW_DAYS); | |
| 30 | expect(parseWindow("garbage")).toBe(DEFAULT_WINDOW_DAYS); | |
| 31 | }); | |
| 32 | it("formatDuration still formats", () => { | |
| 33 | expect(formatDuration(null)).toBe("\u2014"); | |
| 34 | expect(formatDuration(HOUR)).toBe("1h"); | |
| 35 | }); | |
| 36 | it("VALID_WINDOWS includes the default", () => { | |
| 37 | expect(VALID_WINDOWS).toContain(DEFAULT_WINDOW_DAYS); | |
| 38 | expect(VALID_WINDOWS).toContain(0); | |
| 39 | }); | |
| 40 | }); | |
| 41 | ||
| 42 | describe("pr-lead-time — computeLeadTime", () => { | |
| 43 | it("returns null when not merged", () => { | |
| 44 | expect( | |
| 45 | computeLeadTime({ createdAt: new Date(), mergedAt: null }) | |
| 46 | ).toBeNull(); | |
| 47 | expect( | |
| 48 | computeLeadTime({ createdAt: new Date(), mergedAt: undefined }) | |
| 49 | ).toBeNull(); | |
| 50 | }); | |
| 51 | it("returns merged - created", () => { | |
| 52 | expect( | |
| 53 | computeLeadTime({ | |
| 54 | createdAt: new Date("2025-01-01T00:00:00Z"), | |
| 55 | mergedAt: new Date("2025-01-01T01:00:00Z"), | |
| 56 | }) | |
| 57 | ).toBe(HOUR); | |
| 58 | }); | |
| 59 | it("clamps negative deltas to 0", () => { | |
| 60 | expect( | |
| 61 | computeLeadTime({ | |
| 62 | createdAt: new Date("2025-01-02T00:00:00Z"), | |
| 63 | mergedAt: new Date("2025-01-01T00:00:00Z"), | |
| 64 | }) | |
| 65 | ).toBe(0); | |
| 66 | }); | |
| 67 | it("accepts ISO strings", () => { | |
| 68 | expect( | |
| 69 | computeLeadTime({ | |
| 70 | createdAt: "2025-01-01T00:00:00Z", | |
| 71 | mergedAt: "2025-01-01T02:30:00Z", | |
| 72 | }) | |
| 73 | ).toBe(2 * HOUR + 30 * 60 * 1000); | |
| 74 | }); | |
| 75 | it("returns null on unparseable input", () => { | |
| 76 | expect( | |
| 77 | computeLeadTime({ createdAt: "not-a-date", mergedAt: new Date() }) | |
| 78 | ).toBeNull(); | |
| 79 | expect( | |
| 80 | computeLeadTime({ createdAt: new Date(), mergedAt: "also-bad" }) | |
| 81 | ).toBeNull(); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("pr-lead-time — computePrStats + window filter", () => { | |
| 86 | const now = new Date("2025-04-01T00:00:00Z").getTime(); | |
| 87 | const prs: PrLeadTimeInput[] = [ | |
| 88 | { | |
| 89 | id: "a", | |
| 90 | number: 1, | |
| 91 | title: "recent merged", | |
| 92 | state: "merged", | |
| 93 | createdAt: new Date(now - 2 * DAY), | |
| 94 | mergedAt: new Date(now - 2 * DAY + 3 * HOUR), | |
| 95 | }, | |
| 96 | { | |
| 97 | id: "b", | |
| 98 | number: 2, | |
| 99 | title: "old merged", | |
| 100 | state: "merged", | |
| 101 | createdAt: new Date(now - 60 * DAY), | |
| 102 | mergedAt: new Date(now - 60 * DAY + DAY), | |
| 103 | }, | |
| 104 | { | |
| 105 | id: "c", | |
| 106 | number: 3, | |
| 107 | title: "open", | |
| 108 | state: "open", | |
| 109 | createdAt: new Date(now - 3 * DAY), | |
| 110 | }, | |
| 111 | { | |
| 112 | id: "d", | |
| 113 | number: 4, | |
| 114 | title: "draft", | |
| 115 | state: "open", | |
| 116 | isDraft: true, | |
| 117 | createdAt: new Date(now - 5 * DAY), | |
| 118 | }, | |
| 119 | { | |
| 120 | id: "e", | |
| 121 | number: 5, | |
| 122 | title: "closed", | |
| 123 | state: "closed", | |
| 124 | createdAt: new Date(now - 10 * DAY), | |
| 125 | }, | |
| 126 | { | |
| 127 | id: "f", | |
| 128 | number: 6, | |
| 129 | title: "bogus", | |
| 130 | state: "open", | |
| 131 | createdAt: "not-a-date", | |
| 132 | }, | |
| 133 | ]; | |
| 134 | ||
| 135 | it("filters to the window (30 days)", () => { | |
| 136 | const out = computePrStats(prs, 30, now); | |
| 137 | expect(out.map((s) => s.id).sort()).toEqual(["a", "c", "d", "e"]); | |
| 138 | }); | |
| 139 | ||
| 140 | it("window=0 keeps everything (except unparseable)", () => { | |
| 141 | const out = computePrStats(prs, 0, now); | |
| 142 | expect(out.map((s) => s.id).sort()).toEqual(["a", "b", "c", "d", "e"]); | |
| 143 | }); | |
| 144 | ||
| 145 | it("populates leadMs only for merged", () => { | |
| 146 | const out = computePrStats(prs, 0, now); | |
| 147 | const a = out.find((s) => s.id === "a")!; | |
| 148 | const c = out.find((s) => s.id === "c")!; | |
| 149 | expect(a.leadMs).toBe(3 * HOUR); | |
| 150 | expect(c.leadMs).toBeNull(); | |
| 151 | }); | |
| 152 | ||
| 153 | it("populates inFlightMs only for open non-merged", () => { | |
| 154 | const out = computePrStats(prs, 0, now); | |
| 155 | const c = out.find((s) => s.id === "c")!; | |
| 156 | const e = out.find((s) => s.id === "e")!; | |
| 157 | const a = out.find((s) => s.id === "a")!; | |
| 158 | expect(c.inFlightMs).toBe(3 * DAY); | |
| 159 | expect(e.inFlightMs).toBeNull(); | |
| 160 | expect(a.inFlightMs).toBeNull(); | |
| 161 | }); | |
| 162 | ||
| 163 | it("anchors merged window on mergedAt", () => { | |
| 164 | // PR merged 6 days ago, created 60 days ago → keep in a 30d window | |
| 165 | const far: PrLeadTimeInput = { | |
| 166 | id: "z", | |
| 167 | number: 99, | |
| 168 | title: "old created, recent merge", | |
| 169 | state: "merged", | |
| 170 | createdAt: new Date(now - 60 * DAY), | |
| 171 | mergedAt: new Date(now - 6 * DAY), | |
| 172 | }; | |
| 173 | const out = computePrStats([far], 30, now); | |
| 174 | expect(out).toHaveLength(1); | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | describe("pr-lead-time — summariseLeadTimes", () => { | |
| 179 | it("zero stats", () => { | |
| 180 | const s = summariseLeadTimes([]); | |
| 181 | expect(s.total).toBe(0); | |
| 182 | expect(s.merged).toBe(0); | |
| 183 | expect(s.medianMs).toBeNull(); | |
| 184 | expect(s.p90Ms).toBeNull(); | |
| 185 | expect(s.fastestMs).toBeNull(); | |
| 186 | expect(s.slowestMs).toBeNull(); | |
| 187 | }); | |
| 188 | ||
| 189 | it("single merged PR", () => { | |
| 190 | const stats: PrLeadTimeStat[] = [ | |
| 191 | { | |
| 192 | id: "a", | |
| 193 | number: 1, | |
| 194 | title: "t", | |
| 195 | state: "merged", | |
| 196 | isDraft: false, | |
| 197 | createdAt: 0, | |
| 198 | mergedAt: HOUR, | |
| 199 | leadMs: HOUR, | |
| 200 | inFlightMs: null, | |
| 201 | }, | |
| 202 | ]; | |
| 203 | const s = summariseLeadTimes(stats); | |
| 204 | expect(s.merged).toBe(1); | |
| 205 | expect(s.medianMs).toBe(HOUR); | |
| 206 | expect(s.p90Ms).toBe(HOUR); | |
| 207 | }); | |
| 208 | ||
| 209 | it("classifies open vs draft vs closed-unmerged separately", () => { | |
| 210 | const mk = ( | |
| 211 | over: Partial<PrLeadTimeStat> & { id: string } | |
| 212 | ): PrLeadTimeStat => ({ | |
| 213 | id: over.id, | |
| 214 | number: 1, | |
| 215 | title: "x", | |
| 216 | state: "open", | |
| 217 | isDraft: false, | |
| 218 | createdAt: 0, | |
| 219 | mergedAt: null, | |
| 220 | leadMs: null, | |
| 221 | inFlightMs: 1, | |
| 222 | ...over, | |
| 223 | }); | |
| 224 | const stats = [ | |
| 225 | mk({ id: "openA", state: "open", isDraft: false }), | |
| 226 | mk({ id: "draftA", state: "open", isDraft: true }), | |
| 227 | mk({ id: "closedA", state: "closed", isDraft: false, inFlightMs: null }), | |
| 228 | ]; | |
| 229 | const s = summariseLeadTimes(stats); | |
| 230 | expect(s.openNonDraft).toBe(1); | |
| 231 | expect(s.openDraft).toBe(1); | |
| 232 | expect(s.closedUnmerged).toBe(1); | |
| 233 | expect(s.merged).toBe(0); | |
| 234 | }); | |
| 235 | ||
| 236 | it("inclusive-method median + p90 over 1..10h", () => { | |
| 237 | const stats: PrLeadTimeStat[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => ({ | |
| 238 | id: String(n), | |
| 239 | number: n, | |
| 240 | title: "t", | |
| 241 | state: "merged", | |
| 242 | isDraft: false, | |
| 243 | createdAt: 0, | |
| 244 | mergedAt: n * HOUR, | |
| 245 | leadMs: n * HOUR, | |
| 246 | inFlightMs: null, | |
| 247 | })); | |
| 248 | const s = summariseLeadTimes(stats); | |
| 249 | expect(s.medianMs).toBe(Math.round(5.5 * HOUR)); | |
| 250 | expect(s.meanMs).toBe(Math.round(5.5 * HOUR)); | |
| 251 | expect(s.p90Ms).toBe(Math.round(9.1 * HOUR)); | |
| 252 | expect(s.fastestMs).toBe(HOUR); | |
| 253 | expect(s.slowestMs).toBe(10 * HOUR); | |
| 254 | }); | |
| 255 | }); | |
| 256 | ||
| 257 | describe("pr-lead-time — bucketLeadTimes", () => { | |
| 258 | it("distributes into four buckets", () => { | |
| 259 | const mk = (leadMs: number | null, id: string): PrLeadTimeStat => ({ | |
| 260 | id, | |
| 261 | number: 1, | |
| 262 | title: "t", | |
| 263 | state: leadMs === null ? "open" : "merged", | |
| 264 | isDraft: false, | |
| 265 | createdAt: 0, | |
| 266 | mergedAt: leadMs, | |
| 267 | leadMs, | |
| 268 | inFlightMs: null, | |
| 269 | }); | |
| 270 | const b = bucketLeadTimes([ | |
| 271 | mk(30 * 60 * 1000, "a"), | |
| 272 | mk(HOUR, "b"), // ≤ 1h (boundary) | |
| 273 | mk(2 * HOUR, "c"), | |
| 274 | mk(25 * HOUR, "d"), | |
| 275 | mk(8 * DAY, "e"), | |
| 276 | mk(null, "n"), | |
| 277 | ]); | |
| 278 | expect(b.within1h).toBe(2); | |
| 279 | expect(b.within1d).toBe(1); | |
| 280 | expect(b.within1w).toBe(1); | |
| 281 | expect(b.over1w).toBe(1); | |
| 282 | }); | |
| 283 | }); | |
| 284 | ||
| 285 | describe("pr-lead-time — buildLeadTimeReport", () => { | |
| 286 | const now = new Date("2025-04-01T00:00:00Z").getTime(); | |
| 287 | ||
| 288 | it("builds a full report", () => { | |
| 289 | const prs: PrLeadTimeInput[] = [ | |
| 290 | { | |
| 291 | id: "m", | |
| 292 | number: 1, | |
| 293 | title: "merged", | |
| 294 | state: "merged", | |
| 295 | createdAt: new Date(now - DAY), | |
| 296 | mergedAt: new Date(now - 12 * HOUR), | |
| 297 | }, | |
| 298 | { | |
| 299 | id: "o", | |
| 300 | number: 2, | |
| 301 | title: "open", | |
| 302 | state: "open", | |
| 303 | createdAt: new Date(now - 5 * DAY), | |
| 304 | }, | |
| 305 | { | |
| 306 | id: "d", | |
| 307 | number: 3, | |
| 308 | title: "draft", | |
| 309 | state: "open", | |
| 310 | isDraft: true, | |
| 311 | createdAt: new Date(now - 7 * DAY), | |
| 312 | }, | |
| 313 | ]; | |
| 314 | const r = buildLeadTimeReport({ prs, windowDays: 30, now }); | |
| 315 | expect(r.windowDays).toBe(30); | |
| 316 | expect(r.now).toBe(now); | |
| 317 | expect(r.perPr).toHaveLength(3); | |
| 318 | expect(r.summary.merged).toBe(1); | |
| 319 | expect(r.summary.openNonDraft).toBe(1); | |
| 320 | expect(r.summary.openDraft).toBe(1); | |
| 321 | expect(r.oldestOpenIds).toEqual(["o"]); // drafts excluded | |
| 322 | }); | |
| 323 | ||
| 324 | it("sorts oldestOpenIds oldest-first", () => { | |
| 325 | const prs: PrLeadTimeInput[] = [ | |
| 326 | { | |
| 327 | id: "younger", | |
| 328 | number: 1, | |
| 329 | title: "y", | |
| 330 | state: "open", | |
| 331 | createdAt: new Date(now - DAY), | |
| 332 | }, | |
| 333 | { | |
| 334 | id: "older", | |
| 335 | number: 2, | |
| 336 | title: "o", | |
| 337 | state: "open", | |
| 338 | createdAt: new Date(now - 5 * DAY), | |
| 339 | }, | |
| 340 | ]; | |
| 341 | const r = buildLeadTimeReport({ prs, windowDays: 30, now }); | |
| 342 | expect(r.oldestOpenIds).toEqual(["older", "younger"]); | |
| 343 | }); | |
| 344 | ||
| 345 | it("defaults now to Date.now when omitted", () => { | |
| 346 | const before = Date.now(); | |
| 347 | const r = buildLeadTimeReport({ prs: [], windowDays: 30 }); | |
| 348 | const after = Date.now(); | |
| 349 | expect(r.now).toBeGreaterThanOrEqual(before); | |
| 350 | expect(r.now).toBeLessThanOrEqual(after); | |
| 351 | }); | |
| 352 | }); | |
| 353 | ||
| 354 | describe("pr-lead-time — routes", () => { | |
| 355 | it("GET /:o/:r/insights/lead-time returns 2xx or 404 (never 500)", async () => { | |
| 356 | const { default: app } = await import("../app"); | |
| 357 | const res = await app.request("/alice/repo/insights/lead-time"); | |
| 358 | expect([200, 404]).toContain(res.status); | |
| 359 | }); | |
| 360 | it("ignores bogus window values", async () => { | |
| 361 | const { default: app } = await import("../app"); | |
| 362 | const res = await app.request( | |
| 363 | "/alice/repo/insights/lead-time?window=garbage" | |
| 364 | ); | |
| 365 | expect([200, 404]).toContain(res.status); | |
| 366 | }); | |
| 367 | }); | |
| 368 | ||
| 369 | describe("pr-lead-time — __internal parity", () => { | |
| 370 | it("re-exports helpers", () => { | |
| 371 | expect(__internal.parseWindow).toBe(parseWindow); | |
| 372 | expect(__internal.formatDuration).toBe(formatDuration); | |
| 373 | expect(__internal.computeLeadTime).toBe(computeLeadTime); | |
| 374 | expect(__internal.computePrStats).toBe(computePrStats); | |
| 375 | expect(__internal.summariseLeadTimes).toBe(summariseLeadTimes); | |
| 376 | expect(__internal.bucketLeadTimes).toBe(bucketLeadTimes); | |
| 377 | expect(__internal.buildLeadTimeReport).toBe(buildLeadTimeReport); | |
| 378 | expect(typeof __internal.toTime).toBe("function"); | |
| 379 | expect(__internal.DEFAULT_WINDOW_DAYS).toBe(DEFAULT_WINDOW_DAYS); | |
| 380 | expect(__internal.VALID_WINDOWS).toBe(VALID_WINDOWS); | |
| 381 | }); | |
| 382 | }); |