Commit8c5346cunknown_key
feat(BLOCK-J): J29 PR lead-time metric
feat(BLOCK-J): J29 PR lead-time metric - src/lib/pr-lead-time.ts: computeLeadTime, computePrStats (window anchor = mergedAt for merged / createdAt otherwise), summariseLeadTimes (inclusive- method p50/p90, separate merged/openNonDraft/openDraft/closedUnmerged counters), bucketLeadTimes, buildLeadTimeReport. Re-exports parseWindow / formatDuration / VALID_WINDOWS from J25 for DRY - src/routes/pr-lead-time.tsx: GET /:o/:r/insights/lead-time with 9 KPI cards, 4-bucket distribution, oldest-open PRs table (drafts excluded) - Insights page header gets a Lead time link - 24 new tests covering re-exports, computeLeadTime (null/clamp/ISO/unparse), computePrStats (window + anchor logic), summariseLeadTimes (open/draft/ closed split + inclusive percentiles), bucketLeadTimes, buildLeadTimeReport (oldestOpenIds sorting, draft exclusion), route smoke tests, __internal - Full suite: 1322 -> 1346 pass
6 files changed+889−18c5346c3fb597eebdee2354b527f7f2215d7c8a8
6 changed files+889−1
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
@@ -150,6 +150,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
150150| Audit log CSV export | ✅ | J26 — `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv` stream the audit log as RFC 4180 CSV with `Content-Disposition: attachment`. Pure `csvCell` / `csvRow` / `csvDocument` / `formatAuditCsv` / `auditCsvFilename` helpers in `src/lib/audit-csv.ts` implement CRLF termination, `"…"` wrapping + `""` escaping when cells contain `,"\n\r`, and a CSV-injection guard that prefixes any cell starting with `=+-@\t\r` with a single-quote so spreadsheet formula engines don't evaluate attacker-supplied content. Header row is `id,when,actor,action,targetType,targetId,ip,userAgent,metadata`. Same visibility rules as the HTML pages (requireAuth + owner-only for repo variant). Filename is `audit-<scope>-<ISO>.csv`. `Cache-Control: private, no-store`. |
151151| Branch staleness / age report | ✅ | J27 — `GET /:owner/:repo/branches/age[?threshold=0\|30\|60\|90\|180][&sort=age-desc\|age-asc\|name\|ahead-desc\|behind-desc]` walks every branch, fetches the tip commit + ahead/behind counts vs the default branch via `aheadBehind(base, head)` (new `git rev-list --left-right --count` helper in `src/git/repository.ts`), and renders KPI cards (total / non-default / merged / unmerged / median age / oldest), a four-bucket distribution (Fresh <30d, Aging 30–59d, Stale 60–89d, Abandoned ≥90d or missing tip), and a branch table with ahead/behind/last-commit/status columns. Pure `parseThreshold` / `parseSort` / `computeDaysOld` / `classifyBranchAge` / `computeBranchRow` (merged = !default && ahead=0) / `bucketBranches` / `filterByThreshold` / `summariseBranches` (avg + median, excludes default) / `sortBranchRows` (non-mutating, null daysOld sinks) / `buildBranchReport` / `categoryLabel` / `thresholdLabel` / `sortLabel` in `src/lib/branch-age.ts`. softAuth; private repos 404 for non-owner viewers; git failures degrade to empty report — never 500. Linked from repo settings. |
152152| Issue duplicate suggestions | ✅ | J28 — `GET /:owner/:repo/issues/similar.json?q=<title>[&limit][&state=open\|closed]` returns ranked matches as JSON for new-issue-form inline suggestions. `GET /:owner/:repo/issues/:n/similar` is a standalone HTML page ranking related issues against the target title. Pure token-Jaccard ranker in `src/lib/issue-similarity.ts`: `tokeniseTitle` lowercases + strips non-`\p{L}\p{N}_-` + drops stopwords + drops tokens <2 chars; `jaccard` is Unicode-safe `|A∩B| / |A∪B|`; `rankCandidates` sorts score-desc with createdAt-desc + number-desc tie-breaks, honours `minScore` (default 0.15) / `limit` (default 5) / `excludeId` / `excludeNumber` / `state`. `formatSimilarityPercent` clamps to [0,1] and emits `"47%"`. Candidates capped at last 500 issues per repo. softAuth; private repos 404 for non-owner viewers. |
153| PR lead-time metric | ✅ | J29 — `GET /:owner/:repo/insights/lead-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest PR lead times (created→merged), a four-bucket merge-time distribution, and the oldest still-open non-draft PRs. Pure `computeLeadTime` / `computePrStats` (anchors the window on `mergedAt` for merged PRs, `createdAt` otherwise, so a PR merged yesterday with origin 60 days ago still lands in a 30-day window) / `summariseLeadTimes` (inclusive-method percentiles; separate counters for merged / openNonDraft / openDraft / closedUnmerged) / `bucketLeadTimes` / `buildLeadTimeReport` in `src/lib/pr-lead-time.ts`. Reuses `parseWindow` + `formatDuration` + `VALID_WINDOWS` from Block J25's `response-time.ts` to stay DRY. Linked from the Insights page header alongside Response time + Pulse. softAuth; private repos 404 for non-owner viewers. |
153154| 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 |
154155| 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`. |
155156| 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. |
@@ -546,6 +547,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
546547- `src/git/repository.ts` (Block J27 additions) — `aheadBehind(owner, name, base, head)` runs `git rev-list --left-right --count <base>...<head>`. Parses `<behind>\t<ahead>` pair. Returns null on exit failure, non-two-part output, or non-finite numbers. Non-cached — callers fan out per-branch at request time.
547548- `src/lib/issue-similarity.ts` (Block J28) — pure duplicate-suggestion ranker. `MIN_TOKEN_LENGTH=2`, `STOPWORDS` (~50 common English fillers), `DEFAULT_MIN_SCORE=0.15`, `DEFAULT_LIMIT=5`. `tokeniseTitle(s)` is Unicode-safe via `\p{L}\p{N}_-`, returns a deduped `Set`. `jaccard(a,b)` iterates the smaller set for efficiency; returns 0 for empty-vs-empty. `rankCandidates(title, candidates, opts)` filters by `minScore` / `state`, excludes by `excludeId` + `excludeNumber`, sorts score-desc with `createdAt`-desc + `number`-desc tie-breaks, slices to `limit`. Never mutates input. `findSimilar` is an alias. `formatSimilarityPercent(s)` clamps + rounds to whole-percent string. `__internal` re-exports.
548549- `src/routes/issue-similarity.tsx` (Block J28) — serves `GET /:owner/:repo/issues/similar.json` (JSON suggestions, up to `MAX_RESULT_LIMIT=20`) and `GET /:owner/:repo/issues/:number/similar` (HTML "related issues" page). Fetches up to `CANDIDATE_LIMIT=500` issues ordered by createdAt-desc. softAuth; private repos 404 for non-owner viewers; DB errors → empty candidates → empty matches. Must be mounted before `issueRoutes` so `/issues/similar.json` doesn't get eaten by `/issues/:number`.
550- `src/lib/pr-lead-time.ts` (Block J29) — pure PR lead-time rollup. Re-exports `DEFAULT_WINDOW_DAYS`, `VALID_WINDOWS`, `parseWindow`, `formatDuration` from Block J25. `computeLeadTime({createdAt, mergedAt})` returns ms or null (null when not merged / unparseable; clamps negatives to 0). `computePrStats(prs, windowDays, now)` uses `mergedAt` as the window anchor for merged PRs so PRs with ancient `createdAt` but recent merges still appear in recent-window reports; populates `leadMs` + `inFlightMs` (only for open non-merged). `summariseLeadTimes` computes p50 / mean / p90 / fastest / slowest via inclusive-method interpolation + separate counters for `merged / openNonDraft / openDraft / closedUnmerged` so drafts don't pollute open-count KPIs. `bucketLeadTimes` uses ≤1h / ≤1d / ≤1w / >1w. `buildLeadTimeReport` one-shot, `oldestOpenIds` sorted oldest-first and excludes drafts. `__internal` re-exports.
551- `src/routes/pr-lead-time.tsx` (Block J29) — serves `GET /:owner/:repo/insights/lead-time[?window=…]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 2000 PRs in a single Drizzle query, runs the pure report, renders nine KPI cards (total / merged / open-non-draft / drafts / median / mean / p90 / fastest / slowest), four bucket cards, and the top 25 oldest open PRs with an "in-flight" duration. DB failure → empty report → still 200. Linked from the Insights page header.
549552
550553### 4.7 Views (locked contracts)
551554- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -580,7 +583,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
580583```bash
581584bun install
582585bun dev # hot reload
583bun test # 1322 tests currently pass
586bun test # 1346 tests currently pass
584587bun run db:migrate
585588```
586589
Addedsrc/__tests__/pr-lead-time.test.ts+382−0View fileUnifiedSplit
@@ -0,0 +1,382 @@
1/**
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});
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -87,6 +87,7 @@ import branchRenameRoutes from "./routes/branch-rename";
8787import responseTimeRoutes from "./routes/response-time";
8888import branchAgeRoutes from "./routes/branch-age";
8989import issueSimilarityRoutes from "./routes/issue-similarity";
90import prLeadTimeRoutes from "./routes/pr-lead-time";
9091import webRoutes from "./routes/web";
9192
9293const app = new Hono();
@@ -316,6 +317,10 @@ app.route("/", responseTimeRoutes);
316317// over the `/:owner/:repo/tree/:branch` dynamic web catch-all.
317318app.route("/", branchAgeRoutes);
318319
320// PR lead-time metric — /:owner/:repo/insights/lead-time (Block J29)
321// Mounted alongside response-time before insightsRoutes.
322app.route("/", prLeadTimeRoutes);
323
319324// Insights + milestones
320325app.route("/", insightsRoutes);
321326
Addedsrc/lib/pr-lead-time.ts+240−0View fileUnifiedSplit
@@ -0,0 +1,240 @@
1/**
2 * Block J29 — Pull request lead-time metric.
3 *
4 * Lead time = `mergedAt - createdAt` for merged PRs. For still-open PRs, we
5 * report the "in-flight" age from now. Open-but-unmerged PRs are excluded
6 * from the summary percentiles; they roll into a separate counter so the
7 * KPIs aren't biased downward by ancient stale drafts.
8 *
9 * Reuses `parseWindow`, `VALID_WINDOWS`, `formatDuration` from the Block J25
10 * response-time helpers to stay DRY + consistent.
11 */
12
13import {
14 parseWindow as parseWindowJ25,
15 VALID_WINDOWS as VALID_WINDOWS_J25,
16 formatDuration as formatDurationJ25,
17 DEFAULT_WINDOW_DAYS as DEFAULT_WINDOW_DAYS_J25,
18} from "./response-time";
19
20export const DEFAULT_WINDOW_DAYS = DEFAULT_WINDOW_DAYS_J25;
21export const VALID_WINDOWS = VALID_WINDOWS_J25;
22export const parseWindow = parseWindowJ25;
23export const formatDuration = formatDurationJ25;
24
25const HOUR = 60 * 60 * 1000;
26const DAY = 24 * HOUR;
27
28export interface PrLeadTimeInput {
29 id: string;
30 number: number;
31 title: string;
32 state: string; // "open" | "closed" | "merged" (+ anything else)
33 isDraft?: boolean;
34 createdAt: Date | string;
35 mergedAt?: Date | string | null;
36}
37
38export interface PrLeadTimeStat {
39 id: string;
40 number: number;
41 title: string;
42 state: string;
43 isDraft: boolean;
44 createdAt: number;
45 mergedAt: number | null;
46 leadMs: number | null; // null for unmerged
47 inFlightMs: number | null; // null for merged/closed
48}
49
50export interface PrLeadTimeSummary {
51 total: number;
52 merged: number;
53 openNonDraft: number;
54 openDraft: number;
55 closedUnmerged: number;
56 medianMs: number | null;
57 meanMs: number | null;
58 p90Ms: number | null;
59 fastestMs: number | null;
60 slowestMs: number | null;
61}
62
63export interface PrLeadTimeBuckets {
64 /** Merged ≤ 1 hour. */
65 within1h: number;
66 /** > 1h and ≤ 24h. */
67 within1d: number;
68 /** > 24h and ≤ 7d. */
69 within1w: number;
70 /** > 7d. */
71 over1w: number;
72}
73
74export interface PrLeadTimeReport {
75 windowDays: number;
76 now: number;
77 perPr: PrLeadTimeStat[];
78 summary: PrLeadTimeSummary;
79 buckets: PrLeadTimeBuckets;
80 /** Oldest still-open (non-draft) PRs — id list, for the in-flight table. */
81 oldestOpenIds: string[];
82}
83
84function toTime(v: Date | string | null | undefined): number | null {
85 if (v === null || v === undefined) return null;
86 if (v instanceof Date) {
87 const t = v.getTime();
88 return Number.isNaN(t) ? null : t;
89 }
90 if (typeof v === "string") {
91 const t = new Date(v).getTime();
92 return Number.isNaN(t) ? null : t;
93 }
94 return null;
95}
96
97export function computeLeadTime(
98 input: { createdAt: Date | string; mergedAt?: Date | string | null }
99): number | null {
100 const created = toTime(input.createdAt);
101 const merged = toTime(input.mergedAt);
102 if (created === null || merged === null) return null;
103 return Math.max(0, merged - created);
104}
105
106export function computePrStats(
107 prs: readonly PrLeadTimeInput[],
108 windowDays: number,
109 now: number
110): PrLeadTimeStat[] {
111 const cutoff =
112 windowDays > 0 ? now - windowDays * DAY : Number.NEGATIVE_INFINITY;
113 const out: PrLeadTimeStat[] = [];
114 for (const pr of prs) {
115 const created = toTime(pr.createdAt);
116 if (created === null) continue; // unparseable → drop
117 const merged = toTime(pr.mergedAt ?? null);
118 // Window filter: anchor on mergedAt for merged PRs, createdAt for the rest.
119 const anchor = merged ?? created;
120 if (anchor < cutoff) continue;
121 const leadMs =
122 merged !== null ? Math.max(0, merged - created) : null;
123 const isMerged = merged !== null;
124 const inFlightMs =
125 !isMerged && pr.state === "open" ? Math.max(0, now - created) : null;
126 out.push({
127 id: pr.id,
128 number: pr.number,
129 title: pr.title,
130 state: pr.state,
131 isDraft: !!pr.isDraft,
132 createdAt: created,
133 mergedAt: merged,
134 leadMs,
135 inFlightMs,
136 });
137 }
138 return out;
139}
140
141function percentile(sorted: readonly number[], p: number): number | null {
142 if (sorted.length === 0) return null;
143 if (sorted.length === 1) return sorted[0]!;
144 const rank = (p / 100) * (sorted.length - 1);
145 const lo = Math.floor(rank);
146 const hi = Math.ceil(rank);
147 if (lo === hi) return sorted[lo]!;
148 const w = rank - lo;
149 return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * w;
150}
151
152export function summariseLeadTimes(
153 stats: readonly PrLeadTimeStat[]
154): PrLeadTimeSummary {
155 const merged = stats.filter(
156 (s): s is PrLeadTimeStat & { leadMs: number } => s.leadMs !== null
157 );
158 const leadMs = merged.map((s) => s.leadMs).sort((a, b) => a - b);
159 const openNonDraft = stats.filter(
160 (s) => s.leadMs === null && s.state === "open" && !s.isDraft
161 ).length;
162 const openDraft = stats.filter(
163 (s) => s.leadMs === null && s.state === "open" && s.isDraft
164 ).length;
165 const closedUnmerged = stats.filter(
166 (s) => s.leadMs === null && s.state !== "open"
167 ).length;
168 const sum = leadMs.reduce((a, b) => a + b, 0);
169 const mean = leadMs.length > 0 ? Math.round(sum / leadMs.length) : null;
170 const med = percentile(leadMs, 50);
171 const p90 = percentile(leadMs, 90);
172 return {
173 total: stats.length,
174 merged: merged.length,
175 openNonDraft,
176 openDraft,
177 closedUnmerged,
178 medianMs: med === null ? null : Math.round(med),
179 meanMs: mean,
180 p90Ms: p90 === null ? null : Math.round(p90),
181 fastestMs: leadMs.length > 0 ? leadMs[0]! : null,
182 slowestMs: leadMs.length > 0 ? leadMs[leadMs.length - 1]! : null,
183 };
184}
185
186export function bucketLeadTimes(
187 stats: readonly PrLeadTimeStat[]
188): PrLeadTimeBuckets {
189 const out: PrLeadTimeBuckets = {
190 within1h: 0,
191 within1d: 0,
192 within1w: 0,
193 over1w: 0,
194 };
195 for (const s of stats) {
196 if (s.leadMs === null) continue;
197 if (s.leadMs <= HOUR) out.within1h++;
198 else if (s.leadMs <= DAY) out.within1d++;
199 else if (s.leadMs <= 7 * DAY) out.within1w++;
200 else out.over1w++;
201 }
202 return out;
203}
204
205export interface BuildReportOptions {
206 prs: readonly PrLeadTimeInput[];
207 windowDays: number;
208 now?: number;
209}
210
211export function buildLeadTimeReport(
212 opts: BuildReportOptions
213): PrLeadTimeReport {
214 const now = opts.now ?? Date.now();
215 const perPr = computePrStats(opts.prs, opts.windowDays, now);
216 const open = perPr
217 .filter((s) => s.leadMs === null && s.state === "open" && !s.isDraft)
218 .sort((a, b) => a.createdAt - b.createdAt);
219 return {
220 windowDays: opts.windowDays,
221 now,
222 perPr,
223 summary: summariseLeadTimes(perPr),
224 buckets: bucketLeadTimes(perPr),
225 oldestOpenIds: open.map((s) => s.id),
226 };
227}
228
229export const __internal = {
230 DEFAULT_WINDOW_DAYS,
231 VALID_WINDOWS,
232 parseWindow,
233 formatDuration,
234 computeLeadTime,
235 computePrStats,
236 summariseLeadTimes,
237 bucketLeadTimes,
238 buildLeadTimeReport,
239 toTime,
240};
Modifiedsrc/routes/insights.tsx+6−0View fileUnifiedSplit
@@ -158,6 +158,12 @@ insights.get("/:owner/:repo/insights", async (c) => {
158158 >
159159 Response time →
160160 </a>
161 <a
162 href={`/${owner}/${repo}/insights/lead-time`}
163 style="font-size: 12px; color: var(--accent)"
164 >
165 Lead time →
166 </a>
161167 <a
162168 href={`/${owner}/${repo}/pulse`}
163169 style="font-size: 12px; color: var(--accent)"
Addedsrc/routes/pr-lead-time.tsx+252−0View fileUnifiedSplit
@@ -0,0 +1,252 @@
1/**
2 * Block J29 — PR lead-time insights page.
3 *
4 * GET /:owner/:repo/insights/lead-time[?window=7|30|90|365|0]
5 *
6 * softAuth, read-only. Fetches PRs for the repo, runs the pure
7 * `buildLeadTimeReport`, renders a KPI grid (p50/mean/p90/fastest/slowest),
8 * four latency buckets, and the oldest still-open PRs.
9 */
10
11import { Hono } from "hono";
12import { eq, and } from "drizzle-orm";
13import { db } from "../db";
14import { repositories, users, pullRequests } from "../db/schema";
15import { Layout } from "../views/layout";
16import { RepoHeader } from "../views/components";
17import { softAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 buildLeadTimeReport,
21 formatDuration,
22 parseWindow,
23 VALID_WINDOWS,
24 type PrLeadTimeInput,
25} from "../lib/pr-lead-time";
26
27const prLeadTimeRoutes = new Hono<AuthEnv>();
28
29prLeadTimeRoutes.use("*", softAuth);
30
31async function resolveRepo(ownerName: string, repoName: string) {
32 try {
33 const [owner] = await db
34 .select()
35 .from(users)
36 .where(eq(users.username, ownerName))
37 .limit(1);
38 if (!owner) return null;
39 const [repo] = await db
40 .select()
41 .from(repositories)
42 .where(
43 and(
44 eq(repositories.ownerId, owner.id),
45 eq(repositories.name, repoName)
46 )
47 )
48 .limit(1);
49 if (!repo) return null;
50 return { owner, repo };
51 } catch {
52 return null;
53 }
54}
55
56prLeadTimeRoutes.get(
57 "/:owner/:repo/insights/lead-time",
58 async (c) => {
59 const { owner: ownerName, repo: repoName } = c.req.param();
60 const user = c.get("user");
61 const windowDays = parseWindow(c.req.query("window"));
62
63 const resolved = await resolveRepo(ownerName, repoName);
64 if (!resolved) {
65 return c.html(
66 <Layout title="Not Found" user={user}>
67 <div class="empty-state">
68 <h2>Repository not found</h2>
69 </div>
70 </Layout>,
71 404
72 );
73 }
74
75 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
76 return c.html(
77 <Layout title="Not Found" user={user}>
78 <div class="empty-state">
79 <h2>Repository not found</h2>
80 </div>
81 </Layout>,
82 404
83 );
84 }
85
86 let prRows: {
87 id: string;
88 number: number;
89 title: string;
90 state: string;
91 isDraft: boolean;
92 createdAt: Date;
93 mergedAt: Date | null;
94 }[] = [];
95 try {
96 prRows = await db
97 .select({
98 id: pullRequests.id,
99 number: pullRequests.number,
100 title: pullRequests.title,
101 state: pullRequests.state,
102 isDraft: pullRequests.isDraft,
103 createdAt: pullRequests.createdAt,
104 mergedAt: pullRequests.mergedAt,
105 })
106 .from(pullRequests)
107 .where(eq(pullRequests.repositoryId, resolved.repo.id))
108 .limit(2000);
109 } catch {
110 // empty → empty report
111 }
112
113 const inputs: PrLeadTimeInput[] = prRows.map((r) => ({
114 id: r.id,
115 number: r.number,
116 title: r.title,
117 state: r.state,
118 isDraft: r.isDraft,
119 createdAt: r.createdAt,
120 mergedAt: r.mergedAt,
121 }));
122 const report = buildLeadTimeReport({ prs: inputs, windowDays });
123
124 const oldestOpen = report.oldestOpenIds
125 .map((id) => report.perPr.find((p) => p.id === id))
126 .filter((p): p is NonNullable<typeof p> => !!p)
127 .slice(0, 25);
128
129 const windowLabel =
130 windowDays === 0 ? "All time" : `Last ${windowDays} days`;
131
132 const kpi = (label: string, value: string) => (
133 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
134 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
135 {label}
136 </div>
137 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
138 {value}
139 </div>
140 </div>
141 );
142
143 return c.html(
144 <Layout title={`Lead time — ${ownerName}/${repoName}`} user={user}>
145 <RepoHeader owner={ownerName} repo={repoName} />
146 <div style="max-width: 920px">
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
148 <h2 style="margin: 0">PR lead time</h2>
149 <form
150 method="GET"
151 action={`/${ownerName}/${repoName}/insights/lead-time`}
152 style="display: flex; gap: 6px; align-items: center"
153 >
154 <label for="window" style="font-size: 12px; color: var(--text-muted)">
155 Window:
156 </label>
157 <select
158 id="window"
159 name="window"
160 onchange="this.form.submit()"
161 style="padding: 4px 8px; font-size: 12px"
162 >
163 {VALID_WINDOWS.map((w) => (
164 <option value={String(w)} selected={w === windowDays}>
165 {w === 0 ? "All time" : `Last ${w} days`}
166 </option>
167 ))}
168 </select>
169 </form>
170 </div>
171 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
172 <strong>{windowLabel}</strong>. Lead time = time from PR opened to
173 merged. Open PRs show as "in-flight" and roll into a separate
174 counter so the percentiles aren't skewed by stale drafts.
175 </p>
176
177 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px">
178 {kpi("Total PRs", String(report.summary.total))}
179 {kpi("Merged", String(report.summary.merged))}
180 {kpi("Open (non-draft)", String(report.summary.openNonDraft))}
181 {kpi("Drafts", String(report.summary.openDraft))}
182 {kpi("Median (p50)", formatDuration(report.summary.medianMs))}
183 {kpi("Mean", formatDuration(report.summary.meanMs))}
184 {kpi("p90", formatDuration(report.summary.p90Ms))}
185 {kpi("Fastest", formatDuration(report.summary.fastestMs))}
186 {kpi("Slowest", formatDuration(report.summary.slowestMs))}
187 </div>
188
189 <h3 style="margin-bottom: 10px">Merge-time distribution</h3>
190 <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 24px">
191 {[
192 ["≤ 1 hour", report.buckets.within1h],
193 ["1h – 1 day", report.buckets.within1d],
194 ["1d – 1 week", report.buckets.within1w],
195 ["> 1 week", report.buckets.over1w],
196 ].map(([label, count]) => (
197 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center">
198 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
199 {label}
200 </div>
201 <div style="font-size: 18px; font-weight: 600">{count}</div>
202 </div>
203 ))}
204 </div>
205
206 <h3 style="margin-bottom: 10px">
207 Oldest open PRs ({oldestOpen.length}
208 {report.oldestOpenIds.length > oldestOpen.length
209 ? ` of ${report.oldestOpenIds.length}`
210 : ""}
211 )
212 </h3>
213 {oldestOpen.length === 0 ? (
214 <div class="empty-state">
215 <p>No open PRs. Nice.</p>
216 </div>
217 ) : (
218 <table style="width: 100%; border-collapse: collapse">
219 <thead>
220 <tr>
221 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
222 PR
223 </th>
224 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 140px">
225 In flight
226 </th>
227 </tr>
228 </thead>
229 <tbody>
230 {oldestOpen.map((p) => (
231 <tr>
232 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
233 <a href={`/${ownerName}/${repoName}/pulls/${p.number}`}>
234 <span style="color: var(--text-muted)">#{p.number}</span>{" "}
235 {p.title}
236 </a>
237 </td>
238 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
239 {formatDuration(p.inFlightMs)}
240 </td>
241 </tr>
242 ))}
243 </tbody>
244 </table>
245 )}
246 </div>
247 </Layout>
248 );
249 }
250);
251
252export default prLeadTimeRoutes;
0253