Public: anyone can read this; the crew can change it.
A repository's Danger zone, delete and detach from parent, speaks the home page's language #9990
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9952-a-repository-s-danger-zone-delete-and-de → main 2 commits fork
5 files
changed +182
−46
docs/design/areas/settings.md +5
| @@ -165,3 +165,8 @@ Settings › Flags (`flagsPage()` in `src/web/pages/flags.ts`, #9872) has no car | ||
| 165 | 165 | ## General (#9853) |
| 166 | 166 | |
| 167 | 167 | Settings › General (`settingsPage()` and `provenanceCard()` in `src/web/pages/settings.ts`, #9853) adds no class and holds no `.card`: About, Your team, On its own and Held files (`src/web/studio/crew.ts`, `file_holds.ts`), Branch and gates, Mirror, Show where this came from, Follow upstream and Followed sources (`upstream-follow.ts`), and Cut a wave and Archive are each a `<section class="settings-block">` with an `<h2>`, one plain sentence and the form. The crew-readiness notices (`crewReadiness()` in `src/web/pages/crew.ts`) are `.toast` rows with `data-tone="danger"`. Every form keeps its action, method, field names and ids. Pinned by `test/settings.test.ts`, which asserts the whole page has no `class="card`. |
| 168 | ||
| 169 | Settings › Danger zone (`src/web/pages/danger.ts`, #9952) adds no component class. The detach (`detachSection()` in | |
| 170 | `src/web/pages/detach.ts`, a fork only) and the delete are each a `<section class="settings-block">` with an `<h2>`, | |
| 171 | no `.card`: the plan says what goes and what survives, its danger button goes through `/-/confirm`. A running detach is | |
| 172 | one line with since-when; a failed one is a danger `.toast` over the plan. `test/danger-language.test.ts` holds the shape. |
src/web/pages/danger.ts +18 −27
| @@ -12,10 +12,9 @@ import { loadRepo, repoBase, repoNotFound } from "../chrome.ts"; | ||
| 12 | 12 | import { DELETE_REPO } from "../danger_words.ts"; |
| 13 | 13 | import { field, readForm } from "../form.ts"; |
| 14 | 14 | import { html } from "../html.ts"; |
| 15 | import { icon } from "../icons.ts"; | |
| 16 | 15 | import { flashTo } from "../layout.ts"; |
| 17 | import { card, confirmAction } from "../ui.ts"; | |
| 18 | import { detachCard } from "./detach.ts"; | |
| 16 | import { confirmAction } from "../ui.ts"; | |
| 17 | import { detachSection } from "./detach.ts"; | |
| 19 | 18 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; |
| 20 | 19 | |
| 21 | 20 | export function dangerPage(ctx: Ctx): Response { |
| @@ -25,35 +24,27 @@ export function dangerPage(ctx: Ctx): Response { | ||
| 25 | 24 | const base = repoBase(repo); |
| 26 | 25 | const full = `${repo.ownerName}/${repo.name}`; |
| 27 | 26 | |
| 28 | const body = card( | |
| 29 | html`<p> | |
| 30 | Deleting <strong>${full}</strong> removes its issues, pull requests, | |
| 31 | reviews, labels and webhooks, and erases the git objects from disk. | |
| 32 | </p> | |
| 33 | <p class="muted small mt"> | |
| 34 | Any fork of this repository is detached first — it repacks so it owns | |
| 35 | its objects, then loses the link. Forks survive; this repository does | |
| 36 | not. There is no undo and no backup. | |
| 37 | </p> | |
| 38 | <div class="form-actions"> | |
| 39 | ${confirmAction({ | |
| 40 | action: `${base}/settings/delete`, | |
| 41 | verb: DELETE_REPO, | |
| 42 | })} | |
| 43 | </div>`, | |
| 44 | { | |
| 45 | tone: "danger", | |
| 46 | head: html`${icon("alert")} | |
| 47 | <h2>${DELETE_REPO}</h2>`, | |
| 48 | }, | |
| 49 | ); | |
| 50 | const detach = detachCard(ctx.app.db, repo, ctx.auth.user); | |
| 27 | const remove = html`<section class="settings-block" id="delete"> | |
| 28 | <h2>${DELETE_REPO}</h2> | |
| 29 | <p> | |
| 30 | Deleting <strong>${full}</strong> removes its issues, pull requests, | |
| 31 | reviews, labels and webhooks, and erases the git objects from disk. | |
| 32 | </p> | |
| 33 | <p class="muted small"> | |
| 34 | Any fork of this repository is detached first — it repacks so it owns | |
| 35 | its objects, then loses the link. Forks survive; this repository does | |
| 36 | not. There is no undo and no backup. | |
| 37 | </p> | |
| 38 | <div class="form-actions"> | |
| 39 | ${confirmAction({ action: `${base}/settings/delete`, verb: DELETE_REPO })} | |
| 40 | </div> | |
| 41 | </section>`; | |
| 51 | 42 | const response = settingsPageResponse( |
| 52 | 43 | ctx, |
| 53 | 44 | repo, |
| 54 | 45 | "danger", |
| 55 | 46 | "Danger zone", |
| 56 | detach === null ? body : html`${detach}<div class="mt">${body}</div>`, | |
| 47 | html`${detachSection(ctx.app.db, repo, ctx.auth.user)}${remove}`, | |
| 57 | 48 | ); |
| 58 | 49 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 59 | 50 | return response; |
src/web/pages/detach.ts +31 −17
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | * Settings › Danger zone › "Detach from parent" (#7452). The card is the | |
| 2 | * Settings › Danger zone › "Detach from parent" (#7452). The section is the | |
| 3 | 3 | * plan — what the detach copies and what it ends — and its button starts |
| 4 | * it. While the repack runs the card says so and since when; a failure | |
| 5 | * stays on the card with git's line until the next try. The button goes | |
| 4 | * it. While the repack runs the section says so and since when; a failure | |
| 5 | * stays on it with git's line until the next try. The button goes | |
| 6 | 6 | * through /-/confirm first (#7978): the detach has no undo. The mechanics are |
| 7 | 7 | * `actions/detach.ts`. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { detachFromParent, detachJob } from "../../actions/detach.ts"; | |
| 10 | import { detachFromParent, detachJob, type DetachJob } from "../../actions/detach.ts"; | |
| 11 | 11 | import { forkParentFor, type Repo } from "../../core/repos.ts"; |
| 12 | 12 | import type { User } from "../../core/users.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| @@ -15,12 +15,21 @@ import { repoBase } from "../chrome.ts"; | ||
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { icon } from "../icons.ts"; |
| 17 | 17 | import { flashTo } from "../layout.ts"; |
| 18 | import { card, confirmAction, timeAgo } from "../ui.ts"; | |
| 18 | import { confirmAction, timeAgo } from "../ui.ts"; | |
| 19 | 19 | import type { Database } from "bun:sqlite"; |
| 20 | 20 | import { adminForm } from "./settings.ts"; |
| 21 | 21 | |
| 22 | /** The card, for a fork; null on a repository that follows nothing. */ | |
| 23 | export function detachCard( | |
| 22 | /** What the detach section shows: plain data, read by `detachSection`. */ | |
| 23 | export interface DetachView { | |
| 24 | /** owner/name of the parent, or "its parent" when the viewer may not read it. */ | |
| 25 | readonly name: string; | |
| 26 | /** Where the confirmed detach posts. */ | |
| 27 | readonly action: string; | |
| 28 | readonly job: DetachJob | undefined; | |
| 29 | } | |
| 30 | ||
| 31 | /** The section, for a fork; null on a repository that follows nothing. */ | |
| 32 | export function detachSection( | |
| 24 | 33 | db: Database, |
| 25 | 34 | repo: Repo, |
| 26 | 35 | viewer: User | undefined, |
| @@ -29,8 +38,15 @@ export function detachCard( | ||
| 29 | 38 | // Named only for an admin who may read it; a fork never speaks for a |
| 30 | 39 | // parent that was not opened to them. |
| 31 | 40 | const parent = forkParentFor(db, repo, viewer); |
| 32 | const name = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`; | |
| 33 | const job = detachJob(repo.id); | |
| 41 | return detachBlock({ | |
| 42 | name: parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`, | |
| 43 | action: `${repoBase(repo)}/settings/detach`, | |
| 44 | job: detachJob(repo.id), | |
| 45 | }); | |
| 46 | } | |
| 47 | ||
| 48 | /** The plan and its button, the repack running since when, or the last failure over the plan. */ | |
| 49 | export function detachBlock({ name, action, job }: DetachView): Html { | |
| 34 | 50 | const body = |
| 35 | 51 | job?.phase === "repacking" |
| 36 | 52 | ? html`<p data-detach-progress> |
| @@ -54,17 +70,15 @@ export function detachCard( | ||
| 54 | 70 | Branches, history, issues and pulls stay. There is no undo. |
| 55 | 71 | </p> |
| 56 | 72 | <div class="form-actions"> |
| 57 | ${confirmAction({ action: `${repoBase(repo)}/settings/detach`, verb: "Detach from parent" })} | |
| 73 | ${confirmAction({ action, verb: "Detach from parent" })} | |
| 58 | 74 | </div>`; |
| 59 | return card(body, { | |
| 60 | id: "detach", | |
| 61 | tone: "danger", | |
| 62 | head: html`${icon("fork")} | |
| 63 | <h2>Detach from parent</h2>`, | |
| 64 | }); | |
| 75 | return html`<section class="settings-block" id="detach"> | |
| 76 | <h2>Detach from parent</h2> | |
| 77 | ${body} | |
| 78 | </section>`; | |
| 65 | 79 | } |
| 66 | 80 | |
| 67 | /** POST /:owner/:name/settings/detach — start the detach; the card shows it. */ | |
| 81 | /** POST /:owner/:name/settings/detach — start the detach; the section shows it. */ | |
| 68 | 82 | export async function detachSubmit(ctx: Ctx): Promise<Response> { |
| 69 | 83 | const gate = await adminForm(ctx); |
| 70 | 84 | if (gate instanceof Response) return gate; |
test/danger-language.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * Settings › Danger zone speaks the home page's language (#9952): the delete | |
| 3 | * and the detach are each a settings-block under an h2, no card. The typed | |
| 4 | * name still guards the delete; the detach still shows its plan, its running | |
| 5 | * state with since-when, and its failure. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createRepo, forkRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import { commitFiles } from "../src/git/write.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { detachBlock } from "../src/web/pages/detach.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { signUpProven as signUp } from "./helpers/verified.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dataDir = ""; | |
| 23 | let app: App, servers: Servers; | |
| 24 | let fork: Repo; | |
| 25 | let forker = "", stranger = ""; | |
| 26 | ||
| 27 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 28 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 29 | return r.value; | |
| 30 | }; | |
| 31 | ||
| 32 | const signIn = async (username: string) => { | |
| 33 | const r = await fetch(`${servers.url}/signin`, { | |
| 34 | method: "POST", | |
| 35 | headers: { origin: servers.url }, | |
| 36 | body: new URLSearchParams({ username, password: "a-long-passphrase" }), | |
| 37 | redirect: "manual", | |
| 38 | }); | |
| 39 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 40 | }; | |
| 41 | ||
| 42 | beforeAll(async () => { | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-danger-lang-")); | |
| 44 | app = createApp({ dataDir, port: 0 }); | |
| 45 | servers = await serve(app); | |
| 46 | const user = async (username: string) => | |
| 47 | ok(await signUp(app, { username, email: `${username}@forge.test`, password: "a-long-passphrase" })); | |
| 48 | const owner = await user("upstreamer"); | |
| 49 | const down = await user("forker"); | |
| 50 | await user("stranger"); | |
| 51 | const parent = ok(await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "up", visibility: "public" })); | |
| 52 | ok( | |
| 53 | await commitFiles(repoDir(app.reposRoot, parent.ownerName, parent.name), { | |
| 54 | branch: "main", | |
| 55 | message: "one", | |
| 56 | files: [{ path: "one.txt", content: "one" }], | |
| 57 | author: { name: "T", email: "[email protected]" }, | |
| 58 | }), | |
| 59 | ); | |
| 60 | fork = ok(await forkRepo(app.db, app.reposRoot, parent, down.id, "down")); | |
| 61 | forker = await signIn("forker"); | |
| 62 | stranger = await signIn("stranger"); | |
| 63 | }, 30_000); | |
| 64 | ||
| 65 | afterAll(async () => { | |
| 66 | servers.stop(); | |
| 67 | app.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const base = () => `/${fork.ownerName}/${fork.name}`; | |
| 72 | const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 73 | const danger = async (cookie: string) => fetch(`${servers.url}${base()}/settings/danger`, { headers: { cookie } }); | |
| 74 | ||
| 75 | test("the danger zone is two settings-blocks under h2s, no card", async () => { | |
| 76 | const m = main(await (await danger(forker)).text()); | |
| 77 | expect(m).not.toContain('class="card'); | |
| 78 | expect(m).toMatch(/<section class="settings-block" id="detach">\s*<h2>Detach from parent<\/h2>/); | |
| 79 | expect(m).toMatch(/<section class="settings-block" id="delete">\s*<h2>Delete this repository<\/h2>/); | |
| 80 | // what goes and what survives | |
| 81 | expect(m).toMatch(/Forks survive; this repository does\s+not\./); | |
| 82 | expect(m).toContain(`href="/-/confirm?to=${encodeURIComponent(`${base()}/settings/delete`)}"`); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the detach shows its plan with its parent named", async () => { | |
| 86 | const m = main(await (await danger(forker)).text()); | |
| 87 | const detach = m.slice(m.indexOf('id="detach"'), m.indexOf('id="delete"')); | |
| 88 | expect(detach).toContain("<strong>upstreamer/up</strong>"); | |
| 89 | expect(detach).toContain("There is no undo."); | |
| 90 | expect(detach).toContain(`href="/-/confirm?to=${encodeURIComponent(`${base()}/settings/detach`)}"`); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the detach shows its running state since when, and its failure over the plan", () => { | |
| 94 | const startedAt = new Date(Date.now() - 5 * 60_000).toISOString(); | |
| 95 | const done = Promise.resolve(); | |
| 96 | const action = `${base()}/settings/detach`; | |
| 97 | const running = String(detachBlock({ name: "upstreamer/up", action, job: { phase: "repacking", startedAt, message: "", done } })); | |
| 98 | expect(running).toContain('<section class="settings-block" id="detach">'); | |
| 99 | expect(running).toContain("data-detach-progress"); | |
| 100 | expect(running).toContain("started 5m ago"); | |
| 101 | expect(running).not.toContain("/-/confirm"); | |
| 102 | ||
| 103 | const failed = String( | |
| 104 | detachBlock({ name: "upstreamer/up", action, job: { phase: "failed", startedAt, message: "repack died", done } }), | |
| 105 | ); | |
| 106 | expect(failed).toContain("data-detach-failed"); | |
| 107 | expect(failed).toContain("The last detach stopped: repack died."); | |
| 108 | expect(failed).toContain(`href="/-/confirm?to=${encodeURIComponent(action)}"`); | |
| 109 | expect(failed).not.toContain('class="card'); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the delete refuses unless the full name is typed back", async () => { | |
| 113 | const res = await fetch(`${servers.url}${base()}/settings/delete`, { | |
| 114 | method: "POST", | |
| 115 | headers: { cookie: forker, origin: servers.url }, | |
| 116 | body: new URLSearchParams({ confirm: fork.name }), | |
| 117 | redirect: "manual", | |
| 118 | }); | |
| 119 | expect(res.status).toBe(303); | |
| 120 | expect(res.headers.get("location")).toBe(`${base()}/settings/danger`); | |
| 121 | expect(getRepoById(app.db, fork.id)).toBeDefined(); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a non-admin gets the one 404", async () => { | |
| 125 | expect((await danger(stranger)).status).toBe(404); | |
| 126 | }); |
test/fork-detach.test.ts +2 −2
| @@ -227,8 +227,8 @@ describe("detach from parent", () => { | ||
| 227 | 227 | const fork = ok(await forkRepo(app.db, app.reposRoot, secret, forker.id, `kept${n}`)); |
| 228 | 228 | const forkDir = repoDir(app.reposRoot, fork.ownerName, fork.name); |
| 229 | 229 | const name = `${secret.ownerName}/${secret.name}`; |
| 230 | const { detachCard } = await import("../src/web/pages/detach.ts"); | |
| 231 | const card = String(detachCard(app.db, fork, forker)); | |
| 230 | const { detachSection } = await import("../src/web/pages/detach.ts"); | |
| 231 | const card = String(detachSection(app.db, fork, forker)); | |
| 232 | 232 | expect(card).toContain("its parent"); |
| 233 | 233 | expect(card).not.toContain(secret.name); |
| 234 | 234 |