Public: anyone can read this; the crew can change it.
A pure read says who opened the last hour's pulls: per author, how many, and how many of those serve a flow issue #6449
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6443-a-pure-read-says-who-opened-the-last-hou → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +154
−0
src/flow/openers.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * Who opened the last hour's pulls (#6443): per author, how many pulls opened | |
| 3 | * in the hour before `now`, and how many of those serve a flow issue. A pull | |
| 4 | * serves flow when the issue its description closes (`Closes #N`) is one of | |
| 5 | * the flow issues, or waits on one in the dependency graph, however far. | |
| 6 | * Most first, ties by name. Same `publicOnly` rule as `readVitals`; two reads. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { closesFromBody } from "../core/pulls.ts"; | |
| 11 | ||
| 12 | const HOUR_MS = 3_600_000; | |
| 13 | ||
| 14 | /** The flow issues of #5930: flow itself, the freeze, the steward's probe. */ | |
| 15 | export const FLOW_ISSUES: readonly number[] = [5930, 6217, 5994]; | |
| 16 | ||
| 17 | export interface Opener { | |
| 18 | readonly author: string; | |
| 19 | readonly opened: number; | |
| 20 | readonly flow: number; | |
| 21 | } | |
| 22 | ||
| 23 | interface PullRow { | |
| 24 | author: string; | |
| 25 | repo_id: string; | |
| 26 | body: string; | |
| 27 | } | |
| 28 | ||
| 29 | export function readOpeners(db: Database, now = new Date(), publicOnly = false): Opener[] { | |
| 30 | const to = now.toISOString(); | |
| 31 | const from = new Date(now.getTime() - HOUR_MS).toISOString(); | |
| 32 | const pub = publicOnly ? 1 : 0; | |
| 33 | const flowRows = db | |
| 34 | .query<{ key: string }, [number, number, number, number]>( | |
| 35 | `WITH RECURSIVE flow(id) AS ( | |
| 36 | SELECT i.id FROM issue i JOIN repo r ON r.id = i.repo_id | |
| 37 | WHERE i.number IN (?1, ?2, ?3) AND (?4 = 0 OR r.visibility = 'public') | |
| 38 | UNION | |
| 39 | SELECT d.issue_id FROM issue_dependency d JOIN flow f ON f.id = d.blocked_by_id | |
| 40 | ) | |
| 41 | SELECT i.repo_id || '#' || i.number AS key FROM issue i JOIN flow ON flow.id = i.id`, | |
| 42 | ) | |
| 43 | .all(FLOW_ISSUES[0]!, FLOW_ISSUES[1]!, FLOW_ISSUES[2]!, pub); | |
| 44 | const flow = new Set(flowRows.map((r) => r.key)); | |
| 45 | const pulls = db | |
| 46 | .query<PullRow, [string, string, number]>( | |
| 47 | `SELECT u.username AS author, i.repo_id AS repo_id, i.body AS body | |
| 48 | FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id JOIN user u ON u.id = i.author_id | |
| 49 | WHERE i.created_at >= ?1 AND i.created_at <= ?2 AND (?3 = 0 OR r.visibility = 'public')`, | |
| 50 | ) | |
| 51 | .all(from, to, pub); | |
| 52 | const by = new Map<string, { opened: number; flow: number }>(); | |
| 53 | for (const p of pulls) { | |
| 54 | const t = by.get(p.author) ?? { opened: 0, flow: 0 }; | |
| 55 | t.opened++; | |
| 56 | if (closesFromBody(p.body).some((n) => flow.has(`${p.repo_id}#${n}`))) t.flow++; | |
| 57 | by.set(p.author, t); | |
| 58 | } | |
| 59 | return [...by].map(([author, t]) => ({ author, ...t })).sort((a, b) => b.opened - a.opened || a.author.localeCompare(b.author)); | |
| 60 | } |
test/flow-openers.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** Who opened the last hour's pulls (#6443). */ | |
| 2 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { id } from "../src/kernel/id.ts"; | |
| 9 | import { readOpeners } from "../src/flow/openers.ts"; | |
| 10 | ||
| 11 | const NOW = new Date("2026-10-26T12:00:00.000Z"); | |
| 12 | const ago = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString(); | |
| 13 | ||
| 14 | describe("readOpeners", () => { | |
| 15 | let dir: string; | |
| 16 | let app: App; | |
| 17 | let users: Record<string, string>; | |
| 18 | let pub: string; | |
| 19 | let priv: string; | |
| 20 | let n = 0; | |
| 21 | ||
| 22 | const repo = (name: string, visibility: string, owner: string) => { | |
| 23 | const rid = id(); | |
| 24 | app.db.query(`INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)`).run(rid, owner, name, visibility); | |
| 25 | return rid; | |
| 26 | }; | |
| 27 | const issue = (repoId: string, number: number, author: string, kind = "issue", body = "", at = ago(600)) => { | |
| 28 | const iid = id(); | |
| 29 | app.db | |
| 30 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, created_at) VALUES (?1, ?2, ?3, ?4, 't', ?5, ?6, ?7)`) | |
| 31 | .run(iid, repoId, number, kind, body, author, at); | |
| 32 | return iid; | |
| 33 | }; | |
| 34 | const pull = (repoId: string, author: string, body: string, min: number) => { | |
| 35 | const iid = issue(repoId, 100000 + ++n, author, "pull", body, ago(min)); | |
| 36 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', '')`).run(iid, `b/${n}`); | |
| 37 | }; | |
| 38 | ||
| 39 | beforeEach(async () => { | |
| 40 | dir = mkdtempSync(join(tmpdir(), "forge-openers-")); | |
| 41 | app = createApp({ dataDir: dir, port: 0 }); | |
| 42 | users = {}; | |
| 43 | for (const name of ["alice", "bob"]) { | |
| 44 | const u = await createUser(app.db, { username: name, email: `${name}@forge.test`, password: "a-good-passphrase", origin: "seed", trusted: true }); | |
| 45 | if (!u.ok) throw new Error(u.error); | |
| 46 | users[name] = u.value.id; | |
| 47 | } | |
| 48 | pub = repo("pub", "public", users.alice!); | |
| 49 | priv = repo("priv", "private", users.alice!); | |
| 50 | }); | |
| 51 | afterEach(() => { | |
| 52 | app.db.close(); | |
| 53 | rmSync(dir, { recursive: true, force: true }); | |
| 54 | }); | |
| 55 | ||
| 56 | test("counts two authors, most first, and marks the pulls that serve a flow issue", () => { | |
| 57 | issue(pub, 5930, users.alice!); | |
| 58 | pull(pub, users.bob!, "Closes #5930", 5); | |
| 59 | pull(pub, users.bob!, "Closes #1", 10); | |
| 60 | pull(pub, users.bob!, "", 15); | |
| 61 | pull(pub, users.alice!, "Fixes #6217", 20); | |
| 62 | issue(pub, 6217, users.alice!); | |
| 63 | expect(readOpeners(app.db, NOW)).toEqual([ | |
| 64 | { author: "bob", opened: 3, flow: 1 }, | |
| 65 | { author: "alice", opened: 1, flow: 1 }, | |
| 66 | ]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("a pull whose issue waits on a flow issue, however far, is flow", () => { | |
| 70 | const root = issue(pub, 5994, users.alice!); | |
| 71 | const mid = issue(pub, 20, users.alice!); | |
| 72 | const leaf = issue(pub, 21, users.alice!); | |
| 73 | issue(pub, 22, users.alice!); | |
| 74 | const dep = (a: string, b: string) => app.db.query(`INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)`).run(a, b); | |
| 75 | dep(mid, root); | |
| 76 | dep(leaf, mid); | |
| 77 | pull(pub, users.bob!, "Closes #21", 3); | |
| 78 | pull(pub, users.bob!, "Closes #22", 4); | |
| 79 | expect(readOpeners(app.db, NOW)).toEqual([{ author: "bob", opened: 2, flow: 1 }]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a pull opened 61 minutes ago is left out, 59 is in", () => { | |
| 83 | pull(pub, users.bob!, "", 61); | |
| 84 | pull(pub, users.alice!, "", 59); | |
| 85 | expect(readOpeners(app.db, NOW)).toEqual([{ author: "alice", opened: 1, flow: 0 }]); | |
| 86 | }); | |
| 87 | ||
| 88 | test("publicOnly hides private repositories", () => { | |
| 89 | pull(pub, users.alice!, "", 5); | |
| 90 | pull(priv, users.bob!, "", 5); | |
| 91 | expect(readOpeners(app.db, NOW).map((o) => o.author)).toEqual(["alice", "bob"]); | |
| 92 | expect(readOpeners(app.db, NOW, true)).toEqual([{ author: "alice", opened: 1, flow: 0 }]); | |
| 93 | }); | |
| 94 | }); |
new above
offline
Hard-coded flow list: when the direction changes (e.g. #6263 is also buildable), this silently goes stale. Share one list with the alarms.