Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

pr-lead-time.test.tsBlame382 lines · 1 contributor
8c5346cClaude1/**
2 * Block J29 — PR lead-time metric. Pure rollup tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
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
21const HOUR = 60 * 60 * 1000;
22const DAY = 24 * HOUR;
23
24describe("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
42describe("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
85describe("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
178describe("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
257describe("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
285describe("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
354describe("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
369describe("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});