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

A writer seals or unseals an issue from the terminal's issue screen with `S`, after a confirm, in the issue page's words #8003

Merged opened this

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

Merged

scout/forge:scout/7996-a-writer-seals-or-unseals-an-issue-from → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/7996-a-writer-seals-or-unseals-an-issue-from was merged into main as 6f09866.

Review

Sign in to review this change.
5 files changed +248 −12
README.md +2
@@ -92,6 +92,8 @@ A pull the merge queue holds as red alone (red on its own merge at its current h
9292
9393 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
9494
95 A writer seals or unseals an issue with `S` on the issue's terminal screen (readers are not offered it), after a confirm whose default is Cancel; Unseal is drawn as the danger button because it shows the report, and its fix, to everyone who can read the repository. It goes through `setIssueSealed`, as the issue page's Seal form does, and the toast says the page's words from `src/actions/seal_issue_words.ts`, refusals included: sealing an issue on a mirror is refused, since a mirror's issues live on its origin in the open.
96
9597 When an open issue waits for a blocker that closed as dropped or split, the terminal's issue screen pins a line under the title, "waits for #N (dropped), closed without being done". A writer who is a person (not a reader, not on the crew) sees `g` there: after a confirm (Cancel is the default) it calls `confirmIssue`, as the issue page and MCP `confirm_wait` do, and the line goes. With several blockers held, `g` first asks which one (or all of them).
9698 An issue's title and body are edited on its page, with MCP `edit`, or with `e` on the issue's terminal screen: the editor opens with the title on the first line, a blank line, then the body; `ctrl+d` saves and `esc` discards. All three go through `editIssue`, so the same people may (the author until a writer takes it up, any person who writes the repository; never on a mirror) and a refusal reads the same, with the terminal's draft kept.
9799
src/actions/seal_issue_words.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * What sealing or unsealing an issue says (#7996): one place, so the issue
3 * page's Seal form and the terminal's issue screen `S` read the same.
4 */
5
6 import type { AccessError } from "./access.ts";
7 import type { MirrorError } from "./issues.ts";
8
9 export const SEAL_ISSUE_SAYS = {
10 sealed: "Sealed: only people who can write here and the crew see it now.",
11 unsealed: "Unsealed: everyone who can read this repository sees it now.",
12 mirrored:
13 "A mirror's issues live on its origin, in the open: nothing here can be sealed.",
14 forbidden: "Only someone who can write here seals or unseals.",
15 } as const;
16
17 /** The sentence a seal or unseal said, in the issue page's words. */
18 export function sealedWords(sealed: boolean): string {
19 return sealed ? SEAL_ISSUE_SAYS.sealed : SEAL_ISSUE_SAYS.unsealed;
20 }
21
22 /** A refusal from `setIssueSealed`, in the issue page's words. */
23 export function sealRefusedWords(error: AccessError | MirrorError): string {
24 return error === "mirrored" ? SEAL_ISSUE_SAYS.mirrored : SEAL_ISSUE_SAYS.forbidden;
25 }
src/tui/screens/issue.ts +42
@@ -22,8 +22,10 @@ import {
2222 mayEditIssue,
2323 setIssueLabelsThrough,
2424 setIssueStateThrough,
25 setIssueSealed,
2526 TAKEN_UP,
2627 } from "../../actions/issues.ts";
28 import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts";
2729 import {
2830 dependencyWords,
2931 linkIssue,
@@ -419,6 +421,41 @@ export function issueScreen(
419421 });
420422 };
421423
424 /** `S` — seal or unseal this report, after a confirm that says who will see it. */
425 const seal = (): void => {
426 const at = row();
427 if (at?.issue === undefined) return;
428 const { repo: target, issue } = at;
429 const sealed = !issue.sealed;
430 ctx.push(
431 confirmScreen(ctx, screen, {
432 title: sealed ? "Seal this report?" : "Unseal this report?",
433 body: [
434 sealed
435 ? `Seal #${issue.number}? Only people who can write here and the crew will see it.`
436 : `Unseal #${issue.number}? Everyone who can read ${target.ownerName}/${target.name} sees it and its fix.`,

Document the danger variant on Unseal (it exposes the report), and add the README line for S (see the review body).

437 ],
438 actions: [
439 // Unsealing shows the report and its fix to every reader: the danger button.
440 { label: sealed ? "Seal" : "Unseal", variant: sealed ? "default" : "danger" },
441 { label: "Cancel", variant: "ghost" },
442 ],
443 selected: 1,
444 width: 90,
445 onChoose: (index) => {
446 if (index !== 0) return;
447 const done = setIssueSealed(f.app, f.user, target, issue, sealed);
448 if (!done.ok) {
449 ctx.toast(sealRefusedWords(done.error), "danger");
450 return;
451 }
452 ctx.toast(sealedWords(sealed), "success");
453 runAsync(ctx, reload);
454 },
455 }),
456 );
457 };
458
422459 /**
423460 * `w` — a number to wait for. A new one links it; one it already waits
424461 * for asks first, then stops the wait. Refusals in `dependencyWords`.
@@ -510,6 +547,7 @@ export function issueScreen(
510547 binding(["t"], "teams"),
511548 ...(mayGoAhead() ? [binding(["g"], "go ahead")] : []),
512549 binding(["w"], "waits for"),
550 binding(["S"], issueRow?.sealed === true ? "unseal" : "seal"),
513551 ...(issueRow?.sealed === true
514552 ? [binding(["p"], "private repo")]
515553 : []),
@@ -789,6 +827,10 @@ export function issueScreen(
789827 waitFor();
790828 return true;
791829 }
830 if (e.key === "S" && mayWrite()) {
831 seal();
832 return true;
833 }
792834 if (e.key === "p" && mayWrite()) {
793835 privateRepo();
794836 return true;
src/web/pages/issues.ts +3 −12
@@ -13,6 +13,7 @@ import {
1313 import type { Issue } from "../../core/issues.ts";
1414 import { canRead, type Repo } from "../../core/repos.ts";
1515 import { privateRepoForSealed } from "../../actions/repos.ts";
16 import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts";
1617 import { BODY_TOO_LONG, TITLE_TOO_LONG } from "../../actions/thread_limits_words.ts";
1718 import { COMMENT_EMPTY, COMMENT_NOT_POSTED } from "../../actions/comment_words.ts";
1819 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
@@ -233,18 +234,8 @@ export async function issueSealSubmit(ctx: Ctx): Promise<Response> {
233234 ctx,
234235 dest,
235236 done.ok
236 ? {
237 message: sealed
238 ? "Sealed: only people who can write here and the crew see it now."
239 : "Unsealed: everyone who can read this repository sees it now.",
240 }
241 : {
242 tone: "danger",
243 message:
244 done.error === "mirrored"
245 ? "A mirror's issues live on its origin, in the open: nothing here can be sealed."
246 : "Only someone who can write here seals or unseals.",
247 },
237 ? { message: sealedWords(sealed) }
238 : { tone: "danger", message: sealRefusedWords(done.error) },
248239 );
249240 }
250241
test/tui-issue-seal.test.ts added +176
@@ -0,0 +1,176 @@
1 /**
2 * The issue screen's `S` (#7996): a writer seals or unseals a report after a
3 * confirm, and the toast says what the issue page's Seal form says.
4 */
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 { openIssue } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { SEAL_ISSUE_SAYS } from "../src/actions/seal_issue_words.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { getIssue, type Issue } from "../src/core/issues.ts";
15 import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createUser, type User } from "../src/core/users.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { issueScreen } from "../src/tui/screens/issue.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
25 quietLogs();
26
27 describe("the terminal's issue screen seals with S", () => {
28 let dataDir = "";
29 let app: App;
30 let alice: User;
31 let carol: User;
32 let repo: Repo;
33
34 const open = async (user: User, issue: Issue, on: Repo = getRepoById(app.db, repo.id)!) => {
35 const { app: tui, session } = createForgeApp(
36 app,
37 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
38 { terminal: headlessTerminal(140, 40), exitHooks: false },
39 );
40 tui.start();
41 await settleApp(tui);
42 tui.push(issueScreen(session, tui.ctx, local(session, on), issue.number));
43 await settleApp(tui);
44 const key = async (k: string) => {
45 tui.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
46 await settleApp(tui);
47 };
48 return { tui, key, text: () => tui.screen.toText() };
49 };
50
51 const sealedNow = (issue: Issue, on: Repo = repo): boolean =>
52 getIssue(app.db, on.id, issue.number)!.sealed;
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-issue-seal-"));
56 app = createApp({ dataDir, port: 0 });
57 for (const username of ["alice", "carol"]) {
58 const made = await createUser(app.db, {
59 origin: "seed",
60 trusted: true,
61 username,
62 email: `${username}@forge.local`,
63 password: "a-good-passphrase",
64 adminIfFirst: username === "alice",
65 });
66 if (!made.ok) throw new Error(made.error);
67 if (username === "alice") alice = made.value;
68 else carol = made.value;
69 }
70 const r = await createRepo(app, alice, { name: "garden", visibility: "public" });
71 if (!r.ok) throw new Error(String(r.error));
72 repo = r.value;
73 });
74
75 afterAll(async () => {
76 app.close();
77 await rm(dataDir, { recursive: true, force: true });
78 });
79
80 const newIssue = (title: string, on: Repo = repo): Issue => {
81 const o = openIssue(app, alice, on, { title, body: "A hole." });
82 if (!o.ok) throw new Error(o.error);
83 return o.value;
84 };
85
86 test("a writer seals with S then confirm, and S again unseals", async () => {
87 const issue = newIssue("A leak");
88 const t = await open(alice, issue);
89 try {
90 expect(t.text()).toMatch(/\bS seal\b/);
91 await t.key("S");
92 expect(t.text()).toContain(
93 `Seal #${issue.number}? Only people who can write here and the crew will see it.`,
94 );
95 await t.key("left");
96 await t.key("enter");
97 expect(sealedNow(issue)).toBe(true);
98 expect(t.text()).toContain(SEAL_ISSUE_SAYS.sealed);
99 } finally {
100 t.tui.stop();
101 }
102 // The toast covers the key bar; a fresh screen shows the new state.
103 const u = await open(alice, issue);
104 try {
105 expect(u.text()).toMatch(/\bS unseal\b/);
106 await u.key("S");
107 expect(u.text()).toContain(
108 `Unseal #${issue.number}? Everyone who can read alice/garden sees it and its fix.`,
109 );
110 await u.key("left");
111 await u.key("enter");
112 expect(sealedNow(issue)).toBe(false);
113 expect(u.text()).toContain(SEAL_ISSUE_SAYS.unsealed);
114 } finally {
115 u.tui.stop();
116 }
117 });
118
119 test("Cancel is chosen first and leaves the issue as it was", async () => {
120 const issue = newIssue("A draught");
121 const t = await open(alice, issue);
122 try {
123 await t.key("S");
124 await t.key("enter");
125 expect(sealedNow(issue)).toBe(false);
126 expect(t.text()).not.toContain(`Seal #${issue.number}?`);
127 } finally {
128 t.tui.stop();
129 }
130 });
131
132 test("a reader is not offered S, and pressing it asks nothing", async () => {
133 const issue = newIssue("A crack");
134 const t = await open(carol, issue);
135 try {
136 expect(t.text()).toContain("A crack");
137 expect(t.text()).not.toMatch(/\bS (un)?seal\b/);
138 await t.key("S");
139 expect(t.text()).not.toContain(`Seal #${issue.number}?`);
140 expect(sealedNow(issue)).toBe(false);
141 } finally {
142 t.tui.stop();
143 }
144 });
145
146 test("sealing on a mirror toasts the mirror refusal", async () => {
147 const made = await createRepo(app, alice, { name: "widgets", visibility: "public" });
148 if (!made.ok) throw new Error(String(made.error));
149 const issue = newIssue("A cog", made.value);
150 const o = createOrigin(app.db, dataDir, {
151 userId: alice.id,
152 kind: "forgejo",
153 apiUrl: "https://codeberg.org/api/v1",
154 webUrl: "https://codeberg.org",
155 login: "alice",
156 token: "t0ken",
157 });
158 if (!o.ok) throw new Error(o.error);
159 linkRepoToOrigin(app.db, made.value.id, {
160 originId: o.value.id,
161 fullName: "octo/widgets",
162 remoteId: "1",
163 htmlUrl: "https://codeberg.org/octo/widgets",
164 });
165 const t = await open(alice, issue, getRepoById(app.db, made.value.id)!);
166 try {
167 await t.key("S");
168 await t.key("left");
169 await t.key("enter");
170 expect(sealedNow(issue, made.value)).toBe(false);
171 expect(t.text()).toContain(SEAL_ISSUE_SAYS.mirrored);
172 } finally {
173 t.tui.stop();
174 }
175 });
176 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs