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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7996-a-writer-seals-or-unseals-an-issue-from → main 4 commits fork 2 threads · 2 unresolved
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 | ||
| 92 | 92 | |
| 93 | 93 | 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`. |
| 94 | 94 | |
| 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 | ||
| 95 | 97 | 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). |
| 96 | 98 | 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. |
| 97 | 99 |
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 { | ||
| 22 | 22 | mayEditIssue, |
| 23 | 23 | setIssueLabelsThrough, |
| 24 | 24 | setIssueStateThrough, |
| 25 | setIssueSealed, | |
| 25 | 26 | TAKEN_UP, |
| 26 | 27 | } from "../../actions/issues.ts"; |
| 28 | import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts"; | |
| 27 | 29 | import { |
| 28 | 30 | dependencyWords, |
| 29 | 31 | linkIssue, |
| @@ -419,6 +421,41 @@ export function issueScreen( | ||
| 419 | 421 | }); |
| 420 | 422 | }; |
| 421 | 423 | |
| 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.`, | |
| 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 | ||
| 422 | 459 | /** |
| 423 | 460 | * `w` — a number to wait for. A new one links it; one it already waits |
| 424 | 461 | * for asks first, then stops the wait. Refusals in `dependencyWords`. |
| @@ -510,6 +547,7 @@ export function issueScreen( | ||
| 510 | 547 | binding(["t"], "teams"), |
| 511 | 548 | ...(mayGoAhead() ? [binding(["g"], "go ahead")] : []), |
| 512 | 549 | binding(["w"], "waits for"), |
| 550 | binding(["S"], issueRow?.sealed === true ? "unseal" : "seal"), | |
| 513 | 551 | ...(issueRow?.sealed === true |
| 514 | 552 | ? [binding(["p"], "private repo")] |
| 515 | 553 | : []), |
| @@ -789,6 +827,10 @@ export function issueScreen( | ||
| 789 | 827 | waitFor(); |
| 790 | 828 | return true; |
| 791 | 829 | } |
| 830 | if (e.key === "S" && mayWrite()) { | |
| 831 | seal(); | |
| 832 | return true; | |
| 833 | } | |
| 792 | 834 | if (e.key === "p" && mayWrite()) { |
| 793 | 835 | privateRepo(); |
| 794 | 836 | return true; |
src/web/pages/issues.ts +3 −12
| @@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | import type { Issue } from "../../core/issues.ts"; |
| 14 | 14 | import { canRead, type Repo } from "../../core/repos.ts"; |
| 15 | 15 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 16 | import { sealedWords, sealRefusedWords } from "../../actions/seal_issue_words.ts"; | |
| 16 | 17 | import { BODY_TOO_LONG, TITLE_TOO_LONG } from "../../actions/thread_limits_words.ts"; |
| 17 | 18 | import { COMMENT_EMPTY, COMMENT_NOT_POSTED } from "../../actions/comment_words.ts"; |
| 18 | 19 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -233,18 +234,8 @@ export async function issueSealSubmit(ctx: Ctx): Promise<Response> { | ||
| 233 | 234 | ctx, |
| 234 | 235 | dest, |
| 235 | 236 | 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) }, | |
| 248 | 239 | ); |
| 249 | 240 | } |
| 250 | 241 |
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 | }); |
new above
offline
Document the danger variant on Unseal (it exposes the report), and add the README line for
S(see the review body).