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.
| 9090df3 | 1 | /** |
| 2 | * Block J25 — Time-to-first-response 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 | computeTimeToFirstResponse, | |
| 11 | computeIssueStats, | |
| 12 | summariseResponseTimes, | |
| 13 | bucketResponseTimes, | |
| 14 | buildResponseReport, | |
| 15 | formatDuration, | |
| 16 | __internal, | |
| 17 | type ResponseIssueInput, | |
| 18 | } from "../lib/response-time"; | |
| 19 | ||
| 20 | const HOUR = 60 * 60 * 1000; | |
| 21 | const DAY = 24 * HOUR; | |
| 22 | ||
| 23 | describe("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 | ||
| 47 | describe("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 | ||
| 145 | describe("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 | ||
| 199 | describe("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 | ||
| 254 | describe("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 | ||
| 271 | describe("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 | ||
| 301 | describe("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 | ||
| 374 | describe("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 | ||
| 390 | describe("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 | }); |