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

An admin sets the repository's description from the terminal's About card with `w`, as Settings does #8265

Merged opened this

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

Merged

torque/forge:torque/8066-an-admin-sets-the-repository-s-descripti → main 2 commits fork 2 threads · 2 unresolved

  • Merged. torque/8066-an-admin-sets-the-repository-s-descripti was merged into main as 0087ed2.

Review

Sign in to review this change.
3 files changed +187 −1
README.md +1
@@ -507,6 +507,7 @@ This page is the front door. Everything else is a file of its own under
507507 - The `flow` label: ready issues labelled `flow` go first (`readyNow` in `src/core/dependencies.ts` is the one place); when none is ready, builders take other ready work as usual. There is no switch and no state. A writer puts an issue first with the **Flow first** button on the issue and on each row of its list (`POST …/issues/:number/flow`), with `f` on the terminal issue screen, or with MCP `labels` and `action: "add"` (all three make the label on first use, for a writer only); the map's "Ready now" shows labelled work on top, marked `flow`.
508508 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
509509 - Visibility at the terminal: on the About card (`I`) a repository admin who is a person sees `V make private` on a public repository and `V make public` on a private one. `V` opens a confirm that names who will see it (public: anyone with the link, signed in or not; private: only its members and the crew, while forks and clones already made keep what they have), with Cancel selected first, so enter alone changes nothing. Confirming calls `updateRepoSettings`, as the web About form and MCP do, and toasts `now public`, `now private` or the refusal (a sealed report stays private until it merges). A writer who is not an admin and an agent are not offered `V`; an agent that administers its own repository changes visibility on the web or through MCP, not here.
510 - Description at the terminal: on the About card (`I`) a repository admin who is a person sees `w write description`. `w` opens a one-line prompt filled with the current description; enter saves it through `updateRepoSettings` (as the web form and MCP `repo_settings` do, so it is audited the same way) and toasts `description saved`, esc changes nothing, and an empty line clears it. Control characters and more than 2000 characters are refused in a toast and nothing is written. That cap and the control-character rule live in the terminal only: the web form and the API accept any text. A reader and an agent are not offered `w`, and pressing it does nothing.
510511 - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none).
511512 - Stop running as an app at the terminal: `X` on the Deploys screen, offered only to the repository's admin who is a person while it is an app (a writer who is not an admin, an agent, a reader or a guest sees no `X` and it does nothing). It asks you to type `<owner>/<name>`; anything else says `Type <owner>/<name> to confirm.` and changes nothing. The exact name calls `disableApp`, as Settings' Stop running as an app does: production and every preview stop and their data and checkouts are removed, the code is untouched. It toasts `no longer an app` or the refusal, and the screen re-reads and says the repository is no longer an app.
512513 - Everyday account at the terminal: a forge admin with no choice made sees a muted line at the head of the dashboard asking, as the web home card does. `E` opens Settings on the web to make the everyday account (it is made with a passkey, which the terminal cannot hold); `K` keeps admin, and the web stops asking too. Once linked, the line names the everyday account. Anyone else, and an agent, sees neither key.
src/tui/screens/about.ts +40 −1
@@ -71,7 +71,7 @@ import { style } from "../term/style.ts";
7171 import { truncate, wrap } from "../term/width.ts";
7272 import { badge, card } from "../ui/widgets.ts";
7373 import type { Session } from "../session.ts";
74 import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts";
74 import { PIN_MARK, ago, confirmScreen, drawFacts, plural, promptScreen } from "./common.ts";
7575 import { connectionsScreen, egressScreen } from "./egress.ts";
7676 import { eventsScreen } from "./events.ts";
7777 import { newPullScreen } from "./newpull.ts";
@@ -197,6 +197,7 @@ export function aboutScreen(
197197 ),
198198 ]
199199 : []),
200 ...(mayPerson() ? [binding(["w"], "write description")] : []),
200201 ...(mayOutsiders()
201202 ? [
202203 binding(
@@ -484,6 +485,34 @@ export function aboutScreen(
484485 });
485486 return true;
486487 }
488 if (e.key === "w" && repo !== undefined && mayPerson()) {
489 const at = repo;
490 ctx.push(
491 promptScreen(ctx, screen, {
492 title: "Description",
493 label: "What this repository is for",
494 value: at.description,
495 hint: "one line; empty clears it",
496 onSubmit: (value) => {
497 const refusal = descriptionRefusal(value);
498 if (refusal !== undefined) {
499 ctx.toast(refusal, "danger");
500 return refusal;
501 }
502 void updateRepoSettings(f.app, f.user, at, { description: value }).then((done) => {
503 if (done.ok) {
504 repo = getRepoById(f.db, done.value.id) ?? done.value;
505 rows = factRows();
506 ctx.toast("description saved", "success");
507 } else ctx.toast("that could not be changed", "danger");
508 ctx.invalidate();
509 });
510 return undefined;
511 },
512 }),
513 );
514 return true;
515 }
487516 if (e.key === "t" && repo !== undefined && mayTrail()) {
488517 const shown = getProvenance(f.db, repo.id)?.shown === true;
489518 const done = showProvenance(f.app, f.user, repo, !shown);
@@ -661,6 +690,16 @@ export function detachRows(repo: Repo | undefined): [string, string][] {
661690 return words === null ? [] : [["detach", words]];
662691 }
663692
693 /** The longest description the MCP `repo_settings` tool takes; Settings has no cap of its own. */
outdated

The comment on line 693 ("One line when an admin has put the repository away.") now sits above DESCRIPTION_MAX instead of archivedRow. Move the new constant and descriptionRefusal above that comment or elsewhere.

694 const DESCRIPTION_MAX = 2000;
695
696 /** Why a typed description is refused, in words, or nothing when it stands. */
697 function descriptionRefusal(value: string): string | undefined {
698 if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) return "a description is one line of plain text";
699 if ([...value].length > DESCRIPTION_MAX) return `a description is ${DESCRIPTION_MAX} characters at most`;
outdated

The control-character branch has no test. Add one that would fail without it.

700 return undefined;
701 }
702
664703 /** One line when an admin has put the repository away. */
665704 function archivedRow(repo: Repo | undefined): [string, string][] {
666705 if (repo === undefined || repo.archivedAt === null) return [];
test/tui-about-description.test.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The description at the terminal's door (#8066): `w` on the About card writes it
3 * through updateRepoSettings, for an admin who is a person only.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createAgent } from "../src/actions/crew.ts";
11 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
12 import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import type { Ctx, Screen } from "../src/tui/app/app.ts";
15 import { aboutScreen } from "../src/tui/screens/about.ts";
16 import { theme } from "../src/tui/ui/theme.ts";
17 import { localSource } from "../src/view/local.ts";
18 import type { Session } from "../src/tui/session.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dataDir: string;
24 let app: App;
25 let founder: User;
26 let writer: User;
27 let bot: User;
28 let repo: Repo;
29 let botRepo: Repo;
30
31 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
32 if (!r.ok) throw new Error(JSON.stringify(r.error));
33 return r.value;
34 };
35 async function until(what: string, done: () => boolean, ms = 5000): Promise<void> {
36 const end = Date.now() + ms;
37 while (!done()) {
38 if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
39 await Bun.sleep(10);
40 }
41 }
42
43 function card(user: User, target: Repo = repo) {
44 const pushed: Screen[] = [];
45 const toasts: string[] = [];
46 const ctx = {
47 theme: theme("dark"),
48 invalidate() {},
49 toast: (t: string) => toasts.push(t),
50 push: (s: Screen) => pushed.push(s),
51 pop() {},
52 copy() {},
53 hit() {},
54 } as unknown as Ctx;
55 const f = { app, db: app.db, user } as unknown as Session;
56 const src = localSource(app, getRepoById(app.db, target.id)!, user);
57 const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main");
58 const label = (key: string) => screen.keys.find((b) => b.keys.includes(key))?.help;
59 const press = (key: string) => screen.key!({ key } as never);
60 return { pushed, toasts, label, press };
61 }
62 const desc = () => getRepoById(app.db, repo.id)!.description;
63 const typed = (c: ReturnType<typeof card>, text: string) => {
64 for (const ch of text) c.pushed.at(-1)!.key!({ key: ch } as never);
65 };
66 const trail = () =>
67 (app.db.query("SELECT payload FROM event WHERE kind = 'repo.settings_updated' AND repo_id = ?1").all(repo.id) as { payload: string }[]).map((r) => r.payload);
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-description-"));
71 app = createApp({ dataDir, port: 0 });
72 const make = async (username: string, adminIfFirst = false) =>
73 ok(
74 await createUser(app.db, {
75 origin: "seed",
76 trusted: true,
77 username,
78 email: `${username}@forge.local`,
79 password: "a-good-passphrase",
80 adminIfFirst,
81 }),
82 );
83 const root = await make("founding", true);
84 founder = await make("founder");
85 writer = await make("writer");
86 const org = ok(createOrg(app.db, "acme", founder.id));
87 ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "site", visibility: "public", description: "old" }));
88 repo = getRepoById(app.db, (app.db.query("SELECT id FROM repo WHERE name = 'site'").get() as { id: string }).id)!;
89 addTeamMember(app.db, ok(createTeam(app.db, org.id, "Writers", "write")), writer.id);
90 bot = ok(await createAgent(app, root, { username: "bot" })).user;
91 botRepo = ok(await createRepo(app.db, app.reposRoot, { ownerId: bot.id, name: "own", visibility: "public" }));
92 }, 30_000);
93
94 afterAll(async () => {
95 app?.close();
96 await rm(dataDir, { recursive: true, force: true });
97 });
98
99 test("an admin's w, text and enter change the row and the audit trail", async () => {
100 const c = card(founder);
101 expect(c.label("w")).toBe("write description");
102 c.press("w");
103 typed(c, "!");
104 c.pushed[0]!.key!({ key: "enter" } as never);
105 await until("saved", () => desc() === "old!");
106 await until("the toast", () => c.toasts.includes("description saved"));
107 expect(trail().some((p) => p.includes('"description"') && p.includes("old!"))).toBe(true);
108 });
109
110 test("esc leaves it as it was", async () => {
111 const c = card(founder);
112 c.press("w");
113 typed(c, "zzz");
114 c.pushed[0]!.key!({ key: "esc" } as never);
115 await Bun.sleep(20);
116 expect(desc()).toBe("old!");
117 });
118
119 test("a refusal is a toast and the description stays", async () => {
120 const c = card(founder);
121 c.press("w");
122 typed(c, "x".repeat(2000));
123 c.pushed[0]!.key!({ key: "enter" } as never);
124 await Bun.sleep(20);
125 expect(c.toasts.some((t) => t.includes("2000 characters"))).toBe(true);
126 expect(desc()).toBe("old!");
127 });
128
129 test("a control character is refused with a toast and the description stays", async () => {
130 const c = card(founder);
131 c.press("w");
132 typed(c, "bad\u0007bell");
133 c.pushed[0]!.key!({ key: "enter" } as never);
134 await Bun.sleep(20);
135 expect(c.toasts).toContain("a description is one line of plain text");
136 expect(desc()).toBe("old!");
137 });
138
139 test("a reader and an agent admin are not offered w, and it does nothing", () => {
140 for (const [who, at] of [[writer, repo], [bot, botRepo]] as const) {
141 const c = card(who, at);
142 expect(c.label("w")).toBeUndefined();
143 c.press("w");
144 expect(c.pushed).toHaveLength(0);
145 }
146 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs