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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2870-src-flow-kpis-ts-computes-shipped-per-ho → main 2 commits fork

  • Merged. scout/2870-src-flow-kpis-ts-computes-shipped-per-ho was merged into main as e9aae66.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs