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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7057-every-repository-admin-sees-the-held-fil → main 2 commits fork 6 threads · 6 unresolved
README.md +2
| @@ -40,6 +40,8 @@ when). The terminal's palette has the same as "Crew hand-overs", a read-only | ||
| 40 | 40 | list of the same rows (repo#number, title, reasons; it does not open the |
| 41 | 41 | issue — use the link on the page). Both are a forge admin's only. |
| 42 | 42 | |
| 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 | ||
| 43 | 45 | 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. |
| 44 | 46 | |
| 45 | 47 | 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 @@ | ||
| 1 | 1 | # Boards |
| 2 | 2 | |
| 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 @@ | ||
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import type { User } from "./users.ts"; |
| 12 | 12 | |
| 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 | |
| 19 | 15 | OR repo.owner_id = ?1 |
| 20 | 16 | OR repo.owner_id IN ( |
| 21 | 17 | SELECT team.org_id FROM team |
| 22 | 18 | 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}`) | |
| 25 | 36 | .all(user.id, user.isAdmin ? 1 : 0); |
| 26 | 37 | return new Set(rows.map((r) => r.id)); |
| 27 | 38 | } |
src/http/server.ts +2
| @@ -29,6 +29,7 @@ import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } | ||
| 29 | 29 | import { measureSubmit } from "./measure.ts"; |
| 30 | 30 | import { boardsPage } from "../web/pages/boards.ts"; |
| 31 | 31 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; |
| 32 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; | |
| 32 | 33 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 33 | 34 | import { |
| 34 | 35 | closeStreamsOf, |
| @@ -889,6 +890,7 @@ export function buildRouter(): Router<Handler> { | ||
| 889 | 890 | router.add("GET", "/boards", boardsList); |
| 890 | 891 | router.add("GET", "/boards/data", boardsPage); |
| 891 | 892 | router.add("POST", "/boards/new", boardNew); |
| 893 | router.add("POST", "/boards/holds", boardsHoldPost); | |
| 892 | 894 | router.add("POST", "/boards/pin", boardPin); |
| 893 | 895 | for (const op of ["pin-panel", "unpin-panel", "move-pin"]) router.add("POST", `/boards/${op}`, pinPost); |
| 894 | 896 | router.add("GET", "/boards/:slug", boardShow); |
src/tui/screens/fileholds.ts +10 −1
| @@ -6,9 +6,11 @@ | ||
| 6 | 6 | * owner/name path hold new work on path |
| 7 | 7 | * release owner/name path let it go |
| 8 | 8 | * owner/name list the holds, who set them and when |
| 9 | * (a bare line) list every hold in the repositories you administer | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import { holdFileAs, listFileHolds, releaseFileAs } from "../../actions/file_holds.ts"; |
| 13 | import { boardHolds } from "../../core/admin_holds.ts"; | |
| 12 | 14 | import { getRepo } from "../../core/repos.ts"; |
| 13 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 16 | import type { Session } from "../session.ts"; |
| @@ -25,6 +27,13 @@ export function fileHoldLine(f: Session, text: string): { ok: boolean; says: str | ||
| 25 | 27 | const words = text.trim().split(/\s+/).filter((w) => w !== ""); |
| 26 | 28 | const release = words[0] === "release"; |
| 27 | 29 | 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 | } | |
| 28 | 37 | const [slug, path, ...extra] = words; |
| 29 | 38 | const [owner, name, ...rest] = (slug ?? "").split("/"); |
| 30 | 39 | 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 | ||
| 49 | 58 | export function fileHoldsPrompt(f: Session, ctx: Ctx, parent: Screen): Screen { |
| 50 | 59 | return promptScreen(ctx, parent, { |
| 51 | 60 | 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", | |
| 53 | 62 | hint: "no build starts on an issue that names a held path", |
| 54 | 63 | onSubmit: (text) => { |
| 55 | 64 | const done = fileHoldLine(f, text); |
src/web/layout.ts +4
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { OPENSEARCH_PATH, OPENSEARCH_TITLE, OPENSEARCH_TYPE } from "./opensearch.ts"; |
| 11 | 11 | import { protectionNotice } from "../kernel/protection.ts"; |
| 12 | import { administersAny } from "../core/admin_repos.ts"; | |
| 12 | 13 | import { appsForRepos } from "../core/apps.ts"; |
| 13 | 14 | import { getRepo, listReposVisibleTo } from "../core/repos.ts"; |
| 14 | 15 | import { mayDirectCrew } from "../crew/direct.ts"; |
| @@ -225,6 +226,9 @@ function userMenu( | ||
| 225 | 226 | { label: "Access tokens", href: "/settings/tokens", icon: "key" }, |
| 226 | 227 | { label: "SSH keys", href: "/settings/keys", icon: "lock" }, |
| 227 | 228 | { 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 | : []), | |
| 228 | 232 | ...(user.isAdmin |
| 229 | 233 | ? [ |
| 230 | 234 | { 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} | |
| 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 |
||
| 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"; | ||
| 20 | 20 | import { fragment, page } from "../layout.ts"; |
| 21 | 21 | import { btn, card, chartBox, empty, field, selectField } from "../ui.ts"; |
| 22 | 22 | import { flowPanel } from "./flow-panel.ts"; |
| 23 | import { boardsHoldsCard } from "./boards-holds.ts"; | |
| 23 | 24 | import { forgeCeiling } from "../../flow/ceiling.ts"; |
| 24 | 25 | import { alarmLines, cachedAlarms } from "../studio/alarm_lines.ts"; |
| 25 | 26 | import { isPinned, listPins } from "../../boards/pins.ts"; |
| @@ -85,7 +86,12 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 85 | 86 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 86 | 87 | if (ctx.auth.user === undefined) return signInFirst(ctx); |
| 87 | 88 | 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 |
||
| 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 | } | |
| 89 | 95 | const make = html`<form method="post" action="/boards/new" class="bar"> |
| 90 | 96 | ${field({ label: "New board", name: "title", id: "new-board-title", required: true, maxlength: MAX_TITLE, placeholder: "Title" })} |
| 91 | 97 | ${btn("Make board", { variant: "primary" })} |
| @@ -112,7 +118,7 @@ export async function boardsList(ctx: Ctx): Promise<Response> { | ||
| 112 | 118 | body: html`<div class="page-head"><h1>Boards</h1> |
| 113 | 119 | ${btn("Explore the data", { href: "/boards/data", variant: "ghost" })}</div> |
| 114 | 120 | ${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)}`, | |
| 116 | 122 | }); |
| 117 | 123 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 118 | 124 | 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("<img src=x>"); | |
| 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 |
||
| 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 | }); |
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.