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

An admin deletes a repository from the terminal's `:` palette after typing its full name, as Settings' Delete this repository does #8147

Merged opened this

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

Merged

mason/forge:mason/8031-an-admin-deletes-a-repository-from-the-t → main 6 commits fork 2 threads · 2 unresolved

  • Merged. mason/8031-an-admin-deletes-a-repository-from-the-t was merged into main as a01cdb2.

Review

Sign in to review this change.
5 files changed +263 −0
README.md +2
@@ -75,6 +75,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page'
7575
7676 In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge.
7777
78 In the terminal, a repository screen's `:` palette offers **Delete this repository** (`<owner>/<name> — Delete this repository`, "cannot be undone") to a person who administers it, never to an agent, a writer who is not an admin or a reader. It asks for the full `<owner>/<name>`; anything else is refused with "Type <owner>/<name> to confirm." and nothing changes. The exact name calls `deleteRepo` (`src/actions/repos.ts`, as Settings' danger zone does), toasts the result and unwinds the stack to the screen beneath the repository's (`leaveRepo`), so no screen reads the deleted row.
79
7880 In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. On that list an admin of the organization adds a person with `a` (type a username) and removes the member under the cursor with `d` after a y/n, in the Teams page's words; a member who is not an org admin is offered neither key.
7981
8082 In the terminal, the palette's **Connected origins** lists the hosts your mirrors come from (a signed-in person's, not an agent's): `s` syncs the selected one now, `d` disconnects it after a confirm; see `docs/ORIGINS.md`.
src/tui/app/app.ts +15
@@ -88,6 +88,8 @@ export interface Screen {
8888 load?(signal: AbortSignal): Promise<void>;
8989 /** Palette rows only this screen offers, listed after the app's own. */
9090 commands?(): Command[];
91 /** The local repository this screen shows, so `leaveRepo` can find it when it is deleted. */
92 readonly repoId?: string;
9193 }
9294
9395 /**
@@ -139,6 +141,11 @@ export interface Ctx {
139141 push(screen: Screen): void;
140142 pop(): void;
141143 replace(screen: Screen): void;
144 /**
145 * A repository is gone: drop its lowest screen and everything above it, down
146 * to the one beneath (never the root), so nothing reads the deleted row.
147 */
148 leaveRepo(repoId: string): void;
142149 invalidate(): void;
143150 toast(text: string, tone?: Tone): void;
144151 /** Where the terminal caret belongs this frame, if anywhere. */
@@ -395,6 +402,7 @@ export class App {
395402 push: (s) => app.push(s),
396403 pop: () => app.pop(),
397404 replace: (s) => app.replace(s),
405 leaveRepo: (id) => app.leaveRepo(id),
398406 invalidate: () => app.invalidate(),
399407 toast: (text, tone) => app.toast(text, tone),
400408 track: (work) => app.track(work),
@@ -468,6 +476,13 @@ export class App {
468476 this.invalidate();
469477 }
470478
479 leaveRepo(repoId: string): void {
480 const at = this.stack.findIndex((e) => e.screen.repoId === repoId);
481 if (at < 0) return;
482 for (const e of this.stack.splice(Math.max(at, 1))) e.abort.abort();
483 this.invalidate();
484 }
485
471486 replace(screen: Screen): void {
472487 this.stack.pop()?.abort.abort();
473488 this.push(screen);
src/tui/screens/deleterepo.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * Delete this repository, from a repository screen's `:` palette (#8031): the
3 * terminal's door to Settings' danger zone. The full name is typed before
4 * anything goes, and only a person who administers the repository is offered it.
5 */
6
7 import { deleteRepo } from "../../actions/repos.ts";
8 import { canAdmin, type Repo } from "../../core/repos.ts";
9 import { DELETE_REPO } from "../../web/danger_words.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import type { Command } from "../app/palette.ts";
12 import type { Session } from "../session.ts";
13 import { promptScreen } from "./common.ts";
14
15 const GOES =
16 "every issue, pull request, comment and webhook, and the git objects; forks are detached first and keep working";
17
18 /** The prompt floats over whatever is already drawn; this is the empty backdrop it asks for. */
19 const backdrop: Screen = {
20 title: DELETE_REPO,
21 keys: [],
22 render() {},

This kind !== user check is untested (the test passes without it). Make a test that fails without it, or remove the check.

23 key: () => false,
24 };
25
26 export function deleteRepoCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
27 if (repo === undefined || f.user === undefined || f.user.kind !== "user") return [];
28 if (!canAdmin(f.db, repo, f.user)) return [];
29 const full = `${repo.ownerName}/${repo.name}`;
30 return [
31 {
32 id: "delete-repository",
33 title: `${full} — ${DELETE_REPO}`,
34 hint: "cannot be undone",
35 run: () =>
36 ctx.push(
37 promptScreen(ctx, backdrop, {
38 title: `${DELETE_REPO} (cannot be undone)`,
39 label: `Removes ${GOES}. Type ${full}:`,
40 placeholder: full,
41 onSubmit: (typed) => {
42 if (typed !== full) return `Type ${full} to confirm.`;
43 // The prompt pops itself once this returns; the repository's
44 // own screens unwind after the delete, never to read it again.
45 return (async () => {
46 const done = await deleteRepo(f.app, f.user, repo);
47 if (!done.ok) {
48 ctx.toast(done.error === "not_found" ? "no such repository" : "could not delete the repository", "danger");
49 return;
50 }
51 ctx.toast(`deleted ${full}`, "success");
52 ctx.leaveRepo(repo.id);
53 })();

A single pop leaves a nested screen showing a deleted repository. Unwind to the screen below the repository screen.

54 },
55 }),
56 ),
57 },
58 ];
59 }
src/tui/screens/workspace.ts +3
@@ -130,6 +130,7 @@ import { tellCommands } from "./tell.ts";
130130 import { workingCommands } from "./working.ts";
131131 import { forgeItCommands } from "./forge_it.ts";
132132 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
133 import { deleteRepoCommands } from "./deleterepo.ts";
133134 import { visionCommands } from "./vision.ts";
134135 import { labelsCommands } from "./labels.ts";
135136 import { appEnvCommands } from "./appenv.ts";
@@ -1433,6 +1434,7 @@ export function workspaceScreen(
14331434 };
14341435
14351436 const screen: Screen = {
1437 ...(repo === undefined ? {} : { repoId: repo.id }),
14361438 commands: () => [
14371439 ...workingCommands(f, ctx, repo),
14381440 ...tellCommands(f, ctx, () => screen, repo),
@@ -1440,6 +1442,7 @@ export function workspaceScreen(
14401442 ...reportsCommands(f, ctx, repo),
14411443 ...noteCommands(f, ctx, () => screen, repo),
14421444 ...yoursCommands(f, ctx, repo),
1445 ...deleteRepoCommands(f, ctx, repo),
14431446 ...forgeItCommands(f, ctx, repo, () => screen),
14441447 ...hooksCommands(f, ctx, repo),
14451448 ...visionCommands(f, ctx, repo),
test/tui-delete-repo.test.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * Deleting a repository from the terminal's `:` palette (#8031): an admin who
3 * types the full name deletes it; any other text keeps it; a writer who is not
4 * an admin, and an agent, are never offered the command.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createRepo, deleteRepo } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
13 import { canAdmin, canWrite, getRepoById, type Repo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { Screen } from "../src/tui/app/app.ts";
16 import { style } from "../src/tui/term/style.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { deleteRepoCommands } from "../src/tui/screens/deleterepo.ts";
19 import { repoScreen } from "../src/tui/screens/repo.ts";
20 import { local } from "../src/tui/screens/where.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23 import { settleApp } from "./helpers/tui.ts";
24 import { proveAll } from "./helpers/verified.ts";
25
26 quietLogs();
27
28 let dir: string;
29 let app: App;
30 let alice: User;
31 let bob: User;
32 let bot: User;
33
34 const KEYS: Record<string, string> = { enter: "\r" };
35
36 /** A screen that says it is beneath the repository's, so a test can see where the unwinding landed. */
37 const below: Screen = {
38 title: "below",
39 keys: [],
40 render: (area, s) => void s.text(area.x, area.y, "BELOW-THE-REPO", style(0, 0), area.x + area.w),
41 key: () => false,
42 };
43
44 function open(user: User, at: Repo, nested = false) {
45 const terminal = headlessTerminal(120, 40);
46 const { app: tui, session } = createForgeApp(
47 app,
48 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
49 { terminal, exitHooks: false },
50 );
51 tui.start();
52 if (nested) tui.ctx.push(below);
53 tui.ctx.push(repoScreen(session, tui.ctx, local(session, at)));
54 return {
55 session,
56 ctx: tui.ctx,
57 text: () => tui.screen.toText(),
58 feed: async (...keys: string[]) => {
59 for (const k of keys) {
60 terminal.feed(KEYS[k] ?? k);
61 await settleApp(tui);
62 }
63 },
64 stop: () => tui.stop?.(),
65 };
66 }
67
68 /** Open the delete prompt from the palette once its row has drawn. */
69 async function prompt(h: ReturnType<typeof open>): Promise<void> {
70 await h.feed(":", ..."Delete this");
71 for (let i = 0; i < 100 && !h.text().includes("cannot be undone"); i++)
72 await new Promise<void>((r) => setTimeout(r, 10)), await h.feed();
73 await h.feed("enter");
74 }
75
76 async function mkRepo(owner: string | undefined, name: string): Promise<Repo> {
77 const made = await createRepo(app, alice, { ...(owner ? { owner } : {}), name, visibility: "public" });
78 if (!made.ok) throw new Error(String(made.error));
79 return made.value;
80 }
81
82 beforeAll(async () => {
83 dir = await mkdtemp(join(tmpdir(), "forge-tui-delete-repo-"));
84 app = createApp({ dataDir: dir, port: 0 });
85 const mk = async (username: string, first = false) => {
86 const r = await createUser(app.db, {
87 origin: "seed",
88 trusted: true,
89 username,
90 email: `${username}@forge.local`,
91 password: "a-good-passphrase",
92 adminIfFirst: first,
93 });
94 if (!r.ok) throw new Error(r.error);
95 return r.value;
96 };
97 alice = await mk("alice", true);
98 bob = await mk("bob");
99 bot = await mk("crewbot1");
100 // An agent that is also a forge admin: canAdmin accepts it, so only the kind check keeps it out.
101 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE id = ?1").run(bot.id);
102 bot = { ...bot, kind: "agent", isAdmin: true };
103 proveAll(app);
104 });
105 afterAll(async () => {
106 app.db.close();
107 await rm(dir, { recursive: true, force: true });
108 });
109
110 describe("delete a repository in the terminal", () => {
111 test("an admin who types the name deletes the repository and leaves its screen", async () => {
112 const repo = await mkRepo(undefined, "doomed");
113 const h = open(alice, repo);
114 await prompt(h);
115 expect(h.text()).toContain("alice/doomed");
116 await h.feed(..."alice/doomed", "enter");
117 expect(getRepoById(app.db, repo.id)).toBeUndefined();
118 expect(h.text()).toContain("deleted alice/doomed");
119 h.stop();
120 });
121
122 test("a wrong name is refused and the repository stays", async () => {
123 const repo = await mkRepo(undefined, "keeper");
124 const h = open(alice, repo);
125 await prompt(h);
126 await h.feed(..."keeper", "enter");
127 expect(h.text()).toContain("Type alice/keeper to confirm.");
128 expect(getRepoById(app.db, repo.id)).toBeDefined();
129 h.stop();
130 });
131
132 test("a writer who is not an admin, and an agent, are not offered it", async () => {
133 const org = createOrg(app.db, "acme", alice.id);
134 if (!org.ok) throw new Error(org.error);
135 const devs = createTeam(app.db, org.value.id, "devs", "write");
136 if (!devs.ok) throw new Error(devs.error);
137 addTeamMember(app.db, devs.value, bob.id);
138 const shop = await mkRepo("acme", "shop");
139 expect(canWrite(app.db, shop, bob)).toBe(true);
140 expect(canAdmin(app.db, shop, bob)).toBe(false);
141 expect(canAdmin(app.db, shop, bot)).toBe(true);
142
143 for (const who of [bob, bot]) {
144 const h = open(who, shop);
145 expect(deleteRepoCommands(h.session, h.ctx, shop)).toEqual([]);
146 h.stop();
147 }
148 const h = open(alice, shop);
149 expect(deleteRepoCommands(h.session, h.ctx, shop).map((c) => c.title)).toEqual([
150 "acme/shop — Delete this repository",
151 ]);
152 h.stop();
153 });
154
155 test("an admin lands on the screen beneath the repository's, which is not read again", async () => {
156 const repo = await mkRepo(undefined, "unwound");
157 const h = open(alice, repo, true);
158 await prompt(h);
159 await h.feed(..."alice/unwound", "enter");
160 expect(getRepoById(app.db, repo.id)).toBeUndefined();
161 expect(h.text()).toContain("BELOW-THE-REPO");
162 h.stop();
163 });
164
165 test("leaving a repository drops a nested screen above its own as well", async () => {
166 const repo = await mkRepo(undefined, "nested");
167 const h = open(alice, repo, true);
168 h.ctx.push({ ...below, title: "nested", render: (a, s) => void s.text(a.x, a.y, "NESTED-SCREEN", style(0, 0), a.x + a.w) });
169 await h.feed("x");
170 expect(h.text()).toContain("NESTED-SCREEN");
171 h.ctx.leaveRepo(repo.id);
172 await h.feed("x");
173 expect(h.text()).toContain("BELOW-THE-REPO");
174 expect(h.text()).not.toContain("NESTED-SCREEN");
175 h.stop();
176 });
177
178 test("deleteRepo refuses a writer who is not an admin and the repository stays", async () => {
179 const shop = await mkRepo("acme", "kept");
180 const refused = await deleteRepo(app, bob, shop);
181 expect(refused.ok).toBe(false);
182 expect(getRepoById(app.db, shop.id)).toBeDefined();
183 });
184 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs