Commit9090df3unknown_key
feat(BLOCK-J): J25 time-to-first-response metric
feat(BLOCK-J): J25 time-to-first-response metric GET /:owner/:repo/insights/response-time[?window=7|30|90|365|0] renders p50/mean/p90/fastest/slowest response times, a four-bucket distribution, and the oldest-first list of open issues still waiting for a reply. Pure computeTimeToFirstResponse (ignores comments by the issue author, clamps negative deltas, skips unparseable dates), summariseResponseTimes with inclusive-method percentile interpolation, bucketResponseTimes, formatDuration, parseWindow allow-list, buildResponseReport one-shot builder — all in src/lib/response-time.ts (32 pure tests + 2 route tests, 76 expects). softAuth; private repos 404 for non-owner viewers; DB failure degrades to empty report, never 500. Linked from Insights. Full suite: 1200/1200.
6 files changed+1015−79090df3c554f9a1cf94c960a4665748db8dc51bd
6 changed files+1015−7
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
@@ -146,6 +146,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
146146| Code review suggestion blocks | ✅ | J22 — `` ```suggestion `` fenced blocks inside PR comments are detected (pure `extractSuggestions` in `src/lib/code-suggestions.ts`) and rendered on the PR page with a "Commit suggestion" button when the viewer is the PR author, comment author, or repo owner. `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion` reads the file on the PR's head branch, runs pure `applySuggestionToContent` (preserves CRLF/LF line endings + trailing-newline presence, validates line range + detects no-op), and commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree` → `update-ref`) with a `Co-authored-by:` trailer crediting the applier. `requireAuth`; private repos 404 for non-owners; closed PRs redirect silently. |
147147| Issue/PR search query DSL | ✅ | J23 — GitHub-style search DSL on the issue list. `GET /:owner/:repo/issues?q=…` accepts `is:open\|closed`, `author:<user>`, `label:<name>` (repeatable, AND), `-label:<name>` (exclude), `no:label`, `milestone:"<title>"`, `sort:created-desc\|created-asc\|updated-desc\|updated-asc\|comments-desc`, and any other text → case-insensitive substring match against title+body. Pure `tokenise` / `parseIssueQuery` / `matchIssue` / `sortIssues` / `applyQuery` / `formatIssueQuery` in `src/lib/issue-query.ts` — never throws; unknown qualifiers fall back to text; `sort:` values not in the allow-list fall back to default. The issues route joins labels, applies the DSL in-memory, and renders an inline search bar + collapsible syntax cheat-sheet + live match count. Tab pill counts remain over all issues so filters don't collapse them. |
148148| Branch rename with cascades | ✅ | J24 — `GET/POST /:owner/:repo/settings/branches` (owner-only). Pure `validateBranchName` / `planRename` / `shouldRewriteProtectionPattern` in `src/lib/branch-rename.ts` enforce `git check-ref-format(1)` (rejects `..`, `@{`, bare `@`, leading `-`, leading/trailing `.` or `/`, `//`, `.lock` suffix, whitespace, `~^:?*[\`, control chars). Git plumbing `renameBranch` (creates `refs/heads/<to>` → deletes `refs/heads/<from>` with rollback on partial failure) + `setHeadBranch` live in `src/git/repository.ts`. After the ref move the route cascades to `repositories.default_branch`, `pull_requests.base_branch` / `head_branch`, `merge_queue_entries.base_branch`, and exact-match `branch_protection.pattern` rows (globs untouched), with every rename audited as `branch.rename`. History is preserved — only the ref name changes. |
149| Time-to-first-response metric | ✅ | J25 — `GET /:owner/:repo/insights/response-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest response times, a four-bucket distribution (≤1h, 1h–1d, 1d–1w, >1w), and the oldest-first list of open issues still waiting for a reply. Pure `computeTimeToFirstResponse` (ignores comments by the issue author, clamps negative deltas, skips unparseable dates), `computeIssueStats` (window filter), `summariseResponseTimes` (inclusive-method percentiles), `bucketResponseTimes`, `formatDuration` (`ms`/`s`/`m`/`h m`/`d h`), `buildResponseReport` one-shot, `parseWindow` (allow-list `VALID_WINDOWS`) in `src/lib/response-time.ts`. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; DB failure degrades to empty report — never 500. |
149150| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
150151| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
151152| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -533,6 +534,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
533534- `src/lib/branch-rename.ts` (Block J24) — pure branch-rename helpers. `MAX_BRANCH_NAME_LENGTH=250`. `validateBranchName(name)` mirrors `git-check-ref-format(1)` rejecting `not_string\|empty\|too_long\|slash_boundary\|leading_dash\|dot_boundary\|consecutive_slashes\|double_dot\|at_brace\|only_at\|lock_suffix\|forbidden_char\|control_char\|empty_component\|dot_component\|lock_component`. `branchValidationMessage(reason)` returns human-readable copy for each code. `planRename({from,to,existingBranches,defaultBranch})` returns `{ok, from, to, updatesDefault}` or `{ok:false, reason, detail?}` with `same_name\|invalid_from\|invalid_to\|from_missing\|to_exists`; case-sensitive ref comparison. `shouldRewriteProtectionPattern(pattern, from)` returns true only for exact non-glob matches. `__internal` re-exports for tests.
534535- `src/routes/branch-rename.tsx` (Block J24) — serves `GET/POST /:owner/:repo/settings/branches[/rename]`. Owner-only (via internal `resolveOwned` that wraps DB lookups in try/catch → null). POST path: `planRename` → on rejection redirects with the error copy from `branchValidationMessage`; on approval calls `renameBranch` (which creates `refs/heads/<to>` then deletes `refs/heads/<from>` with auto-rollback if the delete fails) + `setHeadBranch` when renaming the default. Cascades to `repositories.default_branch`, `pull_requests.base_branch`/`head_branch`, `merge_queue_entries.base_branch`, and `branch_protection.pattern` rows where `shouldRewriteProtectionPattern` returns true (duplicate-pattern conflicts swallowed silently). Every op audited as `branch.rename` with `{from, to, updatesDefault}` metadata. Audit failures never block the rename.
535536- `src/git/repository.ts` (Block J24 additions) — adds `renameBranch(owner, name, from, to)` and `setHeadBranch(owner, name, branch)`. `renameBranch` resolves `refs/heads/<from>` to a SHA, writes `refs/heads/<to>` at that SHA, then deletes the old ref; on partial failure it best-effort-removes the newly-created ref so the repo never ends up with both refs pointing at the same SHA. Both helpers flush `gitCache` under `${owner}/${name}:` so branch lists + default-branch lookups see the new state.
537- `src/lib/response-time.ts` (Block J25) — pure time-to-first-response helpers. `DEFAULT_WINDOW_DAYS=30`, `VALID_WINDOWS=[0,7,30,90,365]`, `parseWindow` (allow-list validator), `computeTimeToFirstResponse({issueCreatedAt, issueAuthorId, comments})` (returns earliest non-author delta in ms or null; ignores unparseable dates; clamps negative deltas to 0), `computeIssueStats(issues, windowDays, now)` (window filter is `>= now - windowDays*24h`; `windowDays=0` means all-time; drops unparseable `createdAt`), `summariseResponseTimes(stats)` (medianMs/meanMs/p90Ms via inclusive-method percentile interpolation + `unresponded` counts only open issues with null responseMs), `bucketResponseTimes(stats)` (≤1h / >1h ≤1d / >1d ≤1w / >1w; null responseMs ignored), `formatDuration(ms\|null)` (→ `ms`/`s`/`m`/`Xh Ym`/`Xd Yh`, em-dash for null, negatives clamp to `0s`), `buildResponseReport` one-shot emits `{windowDays, now, perIssue, summary, buckets, unrepliedIssueIds}` sorted oldest-first. `__internal` re-exports for tests.
538- `src/routes/response-time.tsx` (Block J25) — serves `GET /:owner/:repo/insights/response-time[?window=…]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 2000 issues + all their comments via two queries (`inArray`), runs the pure report, renders eight KPI cards (total / responded / unreplied / median / mean / p90 / fastest / slowest), four bucket cards, and the top 25 oldest-unreplied open issues with a "waiting" duration. `resolveRepo` wraps DB in try/catch → never 500. Insights page header now links to it alongside Pulse.
536539
537540### 4.7 Views (locked contracts)
538541- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -567,7 +570,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
567570```bash
568571bun install
569572bun dev # hot reload
570bun test # 1166 tests currently pass
573bun test # 1200 tests currently pass
571574bun run db:migrate
572575```
573576
Addedsrc/__tests__/response-time.test.ts+402−0View fileUnifiedSplit
@@ -0,0 +1,402 @@
1/**
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});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
@@ -84,6 +84,7 @@ import staleIssuesRoutes from "./routes/stale-issues";
8484import codeownersLintRoutes from "./routes/codeowners-lint";
8585import codeSuggestionsRoutes from "./routes/code-suggestions";
8686import branchRenameRoutes from "./routes/branch-rename";
87import responseTimeRoutes from "./routes/response-time";
8788import webRoutes from "./routes/web";
8889
8990const app = new Hono();
@@ -299,6 +300,11 @@ app.route("/", codeSuggestionsRoutes);
299300// Branch rename — owner-only /:owner/:repo/settings/branches (Block J24)
300301app.route("/", branchRenameRoutes);
301302
303// Time-to-first-response metric — /:owner/:repo/insights/response-time (Block J25)
304// Must be mounted BEFORE insightsRoutes so the static `/insights/response-time`
305// path wins over any future `/insights/:id` dynamic route that might appear.
306app.route("/", responseTimeRoutes);
307
302308// Insights + milestones
303309app.route("/", insightsRoutes);
304310
Addedsrc/lib/response-time.ts+276−0View fileUnifiedSplit
@@ -0,0 +1,276 @@
1/**
2 * Block J25 — Time-to-first-response metric.
3 *
4 * For each issue, the response time is the elapsed time between the
5 * issue's creation and the first comment made by someone OTHER than the
6 * issue author. "Responses" from the issue author themselves don't
7 * count — that rule matches GitHub's own "time to first response"
8 * metric used in Insights and Community Health.
9 *
10 * This module is pure. Inputs are plain shapes; outputs are plain
11 * summaries. The route feeds in Drizzle rows + does the display.
12 */
13
14export interface ResponseIssueInput {
15 id: string;
16 createdAt: Date | string;
17 authorId: string;
18 state: "open" | "closed" | string;
19 /** Only comments with `authorId !== issue.authorId` count. */
20 comments: Array<{ authorId: string; createdAt: Date | string }>;
21}
22
23export interface IssueResponseStat {
24 id: string;
25 state: string;
26 createdAt: number; // epoch ms
27 responseMs: number | null; // null when no non-author response yet
28}
29
30export interface ResponseTimeSummary {
31 total: number;
32 responded: number;
33 /** Open issues with zero non-author comments. */
34 unresponded: number;
35 medianMs: number | null;
36 meanMs: number | null;
37 p90Ms: number | null;
38 fastestMs: number | null;
39 slowestMs: number | null;
40}
41
42export interface ResponseTimeBuckets {
43 /** ≤ 1 hour. */
44 within1h: number;
45 /** > 1h and ≤ 24h. */
46 within1d: number;
47 /** > 24h and ≤ 7d. */
48 within1w: number;
49 /** > 7d. */
50 over1w: number;
51}
52
53export interface ResponseReport {
54 /** Window in whole days, 0 means "all time". */
55 windowDays: number;
56 now: number;
57 perIssue: IssueResponseStat[];
58 summary: ResponseTimeSummary;
59 buckets: ResponseTimeBuckets;
60 /** Open issues with no non-author response yet, oldest first. */
61 unrepliedIssueIds: string[];
62}
63
64const HOUR = 60 * 60 * 1000;
65const DAY = 24 * HOUR;
66const WEEK = 7 * DAY;
67
68export const DEFAULT_WINDOW_DAYS = 30;
69export const VALID_WINDOWS = [0, 7, 30, 90, 365] as const;
70export type ResponseWindow = (typeof VALID_WINDOWS)[number];
71
72export function parseWindow(raw: unknown): ResponseWindow {
73 if (typeof raw === "string" && raw.trim()) {
74 const n = parseInt(raw, 10);
75 if (VALID_WINDOWS.includes(n as ResponseWindow)) {
76 return n as ResponseWindow;
77 }
78 }
79 return DEFAULT_WINDOW_DAYS;
80}
81
82function toMs(v: Date | string): number {
83 if (v instanceof Date) return v.getTime();
84 const t = Date.parse(v);
85 return Number.isFinite(t) ? t : NaN;
86}
87
88/**
89 * Returns elapsed ms from `issueCreatedAt` to the earliest comment NOT
90 * authored by the issue author, or `null` when no such comment exists
91 * or the inputs are unparseable. Negative differences (comment dated
92 * before issue) are clamped to 0.
93 */
94export function computeTimeToFirstResponse(input: {
95 issueCreatedAt: Date | string;
96 issueAuthorId: string;
97 comments: Array<{ authorId: string; createdAt: Date | string }>;
98}): number | null {
99 const base = toMs(input.issueCreatedAt);
100 if (!Number.isFinite(base)) return null;
101
102 let earliest: number | null = null;
103 for (const c of input.comments) {
104 if (c.authorId === input.issueAuthorId) continue;
105 const t = toMs(c.createdAt);
106 if (!Number.isFinite(t)) continue;
107 if (earliest === null || t < earliest) earliest = t;
108 }
109 if (earliest === null) return null;
110 return Math.max(0, earliest - base);
111}
112
113/**
114 * Reduce a list of issues to per-issue stats. Filters by window (only
115 * issues created within the last `windowDays` days) when `windowDays > 0`.
116 */
117export function computeIssueStats(
118 issues: ResponseIssueInput[],
119 windowDays: number,
120 now: Date | number = Date.now()
121): IssueResponseStat[] {
122 const nowMs = typeof now === "number" ? now : now.getTime();
123 const cutoff = windowDays > 0 ? nowMs - windowDays * DAY : -Infinity;
124
125 const out: IssueResponseStat[] = [];
126 for (const i of issues) {
127 const created = toMs(i.createdAt);
128 if (!Number.isFinite(created)) continue;
129 if (created < cutoff) continue;
130
131 const responseMs = computeTimeToFirstResponse({
132 issueCreatedAt: i.createdAt,
133 issueAuthorId: i.authorId,
134 comments: i.comments,
135 });
136 out.push({
137 id: i.id,
138 state: i.state,
139 createdAt: created,
140 responseMs,
141 });
142 }
143 return out;
144}
145
146function percentile(sorted: number[], p: number): number | null {
147 if (sorted.length === 0) return null;
148 if (sorted.length === 1) return sorted[0];
149 // Linear interpolation (inclusive method).
150 const rank = (p / 100) * (sorted.length - 1);
151 const lo = Math.floor(rank);
152 const hi = Math.ceil(rank);
153 if (lo === hi) return sorted[lo];
154 const frac = rank - lo;
155 return Math.round(sorted[lo] + (sorted[hi] - sorted[lo]) * frac);
156}
157
158export function summariseResponseTimes(
159 stats: IssueResponseStat[]
160): ResponseTimeSummary {
161 const responded = stats
162 .filter((s) => s.responseMs !== null)
163 .map((s) => s.responseMs as number);
164 const unresponded = stats.filter(
165 (s) => s.responseMs === null && s.state === "open"
166 ).length;
167 responded.sort((a, b) => a - b);
168 const total = stats.length;
169 if (responded.length === 0) {
170 return {
171 total,
172 responded: 0,
173 unresponded,
174 medianMs: null,
175 meanMs: null,
176 p90Ms: null,
177 fastestMs: null,
178 slowestMs: null,
179 };
180 }
181 const sum = responded.reduce((a, b) => a + b, 0);
182 return {
183 total,
184 responded: responded.length,
185 unresponded,
186 medianMs: percentile(responded, 50),
187 meanMs: Math.round(sum / responded.length),
188 p90Ms: percentile(responded, 90),
189 fastestMs: responded[0],
190 slowestMs: responded[responded.length - 1],
191 };
192}
193
194export function bucketResponseTimes(
195 stats: IssueResponseStat[]
196): ResponseTimeBuckets {
197 const buckets: ResponseTimeBuckets = {
198 within1h: 0,
199 within1d: 0,
200 within1w: 0,
201 over1w: 0,
202 };
203 for (const s of stats) {
204 if (s.responseMs === null) continue;
205 if (s.responseMs <= HOUR) buckets.within1h++;
206 else if (s.responseMs <= DAY) buckets.within1d++;
207 else if (s.responseMs <= WEEK) buckets.within1w++;
208 else buckets.over1w++;
209 }
210 return buckets;
211}
212
213/**
214 * Format a ms duration as a compact human string: "3h", "1d 4h",
215 * "12m", "45s", "—" for null. Non-negative; rounds to the nearest
216 * sensible unit.
217 */
218export function formatDuration(ms: number | null): string {
219 if (ms === null) return "\u2014";
220 if (ms < 0) return "0s";
221 if (ms < 1000) return `${ms}ms`;
222 if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`;
223 if (ms < HOUR) return `${Math.round(ms / (60 * 1000))}m`;
224 if (ms < DAY) {
225 const h = Math.floor(ms / HOUR);
226 const m = Math.round((ms - h * HOUR) / (60 * 1000));
227 return m > 0 ? `${h}h ${m}m` : `${h}h`;
228 }
229 const d = Math.floor(ms / DAY);
230 const h = Math.round((ms - d * DAY) / HOUR);
231 return h > 0 ? `${d}d ${h}h` : `${d}d`;
232}
233
234export function buildResponseReport(opts: {
235 issues: ResponseIssueInput[];
236 windowDays: number;
237 now?: Date | number;
238}): ResponseReport {
239 const nowMs =
240 opts.now === undefined
241 ? Date.now()
242 : typeof opts.now === "number"
243 ? opts.now
244 : opts.now.getTime();
245 const perIssue = computeIssueStats(opts.issues, opts.windowDays, nowMs);
246 const summary = summariseResponseTimes(perIssue);
247 const buckets = bucketResponseTimes(perIssue);
248 const unrepliedIssueIds = perIssue
249 .filter((s) => s.responseMs === null && s.state === "open")
250 .sort((a, b) => a.createdAt - b.createdAt)
251 .map((s) => s.id);
252 return {
253 windowDays: opts.windowDays,
254 now: nowMs,
255 perIssue,
256 summary,
257 buckets,
258 unrepliedIssueIds,
259 };
260}
261
262export const __internal = {
263 HOUR,
264 DAY,
265 WEEK,
266 DEFAULT_WINDOW_DAYS,
267 VALID_WINDOWS,
268 parseWindow,
269 computeTimeToFirstResponse,
270 computeIssueStats,
271 summariseResponseTimes,
272 bucketResponseTimes,
273 buildResponseReport,
274 formatDuration,
275 percentile,
276};
Modifiedsrc/routes/insights.tsx+14−6View fileUnifiedSplit
@@ -151,12 +151,20 @@ insights.get("/:owner/:repo/insights", async (c) => {
151151 <RepoNav owner={owner} repo={repo} active="insights" />
152152 <div style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px">
153153 <h3 style="margin: 0">Insights</h3>
154 <a
155 href={`/${owner}/${repo}/pulse`}
156 style="font-size: 12px; color: var(--accent)"
157 >
158 Pulse →
159 </a>
154 <div style="display: flex; gap: 16px">
155 <a
156 href={`/${owner}/${repo}/insights/response-time`}
157 style="font-size: 12px; color: var(--accent)"
158 >
159 Response time →
160 </a>
161 <a
162 href={`/${owner}/${repo}/pulse`}
163 style="font-size: 12px; color: var(--accent)"
164 >
165 Pulse →
166 </a>
167 </div>
160168 </div>
161169
162170 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px">
Addedsrc/routes/response-time.tsx+313−0View fileUnifiedSplit
@@ -0,0 +1,313 @@
1/**
2 * Block J25 — Time-to-first-response insights page.
3 *
4 * GET /:owner/:repo/insights/response-time[?window=7|30|90|365|0]
5 *
6 * softAuth, read-only. Fetches issues for the repo + all their comments
7 * in two queries, runs the pure `buildResponseReport`, renders a
8 * KPI grid (p50/mean/p90/fastest/slowest), four latency buckets, and
9 * the oldest still-unreplied open issues.
10 */
11
12import { Hono } from "hono";
13import { eq, and, inArray } from "drizzle-orm";
14import { db } from "../db";
15import { repositories, users, issues, issueComments } from "../db/schema";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { softAuth } from "../middleware/auth";
19import type { AuthEnv } from "../middleware/auth";
20import {
21 buildResponseReport,
22 formatDuration,
23 parseWindow,
24 VALID_WINDOWS,
25 type ResponseIssueInput,
26} from "../lib/response-time";
27
28const responseTimeRoutes = new Hono<AuthEnv>();
29
30responseTimeRoutes.use("*", softAuth);
31
32async function resolveRepo(ownerName: string, repoName: string) {
33 try {
34 const [owner] = await db
35 .select()
36 .from(users)
37 .where(eq(users.username, ownerName))
38 .limit(1);
39 if (!owner) return null;
40 const [repo] = await db
41 .select()
42 .from(repositories)
43 .where(
44 and(
45 eq(repositories.ownerId, owner.id),
46 eq(repositories.name, repoName)
47 )
48 )
49 .limit(1);
50 if (!repo) return null;
51 return { owner, repo };
52 } catch {
53 return null;
54 }
55}
56
57responseTimeRoutes.get(
58 "/:owner/:repo/insights/response-time",
59 async (c) => {
60 const { owner: ownerName, repo: repoName } = c.req.param();
61 const user = c.get("user");
62 const windowDays = parseWindow(c.req.query("window"));
63
64 const resolved = await resolveRepo(ownerName, repoName);
65 if (!resolved) {
66 return c.html(
67 <Layout title="Not Found" user={user}>
68 <div class="empty-state">
69 <h2>Repository not found</h2>
70 </div>
71 </Layout>,
72 404
73 );
74 }
75
76 // Private-repo visibility: only the owner can see the metric.
77 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
78 return c.html(
79 <Layout title="Not Found" user={user}>
80 <div class="empty-state">
81 <h2>Repository not found</h2>
82 </div>
83 </Layout>,
84 404
85 );
86 }
87
88 let issueRows: {
89 id: string;
90 number: number;
91 title: string;
92 state: string;
93 authorId: string;
94 createdAt: Date;
95 }[] = [];
96 let commentRows: {
97 issueId: string;
98 authorId: string;
99 createdAt: Date;
100 }[] = [];
101 try {
102 issueRows = await db
103 .select({
104 id: issues.id,
105 number: issues.number,
106 title: issues.title,
107 state: issues.state,
108 authorId: issues.authorId,
109 createdAt: issues.createdAt,
110 })
111 .from(issues)
112 .where(eq(issues.repositoryId, resolved.repo.id))
113 .limit(2000);
114
115 if (issueRows.length > 0) {
116 commentRows = await db
117 .select({
118 issueId: issueComments.issueId,
119 authorId: issueComments.authorId,
120 createdAt: issueComments.createdAt,
121 })
122 .from(issueComments)
123 .where(
124 inArray(
125 issueComments.issueId,
126 issueRows.map((i) => i.id)
127 )
128 );
129 }
130 } catch {
131 // Empty arrays → empty report. Page still renders.
132 }
133
134 const commentsByIssue = new Map<
135 string,
136 { authorId: string; createdAt: Date }[]
137 >();
138 for (const c0 of commentRows) {
139 const arr = commentsByIssue.get(c0.issueId) ?? [];
140 arr.push({ authorId: c0.authorId, createdAt: c0.createdAt });
141 commentsByIssue.set(c0.issueId, arr);
142 }
143
144 const inputs: ResponseIssueInput[] = issueRows.map((i) => ({
145 id: i.id,
146 state: i.state,
147 authorId: i.authorId,
148 createdAt: i.createdAt,
149 comments: commentsByIssue.get(i.id) ?? [],
150 }));
151 const report = buildResponseReport({ issues: inputs, windowDays });
152
153 // Look up titles for unreplied issues so we can render a link.
154 const unrepliedMeta = report.unrepliedIssueIds
155 .map((id) => {
156 const i = issueRows.find((r) => r.id === id);
157 if (!i) return null;
158 return {
159 id,
160 number: i.number,
161 title: i.title,
162 createdAt: i.createdAt,
163 };
164 })
165 .filter((x): x is NonNullable<typeof x> => x !== null)
166 .slice(0, 25);
167
168 const windowLabel =
169 windowDays === 0 ? "All time" : `Last ${windowDays} days`;
170
171 const kpi = (label: string, value: string) => (
172 <div
173 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)"
174 >
175 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
176 {label}
177 </div>
178 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
179 {value}
180 </div>
181 </div>
182 );
183
184 return c.html(
185 <Layout
186 title={`Response time — ${ownerName}/${repoName}`}
187 user={user}
188 >
189 <RepoHeader owner={ownerName} repo={repoName} />
190 <div style="max-width: 920px">
191 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
192 <h2 style="margin: 0">Time to first response</h2>
193 <form
194 method="GET"
195 action={`/${ownerName}/${repoName}/insights/response-time`}
196 style="display: flex; gap: 6px; align-items: center"
197 >
198 <label
199 for="window"
200 style="font-size: 12px; color: var(--text-muted)"
201 >
202 Window:
203 </label>
204 <select
205 id="window"
206 name="window"
207 onchange="this.form.submit()"
208 style="padding: 4px 8px; font-size: 12px"
209 >
210 {VALID_WINDOWS.map((w) => (
211 <option value={String(w)} selected={w === windowDays}>
212 {w === 0 ? "All time" : `Last ${w} days`}
213 </option>
214 ))}
215 </select>
216 </form>
217 </div>
218 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
219 <strong>{windowLabel}</strong>. Response time = time from issue
220 creation to the first comment by someone other than the author.
221 Comments authored by the issue author themselves don't count.
222 </p>
223
224 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
225 {kpi("Total issues", String(report.summary.total))}
226 {kpi("Responded", String(report.summary.responded))}
227 {kpi(
228 "Unreplied (open)",
229 String(report.summary.unresponded)
230 )}
231 {kpi("Median (p50)", formatDuration(report.summary.medianMs))}
232 {kpi("Mean", formatDuration(report.summary.meanMs))}
233 {kpi("p90", formatDuration(report.summary.p90Ms))}
234 {kpi("Fastest", formatDuration(report.summary.fastestMs))}
235 {kpi("Slowest", formatDuration(report.summary.slowestMs))}
236 </div>
237
238 <h3 style="margin-bottom: 10px">Distribution</h3>
239 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
240 {[
241 ["≤ 1 hour", report.buckets.within1h],
242 ["1h – 1 day", report.buckets.within1d],
243 ["1d – 1 week", report.buckets.within1w],
244 ["> 1 week", report.buckets.over1w],
245 ].map(([label, count]) => (
246 <div
247 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center"
248 >
249 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
250 {label}
251 </div>
252 <div style="font-size: 18px; font-weight: 600">{count}</div>
253 </div>
254 ))}
255 </div>
256
257 <h3 style="margin-bottom: 10px">
258 Oldest unreplied open issues ({unrepliedMeta.length}
259 {report.unrepliedIssueIds.length > unrepliedMeta.length
260 ? ` of ${report.unrepliedIssueIds.length}`
261 : ""}
262 )
263 </h3>
264 {unrepliedMeta.length === 0 ? (
265 <div class="empty-state">
266 <p>Nothing is waiting for a response. Nice.</p>
267 </div>
268 ) : (
269 <table style="width: 100%; border-collapse: collapse">
270 <thead>
271 <tr>
272 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
273 Issue
274 </th>
275 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 140px">
276 Waiting
277 </th>
278 </tr>
279 </thead>
280 <tbody>
281 {unrepliedMeta.map((i) => {
282 const waitingMs = Math.max(
283 0,
284 report.now - new Date(i.createdAt).getTime()
285 );
286 return (
287 <tr>
288 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
289 <a
290 href={`/${ownerName}/${repoName}/issues/${i.number}`}
291 >
292 <span style="color: var(--text-muted)">
293 #{i.number}
294 </span>{" "}
295 {i.title}
296 </a>
297 </td>
298 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
299 {formatDuration(waitingMs)}
300 </td>
301 </tr>
302 );
303 })}
304 </tbody>
305 </table>
306 )}
307 </div>
308 </Layout>
309 );
310 }
311);
312
313export default responseTimeRoutes;
0314