Public: anyone can read this; the crew can change it.
A test boots the flow-label dispatcher: labelling a held issue `flow` starts its run, that issue only, paid by whoever last assigned it #7520
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7512-a-test-boots-the-flow-label-dispatcher-l → main 1 commit fork
2 files
changed +217
−0
test/flow-label-dispatch.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * The `flow` label's dispatcher, `src/crew/flow_label.ts`, imported and run | |
| 3 | * (#7512). Release 0.7.283 did not boot because of it (#7436), and no test | |
| 4 | * loaded it directly: `actions/issues.ts` loads it only when a label lands. | |
| 5 | * Imported first here, so a failure at import fails this file; then loaded | |
| 6 | * cold in a fresh process the way `src/main.ts` boots, through the label. | |
| 7 | * | |
| 8 | * Labelling a held issue `flow` starts its run, that issue only, paid by | |
| 9 | * whoever last assigned it; an issue that is not held, or has no assignee, | |
| 10 | * starts nothing and throws nothing. | |
| 11 | */ | |
| 12 | import { dispatchFlowLabelled } from "../src/crew/flow_label.ts"; | |
| 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 14 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 19 | import { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 20 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 21 | import { getIssueById, getLabel, setIssueLabels, type Issue } from "../src/core/issues.ts"; | |
| 22 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 23 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 24 | import { flowOnlyHolds } from "../src/crew/flow_only.ts"; | |
| 25 | import { sweepAssigned } from "../src/crew/dispatch.ts"; | |
| 26 | ||
| 27 | const BOOT = join(import.meta.dir, "helpers", "flow-label-boot.ts"); | |
| 28 | ||
| 29 | describe("the flow-label dispatcher", () => { | |
| 30 | let dataDir: string; | |
| 31 | let app: App; | |
| 32 | let alice: User; | |
| 33 | let gus: User; | |
| 34 | let bot: User; | |
| 35 | let repo: Repo; | |
| 36 | ||
| 37 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 38 | const open = (title: string): Issue => { | |
| 39 | const o = openIssue(app, alice, fresh(), { title, body: "" }); | |
| 40 | if (!o.ok) throw new Error(String(o.error)); | |
| 41 | return o.value; | |
| 42 | }; | |
| 43 | const assign = (issue: Issue, to: User | null = bot): Issue => { | |
| 44 | const a = assignIssue(app, alice, fresh(), issue, to); | |
| 45 | if (!a.ok) throw new Error(String(a.error)); | |
| 46 | return a.value; | |
| 47 | }; | |
| 48 | const runsOn = (issue: Issue): number => | |
| 49 | app.db | |
| 50 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND kind = 'agent'") | |
| 51 | .get(issue.id)!.n; | |
| 52 | /** Who each run queued on the issue was announced as asked by: the account it is charged to. */ | |
| 53 | const payers = (issue: Issue): (string | null)[] => | |
| 54 | app.db | |
| 55 | .query<{ actor_id: string | null }, [string]>( | |
| 56 | `SELECT actor_id FROM event WHERE kind = 'run.queued' AND json_extract(payload, '$.issueId') = ?1 ORDER BY id`, | |
| 57 | ) | |
| 58 | .all(issue.id) | |
| 59 | .map((r) => r.actor_id); | |
| 60 | /** Label `flow` as the store does, then hand the issue to the dispatcher. */ | |
| 61 | const label = (issue: Issue): void => { | |
| 62 | setIssueLabels(app.db, issue.id, [getLabel(app.db, repo.id, "flow")!.id]); | |
| 63 | dispatchFlowLabelled(app, fresh(), getIssueById(app.db, issue.id)!); | |
| 64 | }; | |
| 65 | /** An assignment recorded as a guest's: the dispatcher reads the assigner from the event. */ | |
| 66 | const assignedBy = (issue: Issue, by: User): void => { | |
| 67 | app.bus.append({ | |
| 68 | kind: "issue.assigned", | |
| 69 | repoId: repo.id, | |
| 70 | actorId: by.id, | |
| 71 | payload: { number: issue.number, assignee: bot.username }, | |
| 72 | }); | |
| 73 | }; | |
| 74 | ||
| 75 | beforeAll(async () => { | |
| 76 | dataDir = await mkdtemp(join(tmpdir(), "forge-flow-label-dispatch-")); | |
| 77 | app = createApp({ dataDir, port: 0 }); | |
| 78 | const a = await createUser(app.db, { | |
| 79 | origin: "seed", | |
| 80 | trusted: true, | |
| 81 | username: "alice", | |
| 82 | email: "[email protected]", | |
| 83 | password: "a-good-passphrase", | |
| 84 | adminIfFirst: true, | |
| 85 | }); | |
| 86 | if (!a.ok) throw new Error(a.error); | |
| 87 | alice = a.value; | |
| 88 | const g = await createUser(app.db, { | |
| 89 | origin: "seed", | |
| 90 | trusted: true, | |
| 91 | username: "gus", | |
| 92 | email: "[email protected]", | |
| 93 | password: "a-good-passphrase", | |
| 94 | }); | |
| 95 | if (!g.ok) throw new Error(g.error); | |
| 96 | gus = g.value; | |
| 97 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); | |
| 98 | if (!made.ok) throw new Error(String(made.error)); | |
| 99 | repo = made.value; | |
| 100 | const created = await createAgent(app, alice, { username: "bot" }); | |
| 101 | if (!created.ok) throw new Error(String(created.error)); | |
| 102 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 103 | bot = getUserByName(app.db, "bot")!; | |
| 104 | const on = await updateRepoSettings(app, alice, fresh(), { flowOnly: true }); | |
| 105 | if (!on.ok) throw new Error(JSON.stringify(on.error)); | |
| 106 | }); | |
| 107 | ||
| 108 | afterAll(async () => { | |
| 109 | app.close(); | |
| 110 | await rm(dataDir, { recursive: true, force: true }); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a held issue labelled `flow` starts exactly one run, that issue only, charged to its assigner", () => { | |
| 114 | const held = assign(open("Held")); | |
| 115 | const beside = assign(open("Beside, also held")); | |
| 116 | sweepAssigned(app); | |
| 117 | expect(flowOnlyHolds(app.db, fresh(), held)).toBe(true); | |
| 118 | expect(runsOn(held)).toBe(0); | |
| 119 | expect(runsOn(beside)).toBe(0); | |
| 120 | ||
| 121 | label(held); | |
| 122 | expect(runsOn(held)).toBe(1); | |
| 123 | expect(runsOn(beside)).toBe(0); | |
| 124 | expect(payers(held)).toEqual([alice.id]); | |
| 125 | ||
| 126 | // Labelled again, or swept, it is the same one run. | |
| 127 | label(held); | |
| 128 | sweepAssigned(app); | |
| 129 | expect(runsOn(held)).toBe(1); | |
| 130 | expect(runsOn(beside)).toBe(0); | |
| 131 | }); | |
| 132 | ||
| 133 | test("the last assigner pays: a guest's last word starts nothing, an owner's after it does", () => { | |
| 134 | // Assigned by alice, then last by a guest: a guest's word starts no run. | |
| 135 | const guestLast = assign(open("Guest last")); | |
| 136 | assignedBy(guestLast, gus); | |
| 137 | label(guestLast); | |
| 138 | expect(runsOn(guestLast)).toBe(0); | |
| 139 | expect(payers(guestLast)).toEqual([]); | |
| 140 | ||
| 141 | // A guest first, then alice: alice is the last assigner, and pays. | |
| 142 | const ownerLast = open("Owner last"); | |
| 143 | assignedBy(ownerLast, gus); | |
| 144 | assign(ownerLast); | |
| 145 | label(ownerLast); | |
| 146 | expect(runsOn(ownerLast)).toBe(1); | |
| 147 | expect(payers(ownerLast)).toEqual([alice.id]); | |
| 148 | }); | |
| 149 | ||
| 150 | test("an issue that is not held, or has no assignee, starts nothing and throws nothing", () => { | |
| 151 | const nobody = open("No assignee"); | |
| 152 | expect(() => label(nobody)).not.toThrow(); | |
| 153 | expect(runsOn(nobody)).toBe(0); | |
| 154 | ||
| 155 | const person = assign(open("A person's"), alice); | |
| 156 | expect(() => label(person)).not.toThrow(); | |
| 157 | expect(runsOn(person)).toBe(0); | |
| 158 | ||
| 159 | // Assigned while already labelled `flow`: never held, and its one run is the assignment's. | |
| 160 | const flowing = open("Already flow"); | |
| 161 | setIssueLabels(app.db, flowing.id, [getLabel(app.db, repo.id, "flow")!.id]); | |
| 162 | assign(flowing); | |
| 163 | sweepAssigned(app); | |
| 164 | expect(flowOnlyHolds(app.db, fresh(), getIssueById(app.db, flowing.id)!)).toBe(false); | |
| 165 | expect(runsOn(flowing)).toBe(1); | |
| 166 | expect(() => label(flowing)).not.toThrow(); | |
| 167 | expect(runsOn(flowing)).toBe(1); | |
| 168 | ||
| 169 | const closed = assign(open("Closed")); | |
| 170 | const shut = setIssueState(app, alice, fresh(), closed, "closed"); | |
| 171 | if (!shut.ok) throw new Error(String(shut.error)); | |
| 172 | expect(() => label(closed)).not.toThrow(); | |
| 173 | expect(runsOn(closed)).toBe(0); | |
| 174 | }); | |
| 175 | ||
| 176 | test("loaded cold as the forge boots, the label on a held issue starts its run", async () => { | |
| 177 | const held = assign(open("Held at boot")); | |
| 178 | expect(runsOn(held)).toBe(0); | |
| 179 | const child = Bun.spawn([process.execPath, BOOT, dataDir, "alice", "svc", String(held.number)], { | |
| 180 | stdout: "pipe", | |
| 181 | stderr: "pipe", | |
| 182 | env: { PATH: process.env["PATH"] ?? "", HOME: process.env["HOME"] ?? "" }, | |
| 183 | }); | |
| 184 | const [out, err] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text()]); | |
| 185 | expect({ code: await child.exited, err: (await child.exited) === 0 ? "" : err }).toEqual({ code: 0, err: "" }); | |
| 186 | expect(out.trim().split("\n").at(-1)).toBe("1"); | |
| 187 | expect(runsOn(held)).toBe(1); | |
| 188 | }, 30_000); | |
| 189 | }); |
test/helpers/flow-label-boot.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * The `flow` label in a fresh process, loaded as `src/main.ts` loads the | |
| 3 | * forge at boot (`test/flow-label-dispatch.test.ts`): the app and the HTTP | |
| 4 | * server's module graph, then a top-level await, then an owner labels a held | |
| 5 | * issue `flow`. `actions/issues.ts` loads the dispatcher only at that call, so | |
| 6 | * a dispatcher that cannot load cold fails here and nowhere earlier. | |
| 7 | * | |
| 8 | * Printed: the number of agent runs on the issue afterwards. | |
| 9 | */ | |
| 10 | import { createApp } from "../../src/app.ts"; | |
| 11 | import "../../src/http/server.ts"; | |
| 12 | import { setIssueLabels } from "../../src/actions/issues.ts"; | |
| 13 | import { getIssue } from "../../src/core/issues.ts"; | |
| 14 | import { getRepo } from "../../src/core/repos.ts"; | |
| 15 | import { getUserByName } from "../../src/core/users.ts"; | |
| 16 | ||
| 17 | const [dataDir, owner, name, number] = process.argv.slice(2); | |
| 18 | const app = createApp({ dataDir: dataDir!, port: 0 }); | |
| 19 | await Bun.sleep(0); | |
| 20 | const repo = getRepo(app.db, owner!, name!)!; | |
| 21 | const issue = getIssue(app.db, repo.id, Number(number))!; | |
| 22 | const labelled = setIssueLabels(app, getUserByName(app.db, owner!)!, repo, issue, ["flow"]); | |
| 23 | if (!labelled.ok) throw new Error(String(labelled.error)); | |
| 24 | const runs = app.db | |
| 25 | .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND kind = 'agent'") | |
| 26 | .get(issue.id)!.n; | |
| 27 | app.close(); | |
| 28 | console.log(runs); |
new above
offline