Public: anyone can read this; the crew can change it.

Every repository admin sees the Held files card on /boards, with the hold form, and only the holds of repositories they administer #7241

Merged opened this

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

Merged

scout/forge:scout/7057-every-repository-admin-sees-the-held-fil → main 2 commits fork 6 threads · 6 unresolved

  • Merged. scout/7057-every-repository-admin-sees-the-held-fil was merged into main as 2b29b53.

Review

Sign in to review this change.
10 files changed +384 −11
README.md +2
@@ -40,6 +40,8 @@ when). The terminal's palette has the same as "Crew hand-overs", a read-only
4040 list of the same rows (repo#number, title, reasons; it does not open the
4141 issue — use the link on the page). Both are a forge admin's only.
4242
43 `/boards` carries a Held files card for anyone who administers a repository (a forge admin sees every repository's): the holds of the repositories you administer, a form to hold another path, Release on each row, and "Showing 200 of N" when the list is cut. The user menu links Boards to such a person. In the terminal, a bare line on the Held files prompt lists the same holds.
44
4345 A hold can be set "until green" (a checkbox on the hold form, `until: "green"` on the hold API and the MCP `hold` tool): when a check passes on the pull's current head the forge releases it and the pull's timeline says `hold released: green on <short oid>`. A plain hold is never released by a check.
4446
4547 A hold an agent placed does not stand forever: the tick releases one still standing after 24 h, in one transaction, with a comment on the pull, "Hold expired after 24 h: <why>. Back to @builder.", which shows in the pull's timeline like any comment, and the pull goes back to its builder (`expireCrewHolds` in `src/actions/holds.ts`). A person's hold is never expired. The window is fixed; a pull held again and expiring again leaves one such comment per round in its timeline.
docs/design/areas/boards.md +8
@@ -1,2 +1,10 @@
11 # Boards
22
3 ## Held files card (`#board-holds`)
4
5 Drawn from existing components only: `card` with an icon head, `row` per hold
6 (path, repository, who and when, a ghost Release button in the trail), and a
7 `field` form to hold another path. No new class. It lists the holds of the
8 repositories the viewer administers and shows when there are none, because it
9 holds the form. A person who administers a repository but is not a forge
10 admin sees `/boards` as this card alone.
src/core/admin_holds.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * Every file held in a repository the viewer administers, in one statement
3 * and with no per-row `canRead`: the rule `adminRepoIds` keeps, so a private
4 * repository's hold shows to its admins only. The Held files card on /boards
5 * and the terminal's bare line on its holds prompt both read it.
6 */
7 import type { Database } from "bun:sqlite";
8 import { ADMIN_REPO_SQL } from "./admin_repos.ts";
9 import type { User } from "./users.ts";
10
11 /** The most holds one read returns. */
12 export const LISTED = 200;
13
14 export interface BoardHold {
15 readonly repo: string;
16 readonly path: string;
17 readonly byName: string;
18 readonly at: string;
19 }
20
21 /** Holds in the repositories `viewer` administers, one statement; `total` is how many there are before the cut. */
22 export function boardHolds(db: Database, viewer: User): { holds: BoardHold[]; total: number } {
23 if (viewer.kind === "agent") return { holds: [], total: 0 };
24 const rows = db
25 .query<{ repo: string; path: string; by_name: string | null; at: string; total: number }, [string, number, number]>(
26 `SELECT owner.username || '/' || repo.name AS repo, h.path, who.username AS by_name, h.at,
27 COUNT(*) OVER () AS total
28 FROM file_hold h
29 JOIN repo ON repo.id = h.repo_id
30 JOIN user owner ON owner.id = repo.owner_id
31 LEFT JOIN user who ON who.id = h.by
32 WHERE ${ADMIN_REPO_SQL}
33 ORDER BY repo, h.at, h.path
34 LIMIT ?3`,
35 )
36 .all(viewer.id, viewer.isAdmin ? 1 : 0, LISTED);
37 return {
38 holds: rows.map((r) => ({ repo: r.repo, path: r.path, byName: r.by_name ?? "someone", at: r.at })),
39 total: rows[0]?.total ?? 0,
40 };
41 }
src/core/admin_repos.ts +19 −8
@@ -10,18 +10,29 @@
1010 import type { Database } from "bun:sqlite";
1111 import type { User } from "./users.ts";
1212
13 export function adminRepoIds(db: Database, user: User | undefined): Set<string> {
14 if (user === undefined || user.kind === "agent") return new Set();
15 const rows = db
16 .query<{ id: string }, [string, number]>(
17 `SELECT repo.id FROM repo
18 WHERE ?2 = 1
13 /** The one rule, as SQL over `repo`: ?1 is the user id, ?2 is 1 for a forge admin. */
14 export const ADMIN_REPO_SQL = `(?2 = 1
1915 OR repo.owner_id = ?1
2016 OR repo.owner_id IN (
2117 SELECT team.org_id FROM team
2218 JOIN team_member ON team_member.team_id = team.id
23 WHERE team_member.user_id = ?1 AND team.permission = 'admin')`,
24 )
19 WHERE team_member.user_id = ?1 AND team.permission = 'admin'))`;
20
21 /** Whether `user` administers any repository: no statement for a forge admin, one cheap probe for anyone else. */
22 export function administersAny(db: Database, user: User | undefined): boolean {
23 if (user === undefined || user.kind === "agent") return false;
24 if (user.isAdmin) return true;
25 return (
26 db
27 .query<{ one: number }, [string, number]>(`SELECT 1 AS one FROM repo WHERE ${ADMIN_REPO_SQL} LIMIT 1`)
28 .get(user.id, 0) !== null
29 );
30 }
31
32 export function adminRepoIds(db: Database, user: User | undefined): Set<string> {
33 if (user === undefined || user.kind === "agent") return new Set();
34 const rows = db
35 .query<{ id: string }, [string, number]>(`SELECT repo.id FROM repo WHERE ${ADMIN_REPO_SQL}`)
2536 .all(user.id, user.isAdmin ? 1 : 0);
2637 return new Set(rows.map((r) => r.id));
2738 }
src/http/server.ts +2
@@ -29,6 +29,7 @@ import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke }
2929 import { measureSubmit } from "./measure.ts";
3030 import { boardsPage } from "../web/pages/boards.ts";
3131 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
32 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
3233 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
3334 import {
3435 closeStreamsOf,
@@ -889,6 +890,7 @@ export function buildRouter(): Router<Handler> {
889890 router.add("GET", "/boards", boardsList);
890891 router.add("GET", "/boards/data", boardsPage);
891892 router.add("POST", "/boards/new", boardNew);
893 router.add("POST", "/boards/holds", boardsHoldPost);
892894 router.add("POST", "/boards/pin", boardPin);
893895 for (const op of ["pin-panel", "unpin-panel", "move-pin"]) router.add("POST", `/boards/${op}`, pinPost);
894896 router.add("GET", "/boards/:slug", boardShow);
src/tui/screens/fileholds.ts +10 −1
@@ -6,9 +6,11 @@
66 * owner/name path hold new work on path
77 * release owner/name path let it go
88 * owner/name list the holds, who set them and when
9 * (a bare line) list every hold in the repositories you administer
910 */
1011
1112 import { holdFileAs, listFileHolds, releaseFileAs } from "../../actions/file_holds.ts";
13 import { boardHolds } from "../../core/admin_holds.ts";
1214 import { getRepo } from "../../core/repos.ts";
1315 import type { Ctx, Screen } from "../app/app.ts";
1416 import type { Session } from "../session.ts";
@@ -25,6 +27,13 @@ export function fileHoldLine(f: Session, text: string): { ok: boolean; says: str
2527 const words = text.trim().split(/\s+/).filter((w) => w !== "");
2628 const release = words[0] === "release";
2729 if (release) words.shift();
30 if (words.length === 0) {
31 if (f.user === undefined) return { ok: false, says: SAYS.unauthorized };
32 const { holds, total } = boardHolds(f.db, f.user);
33 if (holds.length === 0) return { ok: true, says: "No file is held." };
34 const cut = total > holds.length ? ` (${holds.length} of ${total})` : "";
35 return { ok: true, says: holds.map((h) => `${h.repo} ${h.path}`).join("; ") + cut };
36 }
2837 const [slug, path, ...extra] = words;
2938 const [owner, name, ...rest] = (slug ?? "").split("/");
3039 const repo = owner && name && rest.length === 0 ? getRepo(f.db, owner, name) : undefined;
@@ -49,7 +58,7 @@ export function fileHoldLine(f: Session, text: string): { ok: boolean; says: str
4958 export function fileHoldsPrompt(f: Session, ctx: Ctx, parent: Screen): Screen {
5059 return promptScreen(ctx, parent, {
5160 title: "Held files",
52 label: "owner/name path · release owner/name path · owner/name",
61 label: "owner/name path · release owner/name path · owner/name · empty lists all",
5362 hint: "no build starts on an issue that names a held path",
5463 onSubmit: (text) => {
5564 const done = fileHoldLine(f, text);
src/web/layout.ts +4
@@ -9,6 +9,7 @@
99
1010 import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts";
1111 import { protectionNotice } from "../kernel/protection.ts";
12 import { administersAny } from "../core/admin_repos.ts";
1213 import { appsForRepos } from "../core/apps.ts";
1314 import { getRepo, listReposVisibleTo } from "../core/repos.ts";
1415 import { mayDirectCrew } from "../crew/direct.ts";
@@ -225,6 +226,9 @@ function userMenu(
225226 { label: "Access tokens", href: "/settings/tokens", icon: "key" },
226227 { label: "SSH keys", href: "/settings/keys", icon: "lock" },
227228 { label: "Origins", href: "/settings/origins", icon: "globe" },
229 ...(!user.isAdmin && administersAny(ctx.app.db, user)
230 ? [{ label: "Boards", href: "/boards", icon: "activity" as const }]
231 : []),
228232 ...(user.isAdmin
229233 ? [
230234 { label: "Crew", href: "/settings/crew", icon: "bot" as const },
src/web/pages/boards-holds.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * The Held files card on /boards: every file held in a repository the viewer
3 * administers (`adminRepoIds`), read in one statement with no per-row
4 * `canRead` — the rule `listFileHolds` keeps, so a private repository's hold
5 * shows to its admins only. The card shows with no holds yet, for it holds the
6 * form. `POST /boards/holds` hands the form to the shared handler
7 * (`file_holds.ts`) and returns to /boards.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { administersAny } from "../../core/admin_repos.ts";
12 import { boardHolds, type BoardHold } from "../../core/admin_holds.ts";
13 import { getRepo } from "../../core/repos.ts";
14 import type { User } from "../../core/users.ts";
15 import { MAX_HOLD_PATH } from "../../crew/file_holds.ts";
16 import type { Ctx } from "../../http/context.ts";
17 import { field, readForm } from "../form.ts";
18 import { html, type Html } from "../html.ts";
19 import { icon } from "../icons.ts";
20 import { btn, card, field as formField, row, time } from "../ui.ts";
21 import { fileHoldForm } from "./file_holds.ts";
22
23 /** `owner/name`: two names and a slash. */
24 const MAX_REPO_REF = 80;
25
26 /** Null unless `viewer` administers some repository. */
27 export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoards = false): Html | null {
28 if (viewer === undefined || !administersAny(db, viewer)) return null;
29 const { holds, total } = boardHolds(db, viewer);
30 const release = (h: BoardHold): Html =>
31 html`<form method="post" action="/boards/holds">
32 <input type="hidden" name="repo" value="${h.repo}" />
33 <input type="hidden" name="path" value="${h.path}" />
34 <input type="hidden" name="verb" value="release" />
35 ${btn("Release", { variant: "ghost" })}
36 </form>`;
37 return card(
38 html`<p class="muted small">
39 No build starts on an issue that names a held path. Nothing already
40 running is cut. Only repositories you administer are listed.
41 </p>
42 ${total > holds.length
43 ? html`<p class="muted small" data-cut>Showing ${String(holds.length)} of ${String(total)} holds; release some to see the rest.</p>`
44 : null}
outdated

The list stops at 200 with no word on the page. Show the total, or link to the repository's settings holds, so a hold past the cut can still be found and released.

45 ${holds.length === 0
46 ? html`<p class="muted" data-no-holds>No file is held.</p>`
47 : html`<ul class="list setting-list">
48 ${holds.map((h) =>
49 row({
50 title: html`<span class="mono">${h.path}</span> <span class="muted small">${h.repo}</span>`,
51 meta: html`held by <span data-by>${h.byName}</span> · ${time(h.at)}`,
52 data: { "file-hold": h.path, repo: h.repo },
outdated

This is a second statement on every /boards load, and for a forge admin it reads every repository id, then sends them back as a JSON array to json_each. Put the admin predicate into boardHolds' WHERE clause so the card really costs one query.

53 trail: release(h),
54 }),
55 )}
56 </ul>`}
57 <form method="post" action="/boards/holds">
58 <input type="hidden" name="verb" value="hold" />
59 ${formField({ label: "Repository", name: "repo", maxlength: MAX_REPO_REF, placeholder: "owner/name", mono: true, required: true, spellcheck: false })}
60 ${formField({
61 label: "Hold new work on",
62 name: "path",
63 placeholder: "src/http/mcp.ts",
64 mono: true,
65 required: true,
66 maxlength: MAX_HOLD_PATH,
67 spellcheck: false,
68 hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`,
69 })}
70 <div class="form-actions">${btn("Hold file", { variant: withBoards ? "secondary" : "primary" })}</div>
71 </form>`,
72 { id: "board-holds", head: html`${icon("alert")}<h2>Held files</h2>` },
73 );
74 }
75
76 // POST /boards/holds
77 export async function boardsHoldPost(ctx: Ctx): Promise<Response> {
78 const form = await readForm(ctx, "admin");
79 if (form instanceof Response) return form;
80 const [owner = "", name = "", ...extra] = field(form, "repo").trim().split("/");
81 const repo = extra.length === 0 && owner !== "" && name !== "" ? getRepo(ctx.app.db, owner, name) : undefined;
82 return fileHoldForm(ctx, form, repo, "/boards#board-holds");
83 }
src/web/pages/boards-view.ts +8 −2
@@ -20,6 +20,7 @@ import { html, when, type Html } from "../html.ts";
2020 import { fragment, page } from "../layout.ts";
2121 import { btn, card, chartBox, empty, field, selectField } from "../ui.ts";
2222 import { flowPanel } from "./flow-panel.ts";
23 import { boardsHoldsCard } from "./boards-holds.ts";
2324 import { forgeCeiling } from "../../flow/ceiling.ts";
2425 import { alarmLines, cachedAlarms } from "../studio/alarm_lines.ts";
2526 import { isPinned, listPins } from "../../boards/pins.ts";
@@ -85,7 +86,12 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
8586 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
8687 if (ctx.auth.user === undefined) return signInFirst(ctx);
8788 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
88 if (!listed.ok) return notFoundPage(ctx);
89 if (!listed.ok) {

This door opens for repository admins, but nothing links to it: the user menu shows Boards only when isAdmin (src/web/layout.ts:228). Add the menu item for anyone who administers a repository, and test it.

90 // Not a forge admin: the page is the holds of the repositories they administer, or not found.
91 const holds = boardsHoldsCard(ctx.app.db, ctx.auth.user);
92 if (holds === null) return notFoundPage(ctx);
93 return page({ ctx, title: "Boards", body: html`<div class="page-head"><h1>Boards</h1></div>${holds}` });
94 }
8995 const make = html`<form method="post" action="/boards/new" class="bar">
9096 ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })}
9197 ${btn("Make board", { variant: "primary" })}
@@ -112,7 +118,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> {
112118 body: html`<div class="page-head"><h1>Boards</h1>
113119 ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div>
114120 ${alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config)))}
115 ${card(make)} ${body} ${flow}`,
121 ${card(make)} ${body} ${flow} ${boardsHoldsCard(ctx.app.db, ctx.auth.user, true)}`,
116122 });
117123 notePhase(ctx.request, "render", performance.now() - rendering);
118124 return response;
test/boards-holds.test.ts added +207
@@ -0,0 +1,207 @@
1 // The Held files card on /boards (#7057): every repository admin sees it, with
2 // the hold form, and only the holds of repositories they administer.
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { setRole } from "../src/actions/roles.ts";
10 import { holdFileAs } from "../src/actions/file_holds.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { MAX_HOLD_PATH } from "../src/crew/file_holds.ts";
14 import { fileHoldLine } from "../src/tui/screens/fileholds.ts";
15 import type { Session } from "../src/tui/session.ts";
16 import { quiet } from "./helpers/statements.ts";
17
18 describe("the Held files card on /boards", () => {
19 let dir: string;
20 let app: App;
21 let servers: Servers;
22 let carol: User;
23 let alice: User;
24 let dave: User;
25 const cookies: Record<string, string> = {};
26
27 const person = async (username: string, first = false): Promise<User> => {
28 const made = await createUser(app.db, {
29 origin: "seed",
30 trusted: true,
31 username,
32 email: `${username}@forge.local`,
33 password: "a-good-passphrase",
34 adminIfFirst: first,
35 });
36 if (!made.ok) throw new Error(made.error);
37 return made.value;
38 };
39 const signIn = async (username: string): Promise<string> =>
40 ((await fetch(`${servers.url}/signin`, {
41 method: "POST",
42 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
43 redirect: "manual",
44 })).headers.get("set-cookie") ?? "").split(";")[0]!;
45 const boards = (who: string) => fetch(`${servers.url}/boards`, { headers: { cookie: cookies[who]! } });
46 const post = (who: string, body: Record<string, string>, origin = servers.url) =>
47 fetch(`${servers.url}/boards/holds`, {
48 method: "POST",
49 headers: { cookie: cookies[who]!, origin },
50 body: new URLSearchParams(body),
51 redirect: "manual",
52 });
53
54 beforeAll(async () => {
55 dir = await mkdtemp(join(tmpdir(), "boards-holds-"));
56 app = createApp({ dataDir: dir, port: 0 });
57 alice = await person("alice", true);
58 carol = await person("carol");
59 const bob = await person("bob");
60 dave = await person("dave");
61 for (const u of ["carol", "bob", "dave"]) setRole(app, alice, u, "member");
62 servers = await serve(app);
63 for (const u of ["alice", "carol", "bob", "dave"]) cookies[u] = await signIn(u);
64 for (const [u, name, visibility] of [
65 [carol, "cs", "public"],
66 [bob, "bobs", "public"],
67 [bob, "hidden", "private"],
68 [dave, "ds", "public"],
69 ] as const) {
70 const made = await createRepo(app, u, { name, visibility });
71 if (!made.ok) throw new Error(String(made.error));
72 }
73 });
74 afterAll(async () => {
75 servers.stop();
76 app.close();
77 await rm(dir, { recursive: true, force: true });
78 });
79
80 test("carol, who administers only her repository, holds a path there and sees it, not bob's", async () => {
81 const empty = await (await boards("carol")).text();
82 expect(empty).toContain('id="board-holds"');
83 expect(empty).toContain("No file is held.");
84 expect(empty).toContain('action="/boards/holds"');
85
86 const held = await post("carol", { repo: "carol/cs", path: "src/a.ts", verb: "hold" });
87 expect(held.status).toBe(303);
88 expect(held.headers.get("location")).toStartWith("/boards");
89 await post("bob", { repo: "bob/bobs", path: "src/secret-plan.ts", verb: "hold" });
90 await post("bob", { repo: "bob/hidden", path: "src/private.ts", verb: "hold" });
91
92 const page = await (await boards("carol")).text();
93 expect(page).toContain("src/a.ts");
94 expect(page).toContain("carol/cs");
95 expect(page).not.toContain("src/secret-plan.ts");
96 expect(page).not.toContain('data-repo="bob/bobs"');
97 expect(page).not.toContain("src/private.ts");
98 });
99
100 test("she can neither hold nor release in a repository she does not administer", async () => {
101 await post("carol", { repo: "bob/bobs", path: "src/x.ts", verb: "hold" });
102 await post("carol", { repo: "bob/bobs", path: "src/secret-plan.ts", verb: "release" });
103 const bobs = await (await boards("bob")).text();
104 expect(bobs).not.toContain("src/x.ts");
105 expect(bobs).toContain("src/secret-plan.ts");
106 });
107
108 test("a private repository's hold is shown to its admin and not to a non-reader", async () => {
109 expect(await (await boards("bob")).text()).toContain("src/private.ts");
110 expect(await (await boards("carol")).text()).not.toContain("src/private.ts");
111 });
112
113 test("a forge admin sees every hold, under the flow panel", async () => {
114 const page = await (await boards("alice")).text();
115 for (const p of ["src/a.ts", "src/secret-plan.ts", "src/private.ts"]) expect(page).toContain(p);
116 expect(page.indexOf('id="board-holds"')).toBeGreaterThan(page.indexOf('id="queue"'));
117 });
118
119 test("a person with a repository of their own sees the card; a stranger gets no page", async () => {
120 expect((await boards("dave")).status).toBe(200);
121 await person("erin");
122 setRole(app, alice, "erin", "member");
123 cookies.erin = await signIn("erin");
124 expect((await boards("erin")).status).toBe(404);
125 expect((await fetch(`${servers.url}/boards`, { redirect: "manual" })).status).not.toBe(200);
126 });
127
128 test("a cross-origin post is refused and holds nothing", async () => {
129 const res = await post("carol", { repo: "carol/cs", path: "src/evil.ts", verb: "hold" }, "https://evil.example");
130 expect(res.status).toBe(403);
131 expect(await (await boards("carol")).text()).not.toContain("src/evil.ts");
132 });
133
134 test("a held path is escaped", async () => {
135 const made = await createRepo(app, carol, { name: "x", visibility: "public" });
136 if (!made.ok) throw new Error("repo");
137 holdFileAs(app, carol, made.value, "a/<img src=x>.ts");
138 const page = await (await boards("carol")).text();
139 expect(page).not.toContain("<img src=x>");
140 expect(page).toContain("&lt;img src=x&gt;");
141 });
142
143 test("the menu links Boards to a repository admin, not to a stranger", async () => {
144 const link = 'href="/boards"';
145 expect(await (await fetch(`${servers.url}/`, { headers: { cookie: cookies.carol! } })).text()).toContain(link);
146 expect(await (await fetch(`${servers.url}/`, { headers: { cookie: cookies.erin! } })).text()).not.toContain(link);
outdated

Counting only SQL that contains file_hold hides the adminRepoIds statement the card also runs. Count every statement on a second load, before and after the card exists, or compare against a /boards load by a viewer who has no card.

147 });
148
149 test("Hold file is secondary under the board list and primary on the card-only page", async () => {
150 const hold = /<button[^>]*data-variant="(\w+)"[^>]*>\s*Hold file/;
151 expect((await (await boards("alice")).text()).match(hold)?.[1]).toBe("secondary");
152 expect((await (await boards("carol")).text()).match(hold)?.[1]).toBe("primary");
153 });
154
155 test("the fields carry their limits", async () => {
156 const page = await (await boards("carol")).text();
157 expect(page).toContain(`maxlength="${MAX_HOLD_PATH}"`);
158 expect(page).toContain('maxlength="80"');
159 });
160
161 test("a bare line in the terminal lists every hold the viewer administers", () => {
162 const f = { db: app.db, app, user: carol } as unknown as Session;
163 const got = fileHoldLine(f, "");
164 expect(got.ok).toBe(true);
165 expect(got.says).toContain("carol/cs src/a.ts");
166 expect(got.says).not.toContain("secret-plan");
167 });
168
169 test("a cut list says how many there are", async () => {
170 const made = await createRepo(app, dave, { name: "many", visibility: "public" });
171 if (!made.ok) throw new Error("repo");
172 for (let i = 0; i < 205; i++) holdFileAs(app, dave, made.value, `m/${i}.ts`);
173 const page = await (await boards("dave")).text();
174 expect(page).toContain("Showing 200 of 205 holds");
175 });
176
177 test("a second load adds one card query however many holds there are", async () => {
178 const count = async (): Promise<{ all: number; hold: number }> => {
179 await quiet(app.db);
180 const db = app.db as unknown as { query: (sql: string) => unknown };
181 const real = db.query;
182 const n = { all: 0, hold: 0 };
183 db.query = function (this: unknown, sql: string) {
184 n.all++;
185 if (sql.includes("file_hold")) n.hold++;
186 return real.call(this, sql);
187 };
188 try {
189 await (await boards("alice")).text();
190 } finally {
191 db.query = real;
192 }
193 return n;
194 };
195 const least = async () => {
196 const runs = [await count(), await count(), await count()];
197 return { all: Math.min(...runs.map((r) => r.all)), hold: Math.min(...runs.map((r) => r.hold)) };
198 };
199 const before = await least();
200 const made = await createRepo(app, carol, { name: "y", visibility: "public" });
201 if (!made.ok) throw new Error("repo");
202 for (let i = 0; i < 15; i++) holdFileAs(app, carol, made.value, `f${i}.ts`);
203 const after = await least();
204 expect(after.all).toBe(before.all);
205 expect(after.hold).toBe(1);
206 });
207 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs