Public: anyone can read this; the crew can change it.
Release the issues the old Later-gate rule still holds (#7636, #7637 and others) #7828
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7801-release-the-issues-the-old-later-gate-ru → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +400
−0
README.md +1
| @@ -493,6 +493,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 493 | 493 | - [](docs/features/csv-view.md) — open a `.csv` or `.tsv` in the code view to read it as a table; `?all=1` draws more rows, `?view=source` shows the text. The terminal has its own table view for them (see the `.csv` paragraph above). |
| 494 | 494 | - [](docs/features/file-media.md) — a file in the browser: every file page has **Raw** and **Download** (any size), and `/:owner/:name/media/:ref/*path` serves images, audio, video and PDFs under their real type, sandboxed, with byte ranges. |
| 495 | 495 | - [](docs/features/config-form.md) — a JSON file opens as a form: typed fields (from its schema when one is found), cards to add and reorder, and a save that commits for a writer or proposes a pull for anyone else. In the terminal, a reader edits in the workspace (`e`) and `c` proposes the change from their fork; any commit there that breaks a JSON file's schema is refused first. |
| 496 | - [](docs/features/release-later.md) — owner-run `scripts/release-later.ts`: list and unlink the issues the old Later-gate rule still holds. | |
| 496 | 497 | - [](docs/ROLES.md) — guest, member and admin, and what each may do. |
| 497 | 498 | - [](docs/MCP.md) — the forge as a connector; [](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest. |
| 498 | 499 |
docs/features/release-later.md added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | # Release what the Later gate holds | |
| 2 | ||
| 3 | Before #7797, triage linked an issue to wait for the Later gate when it named no pillar. The rule is gone; the links stayed. Owner-run, on the forge's data directory: | |
| 4 | ||
| 5 | bun scripts/release-later.ts <dataDir> trevato/forge # list, writes nothing | |
| 6 | bun scripts/release-later.ts <dataDir> trevato/forge --apply # unlink them | |
| 7 | bun scripts/release-later.ts <dataDir> trevato/forge --only 7636,7637 --apply | |
| 8 | ||
| 9 | An issue is listed when the gate is the only open issue it waits for; the list says who linked it, so a deliberate deferral by a person can be left out with `--only`. Only the edge to the gate is deleted. Run it again: an empty list confirms it. Triage no longer links work to the gate for lack of a pillar (`test/triage.test.ts` fails if it does), so nothing re-holds them. A person's reversal ("take #N back") still defers its issue behind the gate on purpose; that is a different edge and the list will show it. The script exits 2 with a plain message on a missing `forge.db`, one without the forge's tables, an unknown repository, or bad arguments. |
scripts/release-later.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/release-later.ts <dataDir> <owner/repo> [--only 7636,7637] [--apply]` | |
| 3 | * (#7801): list the open issues the Later gate holds alone, and with | |
| 4 | * `--apply` unlink them from it. Without `--apply` it writes nothing. | |
| 5 | * Owner-run, against the data directory of a stopped or running forge | |
| 6 | * (sqlite WAL; read-only without `--apply`, and `--apply` releases all or | |
| 7 | * none in one transaction); afterwards the list is empty, and that is the confirmation. | |
| 8 | */ | |
| 9 | ||
| 10 | import { Database } from "bun:sqlite"; | |
| 11 | import { existsSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { heldByGateAlone, releaseFromGate } from "../src/crew/release_later.ts"; | |
| 14 | ||
| 15 | const args = process.argv.slice(2); | |
| 16 | const flag = (name: string) => args.indexOf(name); | |
| 17 | const only = flag("--only") >= 0 ? (args[flag("--only") + 1] ?? "") : undefined; | |
| 18 | const apply = flag("--apply") >= 0; | |
| 19 | const [dataDir, slug] = args.filter((a, i) => !a.startsWith("--") && args[i - 1] !== "--only"); | |
| 20 | const [owner, name] = (slug ?? "").split("/"); | |
| 21 | if (!dataDir || !owner || !name || (only !== undefined && !/^\d+(,\d+)*$/.test(only))) { | |
| 22 | console.error("usage: bun scripts/release-later.ts <dataDir> <owner/repo> [--only 7636,7637] [--apply]"); | |
| 23 | process.exit(2); | |
| 24 | } | |
| 25 | ||
| 26 | const dbPath = join(dataDir, "forge.db"); | |
| 27 | if (!existsSync(dbPath)) { | |
| 28 | console.error(`release-later: no forge.db in ${dataDir}`); | |
| 29 | process.exit(2); | |
| 30 | } | |
outdated
Open readonly unless --apply, set busy_timeout, wrap the release in a transaction (live WAL db, a running forge may hold the write lock). |
||
| 31 | // A running forge holds this WAL database: list mode opens it read-only, and a | |
| 32 | // write waits up to 5 s for the forge's lock rather than failing SQLITE_BUSY. | |
| 33 | const db = new Database(dbPath, apply ? { readwrite: true } : { readonly: true }); | |
| 34 | db.run("PRAGMA busy_timeout = 5000"); | |
| 35 | const hasSchema = db | |
| 36 | .query<{ n: number }, []>("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('repo', 'user', 'issue', 'issue_dependency')") | |
| 37 | .get()?.n === 4; | |
| 38 | if (!hasSchema) { | |
| 39 | console.error(`release-later: ${dbPath} is not a forge database (the forge's tables are missing)`); | |
| 40 | process.exit(2); | |
| 41 | } | |
| 42 | const repo = db | |
| 43 | .query<{ id: string }, [string, string]>( | |
| 44 | "SELECT r.id FROM repo r JOIN user u ON u.id = r.owner_id WHERE u.username = ?1 AND r.name = ?2", | |
| 45 | ) | |
| 46 | .get(owner, name); | |
| 47 | if (!repo) { | |
| 48 | console.error(`release-later: no repository ${slug}`); | |
| 49 | process.exit(2); | |
| 50 | } | |
| 51 | const picked = only === undefined ? undefined : new Set(only.split(",").map(Number)); | |
| 52 | const held = heldByGateAlone(db, repo.id).filter((h) => picked === undefined || picked.has(h.number)); | |
| 53 | for (const h of held) console.log(`#${h.number}\tlinked by ${h.linkedBy}\t${h.title}`); | |
| 54 | console.log(`release-later: ${held.length} held by the Later gate alone`); | |
| 55 | if (apply) { | |
| 56 | const done = releaseFromGate(db, repo.id, held.map((h) => h.number)); | |
| 57 | console.log(`release-later: released ${done.length}`); | |
| 58 | } | |
| 59 | db.close(); |
src/crew/release_later.ts added +66
| @@ -0,0 +1,66 @@ | ||
| 1 | /** | |
| 2 | * Releasing what the Later gate holds by an old rule (#7801). Before #7797 | |
| 3 | * triage linked an issue to wait for the gate when it named no pillar; the | |
| 4 | * rule is gone, the links stayed. An issue is held by the gate alone when | |
| 5 | * the gate is the only open issue it waits for. Releasing it deletes that | |
| 6 | * one edge (`removeDependency`) and nothing else: it keeps its pillar | |
| 7 | * links, and any other open blocker keeps it blocked. | |
| 8 | * | |
| 9 | * Reads and one-row deletes only; `scripts/release-later.ts` is the owner's | |
| 10 | * way in, and lists before it writes. | |
| 11 | */ | |
| 12 | ||
| 13 | import type { Database } from "bun:sqlite"; | |
| 14 | import { removeDependency } from "../core/dependencies.ts"; | |
| 15 | import { laterGate } from "./later.ts"; | |
| 16 | ||
| 17 | export interface Held { | |
| 18 | readonly id: string; | |
| 19 | readonly number: number; | |
| 20 | readonly title: string; | |
| 21 | /** Who linked it to the gate; "unknown" when the edge kept no actor. */ | |
| 22 | readonly linkedBy: string; | |
| 23 | } | |
| 24 | ||
| 25 | /** Open issues of a repository that wait for the open Later gate and for no other open issue. */ | |
| 26 | export function heldByGateAlone(db: Database, repoId: string): Held[] { | |
| 27 | const gate = laterGate(db, repoId); | |
| 28 | if (gate === undefined) return []; | |
| 29 | return db | |
| 30 | .query<Held, [string, number]>( | |
| 31 | `SELECT i.id AS id, i.number AS number, i.title AS title, | |
| 32 | COALESCE(u.username, 'unknown') AS linkedBy | |
| 33 | FROM issue i | |
| 34 | JOIN issue_dependency d ON d.issue_id = i.id | |
| 35 | JOIN issue g ON g.id = d.blocked_by_id AND g.repo_id = i.repo_id AND g.number = ?2 | |
outdated
Add |
||
| 36 | LEFT JOIN user u ON u.id = d.actor_id | |
| 37 | WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.state = 'open' | |
| 38 | AND NOT EXISTS ( | |
| 39 | SELECT 1 FROM issue_dependency o | |
| 40 | JOIN issue b ON b.id = o.blocked_by_id | |
| 41 | WHERE o.issue_id = i.id AND o.blocked_by_id <> g.id AND b.state = 'open') | |
| 42 | ORDER BY i.number`, | |
| 43 | ) | |
| 44 | .all(repoId, gate); | |
| 45 | } | |
| 46 | ||
| 47 | /** Delete the gate edge of each issue named, all in one transaction (a failure releases none); returns the numbers released. */ | |
| 48 | export function releaseFromGate(db: Database, repoId: string, numbers: readonly number[]): number[] { | |
| 49 | return db.transaction(() => release(db, repoId, numbers)).immediate(); | |
| 50 | } | |
| 51 | ||
| 52 | function release(db: Database, repoId: string, numbers: readonly number[]): number[] { | |
| 53 | const number = laterGate(db, repoId); | |
| 54 | if (number === undefined) return []; | |
| 55 | const gate = db | |
| 56 | .query<{ id: string }, [string, number]>( | |
| 57 | "SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2", | |
| 58 | ) | |
| 59 | .get(repoId, number); | |
| 60 | if (gate === null) return []; | |
| 61 | const want = new Set(numbers); | |
| 62 | const out: number[] = []; | |
| 63 | for (const held of heldByGateAlone(db, repoId)) | |
| 64 | if (want.has(held.number) && removeDependency(db, held.id, gate.id)) out.push(held.number); | |
| 65 | return out; | |
| 66 | } |
test/release-later-script.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** `scripts/release-later.ts` run as the owner runs it: a subprocess on a real data directory (#7801). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 9 | import { createIssue, type Issue } from "../src/core/issues.ts"; | |
| 10 | import { getUserByName } from "../src/core/users.ts"; | |
| 11 | import { heldByGateAlone } from "../src/crew/release_later.ts"; | |
| 12 | import { adminCookie } from "./helpers/admin.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | import { Database } from "bun:sqlite"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const script = join(import.meta.dir, "../scripts/release-later.ts"); | |
| 19 | const run = (...args: string[]) => { | |
| 20 | const p = Bun.spawnSync(["bun", script, ...args], { stdout: "pipe", stderr: "pipe" }); | |
| 21 | return { code: p.exitCode, out: p.stdout.toString(), err: p.stderr.toString() }; | |
| 22 | }; | |
| 23 | ||
| 24 | describe("scripts/release-later.ts", () => { | |
| 25 | let dataDir = ""; | |
| 26 | let repoId = ""; | |
| 27 | let a: Issue; | |
| 28 | let b: Issue; | |
| 29 | ||
| 30 | const held = () => { | |
| 31 | const db = new Database(join(dataDir, "forge.db"), { readonly: true }); | |
| 32 | try { | |
| 33 | return heldByGateAlone(db, repoId).map((h) => h.number); | |
| 34 | } finally { | |
| 35 | db.close(); | |
| 36 | } | |
| 37 | }; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | dataDir = await mkdtemp(join(tmpdir(), "forge-release-script-")); | |
| 41 | const app = createApp({ dataDir, port: 0 }); | |
| 42 | await adminCookie(app, { username: "alice", email: "[email protected]", password: "a-long-words-passphrase" }); | |
| 43 | const alice = getUserByName(app.db, "alice")!; | |
| 44 | const made = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 45 | if (!made.ok) throw new Error(String(made.error)); | |
| 46 | repoId = made.value.id; | |
| 47 | const open = (title: string): Issue => { | |
| 48 | const r = createIssue(app.db, { repoId, authorId: alice.id, title }); | |
| 49 | if (!r.ok) throw new Error(String(r.error)); | |
| 50 | return r.value; | |
| 51 | }; | |
| 52 | const gate = open("Later gate: not yet"); | |
| 53 | a = open("Held one"); | |
| 54 | b = open("Held two"); | |
| 55 | for (const i of [a, b]) { | |
| 56 | const r = addDependency(app.db, i, gate, alice.id); | |
| 57 | if (!r.ok) throw new Error(r.error); | |
| 58 | } | |
| 59 | app.close(); | |
| 60 | }); | |
| 61 | afterAll(() => rm(dataDir, { recursive: true, force: true })); | |
| 62 | ||
| 63 | test("lists by default and writes nothing", () => { | |
| 64 | const r = run(dataDir, "alice/garden"); | |
| 65 | expect(r.code).toBe(0); | |
| 66 | expect(r.out).toContain(`#${a.number}\tlinked by alice\tHeld one`); | |
| 67 | expect(r.out).toContain(`#${b.number}\tlinked by alice\tHeld two`); | |
| 68 | expect(r.out).toContain("2 held by the Later gate alone"); | |
| 69 | expect(r.out).not.toContain("released"); | |
| 70 | expect(held()).toEqual([a.number, b.number]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("--only narrows the list, and still writes nothing without --apply", () => { | |
| 74 | const r = run(dataDir, "alice/garden", "--only", String(b.number)); | |
| 75 | expect(r.out).toContain("Held two"); | |
| 76 | expect(r.out).not.toContain("Held one"); | |
| 77 | expect(r.out).toContain("1 held by the Later gate alone"); | |
| 78 | expect(held()).toEqual([a.number, b.number]); | |
| 79 | }); | |
| 80 | ||
| 81 | test("--only --apply releases just those named", () => { | |
| 82 | const r = run(dataDir, "alice/garden", "--only", String(a.number), "--apply"); | |
| 83 | expect(r.out).toContain("released 1"); | |
| 84 | expect(held()).toEqual([b.number]); | |
| 85 | }); | |
| 86 | ||
| 87 | test("--apply waits for a forge holding the write lock, then releases the rest", async () => { | |
| 88 | const forge = new Database(join(dataDir, "forge.db")); | |
| 89 | forge.run("BEGIN IMMEDIATE"); | |
| 90 | const p = Bun.spawn(["bun", script, dataDir, "alice/garden", "--apply"], { stdout: "pipe", stderr: "pipe" }); | |
| 91 | await Bun.sleep(500); | |
| 92 | forge.run("COMMIT"); | |
| 93 | forge.close(); | |
| 94 | const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]); | |
| 95 | expect([code, err]).toEqual([0, ""]); | |
| 96 | expect(out).toContain("released 1"); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a second run is empty", () => { | |
| 100 | expect(held()).toEqual([]); | |
| 101 | expect(run(dataDir, "alice/garden").out).toContain("0 held by the Later gate alone"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("refuses with a plain message: bad arguments, unknown repository, missing or foreign database", async () => { | |
| 105 | expect(run(dataDir).code).toBe(2); | |
| 106 | expect(run(dataDir, "alice/garden", "--only", "x").err).toContain("usage:"); | |
| 107 | const none = run(dataDir, "alice/nowhere"); | |
| 108 | expect([none.code, none.err]).toEqual([2, "release-later: no repository alice/nowhere\n"]); | |
| 109 | const empty = await mkdtemp(join(tmpdir(), "forge-release-empty-")); | |
| 110 | expect(run(empty, "alice/garden").err).toContain("no forge.db"); | |
| 111 | new Database(join(empty, "forge.db")).close(); | |
| 112 | const bare = run(empty, "alice/garden"); | |
| 113 | expect(bare.code).toBe(2); | |
| 114 | expect(bare.err).toContain("is not a forge database"); | |
| 115 | expect(bare.err).not.toContain("SQLiteError"); | |
| 116 | await rm(empty, { recursive: true, force: true }); | |
| 117 | }); | |
| 118 | }); |
test/release-later.test.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** The old Later-gate holds are found and released, and only those (#7801). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { setIssueState } from "../src/actions/issues.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { addDependency, dependenciesOf } from "../src/core/dependencies.ts"; | |
| 10 | import { createIssue, type Issue } from "../src/core/issues.ts"; | |
| 11 | import type { Repo } from "../src/core/repos.ts"; | |
| 12 | import { getUserByName } from "../src/core/users.ts"; | |
| 13 | import { heldByGateAlone, releaseFromGate } from "../src/crew/release_later.ts"; | |
| 14 | import { adminCookie } from "./helpers/admin.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("release the issues the Later gate holds", () => { | |
| 20 | let dataDir = ""; | |
| 21 | let app: App; | |
| 22 | let repo: Repo; | |
| 23 | let gate: Issue; | |
| 24 | let alone: Issue; | |
| 25 | let withPillar: Issue; | |
| 26 | let pillar: Issue; | |
| 27 | let free: Issue; | |
| 28 | let closedBlocker: Issue; | |
| 29 | let afterClosed: Issue; | |
| 30 | let crossed: Issue; | |
| 31 | let other: Repo; | |
| 32 | let gateless: Repo; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-release-later-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | await adminCookie(app, { username: "alice", email: "[email protected]", password: "a-long-words-passphrase" }); | |
| 38 | const alice = getUserByName(app.db, "alice")!; | |
| 39 | const made = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 40 | if (!made.ok) throw new Error(String(made.error)); | |
| 41 | repo = made.value; | |
| 42 | const open = (title: string): Issue => { | |
| 43 | const r = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title }); | |
| 44 | if (!r.ok) throw new Error(String(r.error)); | |
| 45 | return r.value; | |
| 46 | }; | |
| 47 | gate = open("Later gate: not yet"); | |
| 48 | pillar = open("An open blocker"); | |
| 49 | alone = open("Held by the old rule"); | |
| 50 | withPillar = open("Held and also waiting"); | |
| 51 | free = open("Never held"); | |
| 52 | closedBlocker = open("Done long ago"); | |
| 53 | afterClosed = open("Held behind a closed blocker and the gate"); | |
| 54 | for (const [i, b] of [[alone, gate], [withPillar, gate], [withPillar, pillar], [afterClosed, closedBlocker], [afterClosed, gate]] as const) { | |
| 55 | const r = addDependency(app.db, i, b, alice.id); | |
| 56 | if (!r.ok) throw new Error(r.error); | |
| 57 | } | |
| 58 | const closed = setIssueState(app, alice, repo, closedBlocker, "closed"); | |
| 59 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 60 | // Another repository whose own "Later gate" has the same number as ours: it must never be matched. | |
| 61 | const second = await createRepo(app, alice, { name: "orchard", visibility: "public" }); | |
| 62 | if (!second.ok) throw new Error(String(second.error)); | |
| 63 | other = second.value; | |
| 64 | const mk = (title: string): Issue => { | |
| 65 | const r = createIssue(app.db, { repoId: other.id, authorId: alice.id, title }); | |
| 66 | if (!r.ok) throw new Error(String(r.error)); | |
| 67 | return r.value; | |
| 68 | }; | |
| 69 | const foreign = mk("Later gate: elsewhere"); | |
| 70 | // Not reachable through addDependency (it refuses cross-repo edges), so a damaged row stands in. | |
| 71 | crossed = open("Waits on another repository's issue of the gate's number"); | |
| 72 | app.db.query("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)").run(crossed.id, foreign.id); | |
| 73 | // A repository with no Later gate at all, where an issue waits for an ordinary blocker. | |
| 74 | const third = await createRepo(app, alice, { name: "meadow", visibility: "public" }); | |
| 75 | if (!third.ok) throw new Error(String(third.error)); | |
| 76 | gateless = third.value; | |
| 77 | const mine = (title: string): Issue => { | |
| 78 | const r = createIssue(app.db, { repoId: gateless.id, authorId: alice.id, title }); | |
| 79 | if (!r.ok) throw new Error(String(r.error)); | |
| 80 | return r.value; | |
| 81 | }; | |
| 82 | const waits = addDependency(app.db, mine("Waits"), mine("Blocks"), alice.id); | |
| 83 | if (!waits.ok) throw new Error(waits.error); | |
| 84 | }); | |
| 85 | afterAll(async () => { | |
| 86 | app.close(); | |
| 87 | await rm(dataDir, { recursive: true, force: true }); | |
| 88 | }); | |
| 89 | ||
| 90 | test("lists only the issue the gate alone holds, with who linked it", () => { | |
| 91 | expect(heldByGateAlone(app.db, repo.id).map((h) => [h.number, h.linkedBy])).toEqual([ | |
| 92 | [alone.number, "alice"], | |
| 93 | [afterClosed.number, "alice"], | |
| 94 | ]); | |
| 95 | expect(heldByGateAlone(app.db, repo.id).some((h) => h.id === crossed.id)).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a repository with no gate lists and releases nothing", () => { | |
| 99 | expect(heldByGateAlone(app.db, gateless.id)).toEqual([]); | |
| 100 | expect(releaseFromGate(app.db, gateless.id, [1, 2])).toEqual([]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a gate nothing in its own repository waits for lists nothing", () => { | |
| 104 | expect(heldByGateAlone(app.db, other.id)).toEqual([]); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a failure part-way releases none", () => { | |
| 108 | // The second delete aborts; the first must roll back with it. | |
| 109 | app.db.run( | |
| 110 | `CREATE TEMP TRIGGER refuse BEFORE DELETE ON issue_dependency WHEN OLD.issue_id = '${afterClosed.id}' | |
| 111 | BEGIN SELECT RAISE(ABORT, 'refused'); END`, | |
| 112 | ); | |
| 113 | try { | |
| 114 | expect(() => releaseFromGate(app.db, repo.id, [alone.number, afterClosed.number])).toThrow("refused"); | |
| 115 | } finally { | |
| 116 | app.db.run("DROP TRIGGER refuse"); | |
| 117 | } | |
| 118 | expect(heldByGateAlone(app.db, repo.id).map((h) => h.number)).toEqual([alone.number, afterClosed.number]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("releasing deletes that one edge and leaves the others", () => { | |
| 122 | expect(releaseFromGate(app.db, repo.id, [free.number, withPillar.number])).toEqual([]); | |
| 123 | expect(releaseFromGate(app.db, repo.id, [alone.number, crossed.number])).toEqual([alone.number]); | |
| 124 | expect(releaseFromGate(app.db, repo.id, [afterClosed.number])).toEqual([afterClosed.number]); | |
| 125 | expect(heldByGateAlone(app.db, repo.id)).toEqual([]); | |
| 126 | expect(dependenciesOf(app.db, alone.id).blockedBy).toEqual([]); | |
| 127 | expect(dependenciesOf(app.db, withPillar.id).blockedBy.length).toBe(2); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a gate that is closed holds nothing", () => { | |
| 131 | const closed = setIssueState(app, getUserByName(app.db, "alice")!, repo, gate, "closed"); | |
| 132 | if (!closed.ok) throw new Error(String(closed.error)); | |
| 133 | const again = addDependency(app.db, free, gate, undefined); | |
| 134 | expect(again.ok).toBe(true); | |
| 135 | expect(heldByGateAlone(app.db, repo.id)).toEqual([]); | |
| 136 | expect(releaseFromGate(app.db, repo.id, [free.number])).toEqual([]); | |
| 137 | }); | |
| 138 | }); |
test/triage.test.ts +9
| @@ -98,6 +98,15 @@ describe("triage", () => { | ||
| 98 | 98 | expect(placementOf(app.db, t.id)?.reply).toBe(p.reply); |
| 99 | 99 | }); |
| 100 | 100 | |
| 101 | test("work that names no pillar opens an issue on no edge: the old Later-gate rule is gone (#7801)", async () => { | |
| 102 | app.config.decider = stub({ somewhere: ["work", 0.9, "none"] }); | |
| 103 | const p = await placeThought(app, fresh(), alice, think("somewhere to put a thing")); | |
| 104 | expect(p.placed[0]!.action).toBe("opened"); | |
| 105 | const issue = getIssue(app.db, repo.id, p.placed[0]!.number)!; | |
| 106 | const edges = app.db.query<{ c: number }, [string]>("SELECT count(*) c FROM issue_dependency WHERE issue_id = ?1").get(issue.id)!.c; | |
| 107 | expect(edges).toBe(0); | |
| 108 | }); | |
| 109 | ||
| 101 | 110 | test("a thought with nothing to act on opens nothing and is kept as a note", async () => { |
| 102 | 111 | app.config.decider = { name: "boom", async decide() { throw new Error("asked"); } } as never; |
| 103 | 112 | const count = () => app.db.query<{ c: number }, []>("SELECT count(*) c FROM issue").get()!.c; |
new above
offline
Check that the repo table exists, or catch the error, and print a usage-style message. Cover the script in a subprocess test.