Public: anyone can read this; the crew can change it.
Shipped this week, and what a shipped change cost, are counted not sampled #177
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/106-shipped-this-week-and-what-a-shipped-cha → main 1 commit fork
3 files
changed +180
−6
src/core/pulls.ts +26
| @@ -288,6 +288,32 @@ export function mergedPullsForRepos( | ||
| 288 | 288 | ); |
| 289 | 289 | } |
| 290 | 290 | |
| 291 | /** | |
| 292 | * How many of a repository's pulls have landed since an instant — what | |
| 293 | * "shipped this week" counts, and what the cost of a shipped change | |
| 294 | * divides by. Counted by the database over the whole window, not off a | |
| 295 | * page of closed pulls: past a hundred of those the week under-reports, | |
| 296 | * and the cost per change reads high with nothing saying why. | |
| 297 | * | |
| 298 | * Closed is not shipped. A pull closed without a merge has no `merged_at`, | |
| 299 | * and `NULL >= anything` is `NULL` — never true, so it never counts; the | |
| 300 | * `IS NOT NULL` says so to the reader as well as the planner. | |
| 301 | */ | |
| 302 | export function mergedPullCount( | |
| 303 | db: Database, | |
| 304 | repoId: string, | |
| 305 | since: string, | |
| 306 | ): number { | |
| 307 | const row = db | |
| 308 | .query<{ n: number }, [string, string]>( | |
| 309 | `SELECT COUNT(*) AS n FROM issue JOIN pull ON pull.issue_id = issue.id | |
| 310 | WHERE issue.repo_id = ?1 | |
| 311 | AND pull.merged_at IS NOT NULL AND pull.merged_at >= ?2`, | |
| 312 | ) | |
| 313 | .get(repoId, since); | |
| 314 | return row?.n ?? 0; | |
| 315 | } | |
| 316 | ||
| 291 | 317 | /** The pull rows for a page of issues, keyed by issue id. */ |
| 292 | 318 | export function pullsForIssues( |
| 293 | 319 | db: Database, |
src/web/studio/app.ts +9 −5
| @@ -11,7 +11,11 @@ import { listCrewRoles } from "../../core/agents.ts"; | ||
| 11 | 11 | import type { AppRecord } from "../../core/apps.ts"; |
| 12 | 12 | import { decisionStats, type DecisionStat } from "../../core/decisions.ts"; |
| 13 | 13 | import { getIssueById, listIssues, type Issue } from "../../core/issues.ts"; |
| 14 | import { listPulls, openPullsClosing } from "../../core/pulls.ts"; | |
| 14 | import { | |
| 15 | listPulls, | |
| 16 | mergedPullCount, | |
| 17 | openPullsClosing, | |
| 18 | } from "../../core/pulls.ts"; | |
| 15 | 19 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 16 | 20 | import { |
| 17 | 21 | agentRunTotals, |
| @@ -158,10 +162,10 @@ export async function studioApp( | ||
| 158 | 162 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); |
| 159 | 163 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); |
| 160 | 164 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); |
| 161 | const shipped = listPulls(db, repo.id, { | |
| 162 | state: "closed", | |
| 163 | limit: 100, | |
| 164 | }).filter((p) => p.mergedAt !== null && p.mergedAt >= week).length; | |
| 165 | // What landed this week, counted by the database like the runs and the | |
| 166 | // spend below — a page of closed pulls would stop at a hundred and the | |
| 167 | // cost per shipped change would read high off the short count. | |
| 168 | const shipped = mergedPullCount(db, repo.id, week); | |
| 165 | 169 | // The rest is spend, worked out only for a viewer who is going to see it — |
| 166 | 170 | // counted by the database over the whole week, not off a page of rows. |
| 167 | 171 | const weekTotals = ownerFacts |
test/studio.test.ts +145 −1
| @@ -29,7 +29,7 @@ import { setCrewRole } from "../src/core/agents.ts"; | ||
| 29 | 29 | import { recordDecision } from "../src/core/decisions.ts"; |
| 30 | 30 | import { getIssue, listIssues, type Issue } from "../src/core/issues.ts"; |
| 31 | 31 | import { recordLook } from "../src/core/looks.ts"; |
| 32 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 32 | import { getPull, mergedPullCount, type Pull } from "../src/core/pulls.ts"; | |
| 33 | 33 | import { setDirectionIssue, type Repo } from "../src/core/repos.ts"; |
| 34 | 34 | import { |
| 35 | 35 | agentRunTotals, |
| @@ -4659,6 +4659,150 @@ describe("this week's runs and spend are counted by the database, not read off a | ||
| 4659 | 4659 | }); |
| 4660 | 4660 | }); |
| 4661 | 4661 | |
| 4662 | describe("this week's shipping is counted by the database, not read off a page of closed pulls", () => { | |
| 4663 | let dataDir: string; | |
| 4664 | let app: App; | |
| 4665 | let servers: Servers; | |
| 4666 | let base: string; | |
| 4667 | let alice: User; | |
| 4668 | let busy: Repo; | |
| 4669 | let ownerCookie: string; | |
| 4670 | ||
| 4671 | /** How many changes landed this week — past the page of closed pulls the | |
| 4672 | * count used to be filtered out of. */ | |
| 4673 | const SHIPPED = 150; | |
| 4674 | /** One agent run's cost, so the page prints a cost per shipped change. */ | |
| 4675 | const SPENT = 30; | |
| 4676 | ||
| 4677 | /** A pull row on a closed issue: `mergedAt` null is closed without a merge. */ | |
| 4678 | const seedPull = (repo: Repo, title: string, mergedAt: string | null) => { | |
| 4679 | const opened = openIssue(app, alice, repo, { title }); | |
| 4680 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 4681 | app.db | |
| 4682 | .query( | |
| 4683 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 4684 | VALUES (?1, 'main', ?2, 'a', 'b', ?3)`, | |
| 4685 | ) | |
| 4686 | .run(opened.value.id, `f${opened.value.number}`, mergedAt); | |
| 4687 | app.db | |
| 4688 | .query("UPDATE issue SET kind = 'pull', state = 'closed' WHERE id = ?1") | |
| 4689 | .run(opened.value.id); | |
| 4690 | }; | |
| 4691 | ||
| 4692 | beforeAll(async () => { | |
| 4693 | dataDir = await mkdtemp(join(tmpdir(), "forge-week-shipped-")); | |
| 4694 | app = createApp({ dataDir, port: 0 }); | |
| 4695 | servers = await serve(app); | |
| 4696 | base = servers.url; | |
| 4697 | const a = await createUser(app.db, { | |
| 4698 | username: "alice", | |
| 4699 | email: "[email protected]", | |
| 4700 | password: "a-good-passphrase", | |
| 4701 | adminIfFirst: true, | |
| 4702 | }); | |
| 4703 | if (!a.ok) throw new Error(a.error); | |
| 4704 | alice = a.value; | |
| 4705 | ownerCookie = ( | |
| 4706 | ( | |
| 4707 | await fetch(`${base}/signin`, { | |
| 4708 | method: "POST", | |
| 4709 | body: new URLSearchParams({ | |
| 4710 | username: "alice", | |
| 4711 | password: "a-good-passphrase", | |
| 4712 | }), | |
| 4713 | redirect: "manual", | |
| 4714 | }) | |
| 4715 | ).headers.get("set-cookie") ?? "" | |
| 4716 | ).split(";")[0]!; | |
| 4717 | ||
| 4718 | for (const name of ["busy", "quiet"] as const) { | |
| 4719 | const made = await createRepo(app, alice, { | |
| 4720 | name, | |
| 4721 | visibility: "private", | |
| 4722 | description: "", | |
| 4723 | }); | |
| 4724 | if (!made.ok) throw new Error(String(made.error)); | |
| 4725 | const seeded = await commitToBranch(app, alice, made.value, { | |
| 4726 | message: "Start", | |
| 4727 | files: [{ path: "README.md", content: "start\n" }], | |
| 4728 | }); | |
| 4729 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 4730 | const enabled = await enableApp(app, alice, made.value, { slug: name }); | |
| 4731 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 4732 | if (name === "busy") busy = made.value; | |
| 4733 | } | |
| 4734 | ||
| 4735 | // 150 changes landed inside the week — past the hundred closed pulls the | |
| 4736 | // page used to read — plus two that must not count: one closed without a | |
| 4737 | // merge, and one merged ten days back. | |
| 4738 | const now = Date.now(); | |
| 4739 | for (let i = 0; i < SHIPPED; i++) | |
| 4740 | seedPull(busy, `landed ${i}`, new Date(now - i * 60_000).toISOString()); | |
| 4741 | seedPull(busy, "abandoned", null); | |
| 4742 | seedPull( | |
| 4743 | busy, | |
| 4744 | "landed last week", | |
| 4745 | new Date(now - 10 * 24 * 60 * 60_000).toISOString(), | |
| 4746 | ); | |
| 4747 | ||
| 4748 | const keeper = await createAgent(app, alice, { username: "keeper" }); | |
| 4749 | if (!keeper.ok) throw new Error(String(keeper.error)); | |
| 4750 | expect(addToCrew(app, alice, busy, "keeper").ok).toBe(true); | |
| 4751 | const run = createRun(app.db, { | |
| 4752 | repoId: busy.id, | |
| 4753 | kind: "agent", | |
| 4754 | actorId: getUserByName(app.db, "keeper")!.id, | |
| 4755 | reason: "assigned", | |
| 4756 | }); | |
| 4757 | claimRun(app.db, run.id); | |
| 4758 | finishRun(app.db, run.id, { | |
| 4759 | state: "ok", | |
| 4760 | summary: "a week of work", | |
| 4761 | costUsd: SPENT, | |
| 4762 | }); | |
| 4763 | }); | |
| 4764 | ||
| 4765 | afterAll(async () => { | |
| 4766 | servers.stop(); | |
| 4767 | app.close(); | |
| 4768 | await rm(dataDir, { recursive: true, force: true }); | |
| 4769 | }); | |
| 4770 | ||
| 4771 | const get = (path: string) => | |
| 4772 | fetch(`${base}${path}`, { | |
| 4773 | headers: { cookie: `${ownerCookie}; forge_mode=studio` }, | |
| 4774 | redirect: "manual", | |
| 4775 | }); | |
| 4776 | ||
| 4777 | test("the aggregate counts the merged pulls in the window and nothing else", () => { | |
| 4778 | const week = new Date(Date.now() - 7 * 24 * 60 * 60_000).toISOString(); | |
| 4779 | expect(mergedPullCount(app.db, busy.id, week)).toBe(SHIPPED); | |
| 4780 | ||
| 4781 | // Window opened wide: the older merge is there after all, the pull closed | |
| 4782 | // without a merge still is not. | |
| 4783 | expect(mergedPullCount(app.db, busy.id, "2000-01-01T00:00:00.000Z")).toBe( | |
| 4784 | SHIPPED + 1, | |
| 4785 | ); | |
| 4786 | ||
| 4787 | // COUNT over no rows is 0, and another repo's merges are never this one's. | |
| 4788 | expect(mergedPullCount(app.db, "no-such-repo", week)).toBe(0); | |
| 4789 | }); | |
| 4790 | ||
| 4791 | test("the app page says 150 changes shipped, not the 100 a page of closed pulls holds", async () => { | |
| 4792 | const page = await (await get("/alice/busy")).text(); | |
| 4793 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>150 changes<\/dd>/); | |
| 4794 | expect(page).not.toContain("100 changes"); | |
| 4795 | // And the cost of a shipped change divides by all 150. | |
| 4796 | expect(page).toContain(`$${(SPENT / SHIPPED).toFixed(2)} per shipped change`); | |
| 4797 | }); | |
| 4798 | ||
| 4799 | test("an app that shipped nothing this week reads 0 changes and no cost per change", async () => { | |
| 4800 | const page = await (await get("/alice/quiet")).text(); | |
| 4801 | expect(page).toMatch(/Shipped<\/dt>\s*<dd>0 changes<\/dd>/); | |
| 4802 | expect(page).not.toContain("per shipped change"); | |
| 4803 | }); | |
| 4804 | }); | |
| 4805 | ||
| 4662 | 4806 | describe("finding things in the studio: /search", () => { |
| 4663 | 4807 | let dataDir: string; |
| 4664 | 4808 | let app: App; |
new above
offline