Public: anyone can read this; the crew can change it.
A test pins leave-to-run: a grant names one head, a new push waits for a new grant, and a grant is never a review #6724
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6718-a-test-pins-leave-to-run-a-grant-names-o → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +141
−0
src/core/run_grant.ts +1
| @@ -11,6 +11,7 @@ export function grantRun( | ||
| 11 | 11 | commitOid: string, |
| 12 | 12 | userId: string, |
| 13 | 13 | ): void { |
| 14 | if (commitOid === "") return; | |
| 14 | 15 | db.query( |
| 15 | 16 | `INSERT INTO pull_run_grant (issue_id, commit_oid, user_id) VALUES (?1, ?2, ?3) |
| 16 | 17 | ON CONFLICT (issue_id, commit_oid) DO UPDATE SET |
test/run-grant.test.ts added +140
| @@ -0,0 +1,140 @@ | ||
| 1 | /** | |
| 2 | * Leave to run: a grant names one head, a new push waits for a new grant, | |
| 3 | * and a grant is never a review. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 10 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { recordPullProposal } from "../src/core/proposal_token.ts"; | |
| 13 | import { pullMayRun } from "../src/core/pull_trust.ts"; | |
| 14 | import { getPull } from "../src/core/pulls.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { grantRun, runGrantedAt } from "../src/core/run_grant.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | ||
| 19 | describe("leave to run", () => { | |
| 20 | let dataDir: string; | |
| 21 | let app: App; | |
| 22 | let alice: User; | |
| 23 | let lib: Repo; | |
| 24 | let issueId: string; | |
| 25 | let number: number; | |
| 26 | let a: string; | |
| 27 | let b: string; | |
| 28 | const pull = () => getPull(app.db, lib.id, number)!; | |
| 29 | const rows = () => | |
| 30 | app.db | |
| 31 | .query<{ user_id: string; granted_at: string }, [string]>( | |
| 32 | "SELECT user_id, granted_at FROM pull_run_grant WHERE issue_id = ?1", | |
| 33 | ) | |
| 34 | .all(issueId); | |
| 35 | ||
| 36 | const push = async (file: string) => { | |
| 37 | const done = await commitToBranch(app, alice, lib, { | |
| 38 | branch: "child/kid/one", | |
| 39 | message: file, | |
| 40 | files: [{ path: file, content: `${file}\n` }], | |
| 41 | }); | |
| 42 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 43 | return done.value.commitOid; | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-run-grant-")); | |
| 48 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 49 | const made = await createUser(app.db, { | |
| 50 | origin: "seed", | |
| 51 | trusted: true, | |
| 52 | username: "alice", | |
| 53 | email: "[email protected]", | |
| 54 | password: "a-good-passphrase", | |
| 55 | adminIfFirst: true, | |
| 56 | }); | |
| 57 | if (!made.ok) throw new Error(made.error); | |
| 58 | alice = made.value; | |
| 59 | const repo = await createRepo(app, alice, { name: "lib", visibility: "public" }); | |
| 60 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 61 | lib = repo.value; | |
| 62 | const seeded = await commitToBranch(app, alice, lib, { | |
| 63 | message: "Start", | |
| 64 | files: [{ path: "README.md", content: "# lib\n" }], | |
| 65 | }); | |
| 66 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 67 | app.db.query("UPDATE repo SET require_review = 1 WHERE id = ?1").run(lib.id); | |
| 68 | lib = { ...lib, requireReview: true }; | |
| 69 | a = await push("a.md"); | |
| 70 | const opened = await openPull(app, alice, lib, { | |
| 71 | title: "Proposed", | |
| 72 | baseBranch: "main", | |
| 73 | headBranch: "child/kid/one", | |
| 74 | }); | |
| 75 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 76 | recordPullProposal(app.db, opened.value.id, { | |
| 77 | child: "kid", | |
| 78 | origin: "https://kid.example.com/", | |
| 79 | }); | |
| 80 | issueId = opened.value.id; | |
| 81 | number = opened.value.number; | |
| 82 | }, 120_000); | |
| 83 | ||
| 84 | afterAll(async () => { | |
| 85 | app.close(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a grant on head A lets A run, and a push to B waits for its own grant", async () => { | |
| 90 | expect(runGrantedAt(app.db, issueId, a)).toBeUndefined(); | |
| 91 | expect(pullMayRun(app.db, lib, pull(), a)).toBe(false); | |
| 92 | ||
| 93 | grantRun(app.db, issueId, a, alice.id); | |
| 94 | expect(runGrantedAt(app.db, issueId, a)).toBeString(); | |
| 95 | expect(pullMayRun(app.db, lib, pull(), a)).toBe(true); | |
| 96 | ||
| 97 | b = await push("b.md"); | |
| 98 | expect(b).not.toBe(a); | |
| 99 | expect(runGrantedAt(app.db, issueId, b)).toBeUndefined(); | |
| 100 | expect(pullMayRun(app.db, lib, pull(), b)).toBe(false); | |
| 101 | ||
| 102 | grantRun(app.db, issueId, b, alice.id); | |
| 103 | expect(pullMayRun(app.db, lib, pull(), b)).toBe(true); | |
| 104 | }); | |
| 105 | ||
| 106 | test("granting twice keeps one row, moves granted_at and records the second granter", async () => { | |
| 107 | app.db | |
| 108 | .query("UPDATE pull_run_grant SET granted_at = '2000-01-01T00:00:00.000Z', user_id = 'first' WHERE issue_id = ?1 AND commit_oid = ?2") | |
| 109 | .run(issueId, a); | |
| 110 | const before = rows().length; | |
| 111 | grantRun(app.db, issueId, a, alice.id); | |
| 112 | grantRun(app.db, issueId, a, alice.id); | |
| 113 | expect(rows()).toHaveLength(before); | |
| 114 | expect(runGrantedAt(app.db, issueId, a)! > "2000-01-01T00:00:00.000Z").toBe(true); | |
| 115 | const row = app.db | |
| 116 | .query<{ user_id: string }, [string, string]>( | |
| 117 | "SELECT user_id FROM pull_run_grant WHERE issue_id = ?1 AND commit_oid = ?2", | |
| 118 | ) | |
| 119 | .get(issueId, a)!; | |
| 120 | expect(row.user_id).toBe(alice.id); | |
| 121 | }); | |
| 122 | ||
| 123 | test("an empty oid is never granted", () => { | |
| 124 | const before = rows().length; | |
| 125 | grantRun(app.db, issueId, "", alice.id); | |
| 126 | expect(runGrantedAt(app.db, issueId, "")).toBeUndefined(); | |
| 127 | expect(rows()).toHaveLength(before); | |
| 128 | expect(pullMayRun(app.db, lib, pull(), "")).toBe(false); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a grant is not a review: the merge gate still wants one", async () => { | |
| 132 | expect(pullMayRun(app.db, lib, pull(), b)).toBe(true); | |
| 133 | const refused = await mergePull(app, alice, lib, pull(), "merge"); | |
| 134 | expect(refused.ok).toBe(false); | |
| 135 | if (!refused.ok) expect(refused.error).toBe("review_required"); | |
| 136 | expect( | |
| 137 | app.db.query("SELECT COUNT(*) AS n FROM review WHERE issue_id = ?1").get(issueId), | |
| 138 | ).toEqual({ n: 0 }); | |
| 139 | }); | |
| 140 | }); |
new above
offline
Good: the writer now matches the reader's empty-oid rule, so no row is ever written that
runGrantedAtwould have to hide.