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

response-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.

response-time.test.tsBlame402 lines · 1 contributor
9090df3Claude1/**
2 * Block J25 — Time-to-first-response metric. Pure rollup tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 DEFAULT_WINDOW_DAYS,
8 VALID_WINDOWS,
9 parseWindow,
10 computeTimeToFirstResponse,
11 computeIssueStats,
12 summariseResponseTimes,
13 bucketResponseTimes,
14 buildResponseReport,
15 formatDuration,
16 __internal,
17 type ResponseIssueInput,
18} from "../lib/response-time";
19
20const HOUR = 60 * 60 * 1000;
21const DAY = 24 * HOUR;
22
23describe("response-time — parseWindow", () => {
24 it("returns the default for undefined/null/empty", () => {
25 expect(parseWindow(undefined)).toBe(DEFAULT_WINDOW_DAYS);
26 expect(parseWindow(null)).toBe(DEFAULT_WINDOW_DAYS);
27 expect(parseWindow("")).toBe(DEFAULT_WINDOW_DAYS);
28 });
29 it("accepts valid windows as strings", () => {
30 expect(parseWindow("0")).toBe(0);
31 expect(parseWindow("7")).toBe(7);
32 expect(parseWindow("30")).toBe(30);
33 expect(parseWindow("90")).toBe(90);
34 expect(parseWindow("365")).toBe(365);
35 });
36 it("rejects unknown values", () => {
37 expect(parseWindow("14")).toBe(DEFAULT_WINDOW_DAYS);
38 expect(parseWindow("-5")).toBe(DEFAULT_WINDOW_DAYS);
39 expect(parseWindow("garbage")).toBe(DEFAULT_WINDOW_DAYS);
40 });
41 it("exports the canonical window list", () => {
42 expect(VALID_WINDOWS).toContain(0);
43 expect(VALID_WINDOWS).toContain(DEFAULT_WINDOW_DAYS);
44 });
45});
46
47describe("response-time — computeTimeToFirstResponse", () => {
48 const created = new Date("2025-01-01T00:00:00Z");
49
50 it("returns null when no comments", () => {
51 expect(
52 computeTimeToFirstResponse({
53 issueCreatedAt: created,
54 issueAuthorId: "author",
55 comments: [],
56 })
57 ).toBeNull();
58 });
59
60 it("ignores comments by the issue author", () => {
61 expect(
62 computeTimeToFirstResponse({
63 issueCreatedAt: created,
64 issueAuthorId: "author",
65 comments: [
66 {
67 authorId: "author",
68 createdAt: new Date("2025-01-01T01:00:00Z"),
69 },
70 ],
71 })
72 ).toBeNull();
73 });
74
75 it("returns earliest non-author comment delta", () => {
76 const ms = computeTimeToFirstResponse({
77 issueCreatedAt: created,
78 issueAuthorId: "author",
79 comments: [
80 {
81 authorId: "author",
82 createdAt: new Date("2025-01-01T00:30:00Z"),
83 },
84 {
85 authorId: "responder",
86 createdAt: new Date("2025-01-01T01:00:00Z"),
87 },
88 {
89 authorId: "other",
90 createdAt: new Date("2025-01-01T02:00:00Z"),
91 },
92 ],
93 });
94 expect(ms).toBe(HOUR);
95 });
96
97 it("handles string dates", () => {
98 const ms = computeTimeToFirstResponse({
99 issueCreatedAt: "2025-01-01T00:00:00Z",
100 issueAuthorId: "author",
101 comments: [
102 { authorId: "responder", createdAt: "2025-01-01T00:30:00Z" },
103 ],
104 });
105 expect(ms).toBe(30 * 60 * 1000);
106 });
107
108 it("clamps negative deltas to 0", () => {
109 const ms = computeTimeToFirstResponse({
110 issueCreatedAt: created,
111 issueAuthorId: "author",
112 comments: [
113 {
114 authorId: "responder",
115 createdAt: new Date("2024-12-31T23:59:00Z"),
116 },
117 ],
118 });
119 expect(ms).toBe(0);
120 });
121
122 it("skips unparseable dates", () => {
123 const ms = computeTimeToFirstResponse({
124 issueCreatedAt: created,
125 issueAuthorId: "author",
126 comments: [
127 { authorId: "responder", createdAt: "not-a-date" },
128 { authorId: "responder", createdAt: new Date("2025-01-01T01:00:00Z") },
129 ],
130 });
131 expect(ms).toBe(HOUR);
132 });
133
134 it("returns null when the issue timestamp is unparseable", () => {
135 expect(
136 computeTimeToFirstResponse({
137 issueCreatedAt: "not-a-date",
138 issueAuthorId: "author",
139 comments: [{ authorId: "r", createdAt: new Date() }],
140 })
141 ).toBeNull();
142 });
143});
144
145describe("response-time — computeIssueStats + window filter", () => {
146 const now = new Date("2025-04-01T00:00:00Z").getTime();
147 const issues: ResponseIssueInput[] = [
148 {
149 id: "a",
150 state: "open",
151 authorId: "alice",
152 createdAt: new Date(now - 2 * DAY),
153 comments: [
154 { authorId: "bob", createdAt: new Date(now - 2 * DAY + HOUR) },
155 ],
156 },
157 {
158 id: "b",
159 state: "closed",
160 authorId: "alice",
161 createdAt: new Date(now - 45 * DAY),
162 comments: [],
163 },
164 {
165 id: "c",
166 state: "open",
167 authorId: "alice",
168 createdAt: new Date(now - 5 * DAY),
169 comments: [], // unresponded
170 },
171 {
172 id: "d",
173 state: "open",
174 authorId: "alice",
175 createdAt: "bad-date",
176 comments: [],
177 },
178 ];
179
180 it("filters to the window (windowDays=30)", () => {
181 const out = computeIssueStats(issues, 30, now);
182 expect(out.map((s) => s.id).sort()).toEqual(["a", "c"]);
183 });
184
185 it("windowDays=0 includes all (except unparseable dates)", () => {
186 const out = computeIssueStats(issues, 0, now);
187 expect(out.map((s) => s.id).sort()).toEqual(["a", "b", "c"]);
188 });
189
190 it("reports responseMs + null correctly", () => {
191 const out = computeIssueStats(issues, 30, now);
192 const a = out.find((s) => s.id === "a");
193 const c = out.find((s) => s.id === "c");
194 expect(a?.responseMs).toBe(HOUR);
195 expect(c?.responseMs).toBeNull();
196 });
197});
198
199describe("response-time — summariseResponseTimes", () => {
200 it("zero stats", () => {
201 const s = summariseResponseTimes([]);
202 expect(s.total).toBe(0);
203 expect(s.medianMs).toBeNull();
204 expect(s.p90Ms).toBeNull();
205 expect(s.fastestMs).toBeNull();
206 expect(s.slowestMs).toBeNull();
207 });
208
209 it("single responded issue", () => {
210 const s = summariseResponseTimes([
211 { id: "a", state: "open", createdAt: 0, responseMs: HOUR },
212 ]);
213 expect(s.total).toBe(1);
214 expect(s.responded).toBe(1);
215 expect(s.medianMs).toBe(HOUR);
216 expect(s.meanMs).toBe(HOUR);
217 expect(s.p90Ms).toBe(HOUR);
218 expect(s.fastestMs).toBe(HOUR);
219 expect(s.slowestMs).toBe(HOUR);
220 });
221
222 it("counts open+unresponded towards `unresponded`, not closed", () => {
223 const s = summariseResponseTimes([
224 { id: "o", state: "open", createdAt: 0, responseMs: null },
225 { id: "c", state: "closed", createdAt: 0, responseMs: null },
226 { id: "r", state: "open", createdAt: 0, responseMs: HOUR },
227 ]);
228 expect(s.total).toBe(3);
229 expect(s.responded).toBe(1);
230 expect(s.unresponded).toBe(1);
231 });
232
233 it("computes median/mean/p90 on responded only", () => {
234 const vals = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => n * HOUR);
235 const s = summariseResponseTimes(
236 vals.map((v, i) => ({
237 id: String(i),
238 state: "open",
239 createdAt: 0,
240 responseMs: v,
241 }))
242 );
243 expect(s.responded).toBe(10);
244 // median of 10 values (indices 0..9) at p=50 → rank 4.5 → between idx 4,5 → (5+6)/2 h = 5.5h
245 expect(s.medianMs).toBe(Math.round(5.5 * HOUR));
246 expect(s.meanMs).toBe(Math.round(5.5 * HOUR));
247 // p90 → rank 8.1 → between idx 8,9 → 9h + 0.1*(10h-9h) = 9.1h
248 expect(s.p90Ms).toBe(Math.round(9.1 * HOUR));
249 expect(s.fastestMs).toBe(HOUR);
250 expect(s.slowestMs).toBe(10 * HOUR);
251 });
252});
253
254describe("response-time — bucketResponseTimes", () => {
255 it("distributes into the four buckets", () => {
256 const b = bucketResponseTimes([
257 { id: "1", state: "open", createdAt: 0, responseMs: 30 * 60 * 1000 }, // 30m → within1h
258 { id: "2", state: "open", createdAt: 0, responseMs: HOUR }, // 1h exactly → within1h
259 { id: "3", state: "open", createdAt: 0, responseMs: 2 * HOUR }, // within1d
260 { id: "4", state: "open", createdAt: 0, responseMs: 25 * HOUR }, // within1w
261 { id: "5", state: "open", createdAt: 0, responseMs: 8 * DAY }, // over1w
262 { id: "n", state: "open", createdAt: 0, responseMs: null }, // ignored
263 ]);
264 expect(b.within1h).toBe(2);
265 expect(b.within1d).toBe(1);
266 expect(b.within1w).toBe(1);
267 expect(b.over1w).toBe(1);
268 });
269});
270
271describe("response-time — formatDuration", () => {
272 it("handles null", () => {
273 expect(formatDuration(null)).toBe("\u2014");
274 });
275 it("ms", () => {
276 expect(formatDuration(750)).toBe("750ms");
277 });
278 it("seconds", () => {
279 expect(formatDuration(45 * 1000)).toBe("45s");
280 });
281 it("minutes", () => {
282 expect(formatDuration(90 * 1000)).toBe("2m");
283 });
284 it("hours with minutes", () => {
285 expect(formatDuration(HOUR + 30 * 60 * 1000)).toBe("1h 30m");
286 });
287 it("exact hours omit minutes", () => {
288 expect(formatDuration(3 * HOUR)).toBe("3h");
289 });
290 it("days with hours", () => {
291 expect(formatDuration(2 * DAY + 5 * HOUR)).toBe("2d 5h");
292 });
293 it("exact days omit hours", () => {
294 expect(formatDuration(3 * DAY)).toBe("3d");
295 });
296 it("negative clamps to 0s", () => {
297 expect(formatDuration(-100)).toBe("0s");
298 });
299});
300
301describe("response-time — buildResponseReport", () => {
302 const now = new Date("2025-04-01T00:00:00Z").getTime();
303
304 it("builds a complete report", () => {
305 const issues: ResponseIssueInput[] = [
306 {
307 id: "a",
308 state: "open",
309 authorId: "alice",
310 createdAt: new Date(now - HOUR * 3),
311 comments: [
312 { authorId: "bob", createdAt: new Date(now - HOUR * 2) },
313 ],
314 },
315 {
316 id: "b",
317 state: "open",
318 authorId: "alice",
319 createdAt: new Date(now - DAY),
320 comments: [],
321 },
322 {
323 id: "c",
324 state: "closed",
325 authorId: "alice",
326 createdAt: new Date(now - 10 * DAY),
327 comments: [
328 { authorId: "carol", createdAt: new Date(now - 10 * DAY + 2 * HOUR) },
329 ],
330 },
331 ];
332 const r = buildResponseReport({ issues, windowDays: 30, now });
333 expect(r.windowDays).toBe(30);
334 expect(r.now).toBe(now);
335 expect(r.perIssue).toHaveLength(3);
336 expect(r.summary.responded).toBe(2);
337 expect(r.summary.unresponded).toBe(1);
338 expect(r.unrepliedIssueIds).toEqual(["b"]);
339 });
340
341 it("defaults `now` to Date.now when omitted", () => {
342 const before = Date.now();
343 const r = buildResponseReport({
344 issues: [],
345 windowDays: 30,
346 });
347 const after = Date.now();
348 expect(r.now).toBeGreaterThanOrEqual(before);
349 expect(r.now).toBeLessThanOrEqual(after);
350 });
351
352 it("sorts unrepliedIssueIds oldest-first", () => {
353 const issues: ResponseIssueInput[] = [
354 {
355 id: "younger",
356 state: "open",
357 authorId: "alice",
358 createdAt: new Date(now - DAY),
359 comments: [],
360 },
361 {
362 id: "older",
363 state: "open",
364 authorId: "alice",
365 createdAt: new Date(now - 3 * DAY),
366 comments: [],
367 },
368 ];
369 const r = buildResponseReport({ issues, windowDays: 30, now });
370 expect(r.unrepliedIssueIds).toEqual(["older", "younger"]);
371 });
372});
373
374describe("response-time — routes", () => {
375 it("GET /insights/response-time returns 2xx or 404 (never 500)", async () => {
376 const { default: app } = await import("../app");
377 const res = await app.request("/alice/repo/insights/response-time");
378 expect([200, 404]).toContain(res.status);
379 });
380
381 it("ignores bogus window values", async () => {
382 const { default: app } = await import("../app");
383 const res = await app.request(
384 "/alice/repo/insights/response-time?window=garbage"
385 );
386 expect([200, 404]).toContain(res.status);
387 });
388});
389
390describe("response-time — __internal parity", () => {
391 it("re-exports helpers", () => {
392 expect(__internal.parseWindow).toBe(parseWindow);
393 expect(__internal.computeTimeToFirstResponse).toBe(
394 computeTimeToFirstResponse
395 );
396 expect(__internal.computeIssueStats).toBe(computeIssueStats);
397 expect(__internal.summariseResponseTimes).toBe(summariseResponseTimes);
398 expect(__internal.bucketResponseTimes).toBe(bucketResponseTimes);
399 expect(__internal.buildResponseReport).toBe(buildResponseReport);
400 expect(__internal.formatDuration).toBe(formatDuration);
401 });
402});