Public: anyone can read this; the crew can change it.
src/flow/kpis.ts computes shipped-per-hour, the live version and the median check time as plain data #2876
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2870-src-flow-kpis-ts-computes-shipped-per-ho → main 2 commits fork
2 files
changed +204
−0
src/flow/kpis.ts added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | /** | |
| 2 | * The home page's three figures (#2870), as plain data: what landed on main | |
| 3 | * per hour over the last 24 hours, the version that is live and since when, | |
| 4 | * and the median check time over the same day. | |
| 5 | * | |
| 6 | * Hours are counted back from `now`, as in `throughput.ts`: bucket 0 is the | |
| 7 | * last hour, bucket 23 the one that ended 23 hours ago. Nothing here reads | |
| 8 | * the clock. One query per figure. | |
| 9 | * | |
| 10 | * A landing counts when it put something on main. A pull that closed as | |
| 11 | * carried (its change was already there, #2036) lands no commit: its | |
| 12 | * `merged_oid` is the base it found, or a commit an earlier pull landed. | |
| 13 | * Those are not counted. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { Database } from "bun:sqlite"; | |
| 17 | import { HOURS, median } from "./throughput.ts"; | |
| 18 | ||
| 19 | const HOUR_MS = 3_600_000; | |
| 20 | ||
| 21 | export interface Kpis { | |
| 22 | /** Non-empty landings on a default branch per hour, bucket 0 the last hour. */ | |
| 23 | readonly landedHours: readonly number[]; | |
| 24 | /** The sum of `landedHours`. */ | |
| 25 | readonly landed: number; | |
| 26 | /** The newest production deploy that serves, or null when none has. */ | |
| 27 | readonly live: { readonly sha: string; readonly at: string } | null; | |
| 28 | /** Median check duration in milliseconds over checks that finished in 24 h; null for none. */ | |
| 29 | readonly medianCheckMs: number | null; | |
| 30 | } | |
| 31 | ||
| 32 | export function readKpis(db: Database, now: Date): Kpis { | |
| 33 | const end = now.getTime(); | |
| 34 | const to = now.toISOString(); | |
| 35 | const since = new Date(end - HOURS * HOUR_MS).toISOString(); | |
| 36 | ||
| 37 | const landedHours = new Array<number>(HOURS).fill(0); | |
| 38 | const landings = db | |
| 39 | .query<{ at: string }, [string, string]>( | |
| 40 | `SELECT p.merged_at AS at | |
| 41 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id | |
| 42 | WHERE p.base_branch = r.default_branch | |
| 43 | AND p.merged_at >= ?1 AND p.merged_at <= ?2 | |
| 44 | AND p.merged_oid IS NOT NULL AND p.merged_oid != '' AND p.merged_oid != p.base_oid | |
| 45 | AND NOT EXISTS ( | |
| 46 | SELECT 1 FROM pull q JOIN issue j ON j.id = q.issue_id | |
| 47 | WHERE j.repo_id = i.repo_id AND q.merged_oid = p.merged_oid | |
| 48 | AND (q.merged_at < p.merged_at OR (q.merged_at = p.merged_at AND q.issue_id < p.issue_id)))`, | |
| 49 | ) | |
| 50 | .all(since, to); | |
| 51 | for (const { at } of landings) { | |
| 52 | const h = Math.floor((end - Date.parse(at)) / HOUR_MS); | |
| 53 | if (h >= 0 && h < HOURS) landedHours[h]! += 1; | |
| 54 | } | |
| 55 | ||
| 56 | const live = db | |
| 57 | .query<{ sha: string; at: string }, [string]>( | |
| 58 | `SELECT commit_oid AS sha, serving_at AS at FROM run | |
| 59 | WHERE kind = 'deploy' AND env = 'production' AND serving_at IS NOT NULL AND serving_at <= ?1 | |
| 60 | ORDER BY serving_at DESC LIMIT 1`, | |
| 61 | ) | |
| 62 | .get(to); | |
| 63 | ||
| 64 | const spans = db | |
| 65 | .query<{ ms: number }, [string, string]>( | |
| 66 | `SELECT (julianday(finished_at) - julianday(started_at)) * 86400000.0 AS ms FROM run | |
| 67 | WHERE kind = 'check' AND state IN ('ok','failed') AND started_at IS NOT NULL | |
| 68 | AND finished_at >= ?1 AND finished_at <= ?2`, | |
| 69 | ) | |
| 70 | .all(since, to) | |
| 71 | .map((r) => Math.round(r.ms)); | |
| 72 | ||
| 73 | return { | |
| 74 | landedHours, | |
| 75 | landed: landedHours.reduce((a, b) => a + b, 0), | |
| 76 | live: live ?? null, | |
| 77 | medianCheckMs: median(spans), | |
| 78 | }; | |
| 79 | } |
test/home-kpis.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * The home page's figures as plain data (#2870): seeded landings, deploys and | |
| 3 | * checks give the expected numbers, a pull that landed no commit is not | |
| 4 | * counted, and an empty forge gives zeros and a null median. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openIssue } from "../src/actions/issues.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import type { Repo } from "../src/core/repos.ts"; | |
| 15 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { readKpis } from "../src/flow/kpis.ts"; | |
| 18 | ||
| 19 | const HOUR = 3_600_000; | |
| 20 | const MIN = 60_000; | |
| 21 | const NOW = Date.parse("2026-10-19T12:00:00.000Z"); | |
| 22 | const ago = (ms: number) => new Date(NOW - ms).toISOString(); | |
| 23 | ||
| 24 | describe("home kpis", () => { | |
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let repo: Repo; | |
| 28 | let n = 0; | |
| 29 | ||
| 30 | const setup = async () => { | |
| 31 | dataDir = await mkdtemp(join(tmpdir(), "forge-kpis-")); | |
| 32 | app = createApp({ dataDir, port: 0 }); | |
| 33 | const a = await createUser(app.db, { | |
| 34 | origin: "seed", | |
| 35 | trusted: true, | |
| 36 | username: "alice", | |
| 37 | email: "[email protected]", | |
| 38 | password: "a-good-passphrase", | |
| 39 | adminIfFirst: true, | |
| 40 | }); | |
| 41 | if (!a.ok) throw new Error(a.error); | |
| 42 | const made = await createRepo(app, a.value, { name: "shop" }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | repo = made.value; | |
| 45 | return a.value; | |
| 46 | }; | |
| 47 | const landing = (user: Awaited<ReturnType<typeof setup>>, mergedAt: string, oid: string, baseOid: string) => { | |
| 48 | const issue = openIssue(app, user, repo, { title: `Pull ${++n}`, body: "" }); | |
| 49 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 50 | app.db.query("UPDATE issue SET kind = 'pull', state = 'closed' WHERE id = ?1").run(issue.value.id); | |
| 51 | app.db | |
| 52 | .query( | |
| 53 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid) | |
| 54 | VALUES (?1, ?2, ?3, ?4, '', ?5, ?6)`, | |
| 55 | ) | |
| 56 | .run(issue.value.id, repo.defaultBranch, `b/${n}`, baseOid, mergedAt, oid); | |
| 57 | }; | |
| 58 | const run = (kind: "check" | "deploy", set: Record<string, string | null>, extra = {}) => { | |
| 59 | const r = createRun(app.db, { repoId: repo.id, kind, ...extra }); | |
| 60 | for (const [column, value] of Object.entries(set)) | |
| 61 | app.db.query(`UPDATE run SET ${column} = ?1 WHERE id = ?2`).run(value, r.id); | |
| 62 | }; | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | app?.db.close(); | |
| 66 | await rm(dataDir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | test("an empty forge gives zeros and a null median", async () => { | |
| 70 | await setup(); | |
| 71 | const k = readKpis(app.db, new Date(NOW)); | |
| 72 | expect(k.landedHours).toEqual(new Array(24).fill(0)); | |
| 73 | expect(k.landed).toBe(0); | |
| 74 | expect(k.live).toBeNull(); | |
| 75 | expect(k.medianCheckMs).toBeNull(); | |
| 76 | }); | |
| 77 | ||
| 78 | describe("a seeded forge", () => { | |
| 79 | beforeAll(async () => { | |
| 80 | await rm(dataDir, { recursive: true, force: true }); | |
| 81 | const alice = await setup(); | |
| 82 | landing(alice, ago(10 * MIN), "c1", "b0"); | |
| 83 | landing(alice, ago(40 * MIN), "c2", "c1"); | |
| 84 | landing(alice, ago(70 * MIN), "c3", "c2"); | |
| 85 | landing(alice, ago(23 * HOUR + 30 * MIN), "c4", "c3"); | |
| 86 | landing(alice, ago(25 * HOUR), "c0", "bz"); // outside the day | |
| 87 | landing(alice, ago(20 * MIN), "c3", "c2"); // carried by an earlier landing | |
| 88 | landing(alice, ago(30 * MIN), "c2", "c2"); // empty: merged into the base it found | |
| 89 | run("deploy", { state: "ok", commit_oid: "old", serving_at: ago(30 * HOUR) }, { env: "production" }); | |
| 90 | run("deploy", { state: "ok", commit_oid: "c2", serving_at: ago(35 * MIN) }, { env: "production" }); | |
| 91 | run("deploy", { state: "ok", commit_oid: "pre", serving_at: ago(5 * MIN) }, { env: "preview" }); | |
| 92 | run("check", { state: "ok", started_at: ago(30 * MIN), finished_at: ago(20 * MIN) }); // 10 min | |
| 93 | run("check", { state: "failed", started_at: ago(2 * HOUR), finished_at: ago(2 * HOUR - 20 * MIN) }); // 20 min | |
| 94 | run("check", { state: "ok", started_at: ago(5 * HOUR), finished_at: ago(5 * HOUR - 60 * MIN) }); // 60 min | |
| 95 | run("check", { state: "ok", started_at: ago(30 * HOUR), finished_at: ago(29 * HOUR) }); // too old | |
| 96 | run("check", { state: "cancelled", started_at: ago(1 * HOUR), finished_at: ago(50 * MIN) }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("landings per hour, the empty ones left out", () => { | |
| 100 | const k = readKpis(app.db, new Date(NOW)); | |
| 101 | expect(k.landedHours[0]).toBe(2); | |
| 102 | expect(k.landedHours[1]).toBe(1); | |
| 103 | expect(k.landedHours[23]).toBe(1); | |
| 104 | expect(k.landedHours).toHaveLength(24); | |
| 105 | expect(k.landed).toBe(4); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the newest serving production deploy is live", () => { | |
| 109 | expect(readKpis(app.db, new Date(NOW)).live).toEqual({ sha: "c2", at: ago(35 * MIN) }); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the median check time is over finished checks of the day", () => { | |
| 113 | expect(readKpis(app.db, new Date(NOW)).medianCheckMs).toBe(20 * MIN); | |
| 114 | }); | |
| 115 | ||
| 116 | test("times come from now, not the clock", () => { | |
| 117 | const later = readKpis(app.db, new Date(NOW + 2 * HOUR)); | |
| 118 | expect(later.landedHours[2]).toBe(2); | |
| 119 | expect(later.landedHours[0]).toBe(0); | |
| 120 | expect(later.landed).toBe(3); | |
| 121 | const earlier = readKpis(app.db, new Date(NOW - 45 * MIN)); | |
| 122 | expect(earlier.live?.sha).toBe("old"); | |
| 123 | }); | |
| 124 | }); | |
| 125 | }); |
new above
offline