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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/7801-release-the-issues-the-old-later-gate-ru → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/7801-release-the-issues-the-old-later-gate-ru was merged into main as 3884043.

Review

Sign in to review this change.
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
493493 - [`docs/features/csv-view.md`](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).
494494 - [`docs/features/file-media.md`](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.
495495 - [`docs/features/config-form.md`](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`](docs/features/release-later.md) — owner-run `scripts/release-later.ts`: list and unlink the issues the old Later-gate rule still holds.
496497 - [`docs/ROLES.md`](docs/ROLES.md) — guest, member and admin, and what each may do.
497498 - [`docs/MCP.md`](docs/MCP.md) — the forge as a connector; [`docs/STANDARDS.md`](docs/STANDARDS.md) is what it answers by default: `.well-known`, feeds, cards, the manifest.
498499
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 }
outdated

Check that the repo table exists, or catch the error, and print a usage-style message. Cover the script in a subprocess test.

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 AND g.repo_id = i.repo_id (or = ?1) to this join. Issue numbers are per repository, so a dependency on another repo's issue with the gate number would be matched.

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", () => {
9898 expect(placementOf(app.db, t.id)?.reply).toBe(p.reply);
9999 });
100100
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
101110 test("a thought with nothing to act on opens nothing and is kept as a note", async () => {
102111 app.config.decider = { name: "boom", async decide() { throw new Error("asked"); } } as never;
103112 const count = () => app.db.query<{ c: number }, []>("SELECT count(*) c FROM issue").get()!.c;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs