Public: anyone can read this; the crew can change it.
A pure read says the time to land: the median and the slowest hours from a pull's opening to its landing over the last 24 h #6447
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6442-a-pure-read-says-the-time-to-land-the-me → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +125
−0
src/flow/lead_time.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The lead time (#6442, flow KPI "time to clear" of #5930): the median and the | |
| 3 | * slowest hours from a pull's opening to its landing, over pulls merged into | |
| 4 | * their repository's default branch in the last 24 h. Counts and hours only, | |
| 5 | * no repository named; `publicOnly` follows `readVitals`. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | ||
| 10 | const HOUR_MS = 3_600_000; | |
| 11 | ||
| 12 | export interface LeadTime { | |
| 13 | readonly landed: number; | |
| 14 | readonly medianHours: number | null; | |
| 15 | readonly maxHours: number | null; | |
| 16 | } | |
| 17 | ||
| 18 | export function readLeadTime(db: Database, now = new Date(), publicOnly = false): LeadTime { | |
| 19 | const from = new Date(now.getTime() - 24 * HOUR_MS).toISOString(); | |
| 20 | const rows = db | |
| 21 | .query<{ hours: number }, [string, string, number]>( | |
| 22 | `SELECT (julianday(p.merged_at) - julianday(i.created_at)) * 24 AS hours | |
| 23 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 24 | WHERE (?3 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch | |
| 25 | AND p.merged_at >= ?1 AND p.merged_at <= ?2 | |
| 26 | ORDER BY hours`, | |
| 27 | ) | |
| 28 | .all(from, now.toISOString(), publicOnly ? 1 : 0); | |
| 29 | const n = rows.length; | |
| 30 | if (n === 0) return { landed: 0, medianHours: null, maxHours: null }; | |
| 31 | const hours = rows.map((r) => Math.max(0, r.hours)); | |
| 32 | const mid = n >> 1; | |
| 33 | const median = n % 2 ? hours[mid]! : (hours[mid - 1]! + hours[mid]!) / 2; | |
| 34 | return { landed: n, medianHours: median, maxHours: hours[n - 1]! }; | |
| 35 | } |
test/flow-lead-time.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** The lead time (#6442): median and slowest hours from opening to landing over the last 24 h. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { id } from "../src/kernel/id.ts"; | |
| 10 | import { readLeadTime } from "../src/flow/lead_time.ts"; | |
| 11 | ||
| 12 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 13 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 14 | ||
| 15 | async function seed() { | |
| 16 | const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-lead-")), port: 0 }); | |
| 17 | const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 18 | if (!u.ok) throw new Error(u.error); | |
| 19 | let n = 0; | |
| 20 | const repo = async (name: string, visibility: "public" | "private") => { | |
| 21 | const r = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name, visibility, description: name }); | |
| 22 | if (!r.ok) throw new Error(r.error); | |
| 23 | return { | |
| 24 | pull: (opened: number, merged: number, base = r.value.defaultBranch) => { | |
| 25 | const issueId = id(); | |
| 26 | n++; | |
| 27 | app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'P', '', ?4, 'closed', ?5)`).run(issueId, r.value.id, n, u.value.id, ago(opened)); | |
| 28 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`).run(issueId, base, `b/${n}`, r.value.id, ago(merged)); | |
| 29 | }, | |
| 30 | }; | |
| 31 | }; | |
| 32 | return { app, repo }; | |
| 33 | } | |
| 34 | ||
| 35 | describe("lead time", () => { | |
| 36 | test("none landed gives null hours", async () => { | |
| 37 | const s = await seed(); | |
| 38 | expect(readLeadTime(s.app.db, NOW)).toEqual({ landed: 0, medianHours: null, maxHours: null }); | |
| 39 | }); | |
| 40 | ||
| 41 | test("three landed pulls give the median and the slowest", async () => { | |
| 42 | const s = await seed(); | |
| 43 | const r = await s.repo("r", "public"); | |
| 44 | r.pull(3, 2); // 1 h | |
| 45 | r.pull(8, 4); // 4 h | |
| 46 | r.pull(12, 1); // 11 h | |
| 47 | const t = readLeadTime(s.app.db, NOW); | |
| 48 | expect(t.landed).toBe(3); | |
| 49 | expect(t.medianHours).toBeCloseTo(4, 6); | |
| 50 | expect(t.maxHours).toBeCloseTo(11, 6); | |
| 51 | }); | |
| 52 | ||
| 53 | test("an even count averages the middle two", async () => { | |
| 54 | const s = await seed(); | |
| 55 | const r = await s.repo("r", "public"); | |
| 56 | r.pull(3, 2); // 1 h | |
| 57 | r.pull(8, 4); // 4 h | |
| 58 | expect(readLeadTime(s.app.db, NOW).medianHours).toBeCloseTo(2.5, 6); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a pull merged into a non-default branch is left out", async () => { | |
| 62 | const s = await seed(); | |
| 63 | const r = await s.repo("r", "public"); | |
| 64 | r.pull(3, 2); | |
| 65 | r.pull(20, 1, "side"); | |
| 66 | const t = readLeadTime(s.app.db, NOW); | |
| 67 | expect(t.landed).toBe(1); | |
| 68 | expect(t.maxHours).toBeCloseTo(1, 6); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a pull merged more than 24 h ago is left out", async () => { | |
| 72 | const s = await seed(); | |
| 73 | const r = await s.repo("r", "public"); | |
| 74 | r.pull(3, 2); | |
| 75 | r.pull(40, 25); | |
| 76 | expect(readLeadTime(s.app.db, NOW).landed).toBe(1); | |
| 77 | }); | |
| 78 | ||
| 79 | test("publicOnly hides private repositories", async () => { | |
| 80 | const s = await seed(); | |
| 81 | const pub = await s.repo("pub", "public"); | |
| 82 | const priv = await s.repo("priv", "private"); | |
| 83 | pub.pull(3, 2); | |
| 84 | priv.pull(20, 1); | |
| 85 | expect(readLeadTime(s.app.db, NOW).landed).toBe(2); | |
| 86 | const t = readLeadTime(s.app.db, NOW, true); | |
| 87 | expect(t.landed).toBe(1); | |
| 88 | expect(t.maxHours).toBeCloseTo(1, 6); | |
| 89 | }); | |
| 90 | }); |
new above
offline
The Math.max(0) clamp is untested. Add a test with a pull merged before it was opened, so a later edit cannot silently drop it.