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

Merged opened this

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

Merged

mason/forge:mason/9952-a-repository-s-danger-zone-delete-and-de → main 2 commits fork

  • Merged. mason/9952-a-repository-s-danger-zone-delete-and-de was merged into main as 12052f4.

Review

Sign in to review this change.
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
165165 ## General (#9853)
166166
167167 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";
1212 import { DELETE_REPO } from "../danger_words.ts";
1313 import { field, readForm } from "../form.ts";
1414 import { html } from "../html.ts";
15 import { icon } from "../icons.ts";
1615 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";
1918 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
2019
2120 export function dangerPage(ctx: Ctx): Response {
@@ -25,35 +24,27 @@ export function dangerPage(ctx: Ctx): Response {
2524 const base = repoBase(repo);
2625 const full = `${repo.ownerName}/${repo.name}`;
2726
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>`;
5142 const response = settingsPageResponse(
5243 ctx,
5344 repo,
5445 "danger",
5546 "Danger zone",
56 detach === null ? body : html`${detach}<div class="mt">${body}</div>`,
47 html`${detachSection(ctx.app.db, repo, ctx.auth.user)}${remove}`,
5748 );
5849 notePhase(ctx.request, "render", performance.now() - rendering);
5950 return response;
src/web/pages/detach.ts +31 −17
@@ -1,13 +1,13 @@
11 /**
2 * Settings › Danger zone › "Detach from parent" (#7452). The card is the
2 * Settings › Danger zone › "Detach from parent" (#7452). The section is the
33 * 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
66 * through /-/confirm first (#7978): the detach has no undo. The mechanics are
77 * `actions/detach.ts`.
88 */
99
10 import { detachFromParent, detachJob } from "../../actions/detach.ts";
10 import { detachFromParent, detachJob, type DetachJob } from "../../actions/detach.ts";
1111 import { forkParentFor, type Repo } from "../../core/repos.ts";
1212 import type { User } from "../../core/users.ts";
1313 import type { Ctx } from "../../http/context.ts";
@@ -15,12 +15,21 @@ import { repoBase } from "../chrome.ts";
1515 import { html, type Html } from "../html.ts";
1616 import { icon } from "../icons.ts";
1717 import { flashTo } from "../layout.ts";
18 import { card, confirmAction, timeAgo } from "../ui.ts";
18 import { confirmAction, timeAgo } from "../ui.ts";
1919 import type { Database } from "bun:sqlite";
2020 import { adminForm } from "./settings.ts";
2121
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(
2433 db: Database,
2534 repo: Repo,
2635 viewer: User | undefined,
@@ -29,8 +38,15 @@ export function detachCard(
2938 // Named only for an admin who may read it; a fork never speaks for a
3039 // parent that was not opened to them.
3140 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 {
3450 const body =
3551 job?.phase === "repacking"
3652 ? html`<p data-detach-progress>
@@ -54,17 +70,15 @@ export function detachCard(
5470 Branches, history, issues and pulls stay. There is no undo.
5571 </p>
5672 <div class="form-actions">
57 ${confirmAction({ action: `${repoBase(repo)}/settings/detach`, verb: "Detach from parent" })}
73 ${confirmAction({ action, verb: "Detach from parent" })}
5874 </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>`;
6579 }
6680
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. */
6882 export async function detachSubmit(ctx: Ctx): Promise<Response> {
6983 const gate = await adminForm(ctx);
7084 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", () => {
227227 const fork = ok(await forkRepo(app.db, app.reposRoot, secret, forker.id, `kept${n}`));
228228 const forkDir = repoDir(app.reposRoot, fork.ownerName, fork.name);
229229 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));
232232 expect(card).toContain("its parent");
233233 expect(card).not.toContain(secret.name);
234234

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs