Public: anyone can read this; the crew can change it.
The terminal's issue screen edits an issue's title and body with `e`, as the page does #7266
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7235-the-terminal-s-issue-screen-edits-an-iss → main 5 commits fork 2 threads · 2 unresolved
3 files
changed +309
−2
README.md +2
| @@ -58,6 +58,8 @@ A pull the merge queue holds as red alone (red on its own merge at its current h | ||
| 58 | 58 | |
| 59 | 59 | 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`. |
| 60 | 60 | |
| 61 | 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. | |
| 62 | ||
| 61 | 63 | Reports from outside (sent with no account) are decided in the terminal too: open the repository as a person who writes it, press `:` and pick "Reports (N)"; beside each title is when it was sent, the page it came from and its words. `a` accepts the one under the cursor as an issue and says its number, `d` dismisses it after a confirm, and one someone else already decided says "this report was already decided". The row shows only while reports wait, and only to a person — not an agent — who can write the repository (`src/tui/screens/reports.ts`). |
| 62 | 64 | |
| 63 | 65 | **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet. |
src/tui/screens/issue.ts +77 −2
| @@ -16,8 +16,11 @@ | ||
| 16 | 16 | |
| 17 | 17 | import { |
| 18 | 18 | addCommentThrough, |
| 19 | editIssue, | |
| 20 | mayEditIssue, | |
| 19 | 21 | setIssueLabelsThrough, |
| 20 | 22 | setIssueStateThrough, |
| 23 | TAKEN_UP, | |
| 21 | 24 | } from "../../actions/issues.ts"; |
| 22 | 25 | import { |
| 23 | 26 | dependencyWords, |
| @@ -33,6 +36,7 @@ import { privateRepoForSealed } from "../../actions/repos.ts"; | ||
| 33 | 36 | import { dependenciesOf } from "../../core/dependencies.ts"; |
| 34 | 37 | import { alreadyDone } from "../../core/done_on_main.ts"; |
| 35 | 38 | import { hasPicture } from "../../core/note_pictures.ts"; |
| 39 | import { isMirror } from "../../origins/write-through.ts"; | |
| 36 | 40 | import { getIssue, type Issue, type Label } from "../../core/issues.ts"; |
| 37 | 41 | import type { Repo } from "../../core/repos.ts"; |
| 38 | 42 | import type { IssueDetail, RepoSource } from "../../view/source.ts"; |
| @@ -81,6 +85,27 @@ const PRIVATE_SAY: Record<string, string> = { | ||
| 81 | 85 | unverified: "Verify your email first: the private repository is for verified writers.", |
| 82 | 86 | }; |
| 83 | 87 | |
| 88 | /** The refusals of an edit, in the action's and the page's words. */ | |
| 89 | const EDIT_SAY: Record<string, string> = { | |
| 90 | forbidden: "only the author, or a person who writes here, edits this", | |
| 91 | taken_up: TAKEN_UP, | |
| 92 | body_too_long: "that description is too long", | |
| 93 | }; | |
| 94 | ||
| 95 | /** | |
| 96 | * An issue's words as the editor holds them: the title on the first line, | |
| 97 | * a blank line, then the body — the shape of a commit message. | |
| 98 | */ | |
| 99 | export function editText(title: string, body: string): string { | |
| 100 | return body === "" ? title : `${title}\n\n${body}`; | |
| 101 | } | |
| 102 | ||
| 103 | /** The editor's text back into a title and a body. */ | |
| 104 | export function readEditText(text: string): { title: string; body: string } { | |
| 105 | const [first = "", ...rest] = text.replace(/\r\n?/g, "\n").split("\n"); | |
| 106 | return { title: first.trim(), body: rest.join("\n").replace(/^\n+/, "").trimEnd() }; | |
| 107 | } | |
| 108 | ||
| 84 | 109 | /** What an issue waits for and holds up, by number, ascending. */ |
| 85 | 110 | export interface Waits { |
| 86 | 111 | readonly blockedBy: readonly number[]; |
| @@ -137,6 +162,8 @@ export function issueScreen( | ||
| 137 | 162 | let focus: "thread" | "crew" = "thread"; |
| 138 | 163 | /** The private repository made for this sealed report, shown until we leave. */ |
| 139 | 164 | let privateNote: string | undefined; |
| 165 | /** The text `e` opened the editor with; set while the composer edits, not comments. */ | |
| 166 | let editing: string | undefined; | |
| 140 | 167 | const mayWrite = (): boolean => src.capabilities.write && repo !== undefined; |
| 141 | 168 | scroll.top = keep.top ?? 0; |
| 142 | 169 | |
| @@ -246,6 +273,43 @@ export function issueScreen( | ||
| 246 | 273 | }); |
| 247 | 274 | }; |
| 248 | 275 | |
| 276 | /** Whether `editIssue` would take this person's words here: its own two tests. */ | |
| 277 | const mayEdit = (): boolean => | |
| 278 | repo !== undefined && | |
| 279 | !isMirror(f.app, repo) && | |
| 280 | issueRow !== undefined && | |
| 281 | mayEditIssue(f.app, repo, issueRow, f.user).ok; | |
| 282 | ||
| 283 | /** `e` — the title and the body in the long-form editor. */ | |
| 284 | const startEdit = (): void => { | |
| 285 | if (issueRow === undefined) return; | |
| 286 | editing = editText(issueRow.title, issueRow.body); | |
| 287 | composer.start(`Edit #${number} — first line is the title`, editing); | |
| 288 | }; | |
| 289 | ||
| 290 | const stopEdit = (): void => { | |
| 291 | editing = undefined; | |
| 292 | composer.close(); | |
| 293 | }; | |
| 294 | ||
| 295 | /** `ctrl+d` in the editor. A refusal keeps the draft open, as typed. */ | |
| 296 | const saveEdit = (): void => { | |
| 297 | const at = row(); | |
| 298 | if (at?.issue === undefined) return; | |
| 299 | if (composer.value === editing) { | |
| 300 | stopEdit(); | |
| 301 | return; | |
| 302 | } | |
| 303 | const edited = editIssue(f.app, f.user, at.repo, at.issue, readEditText(composer.value)); | |
| 304 | if (!edited.ok) { | |
| 305 | ctx.toast(EDIT_SAY[edited.error] ?? errorText(edited.error), "danger"); | |
| 306 | return; | |
| 307 | } | |
| 308 | stopEdit(); | |
| 309 | runAsync(ctx, reload); | |
| 310 | ctx.toast("saved", "success"); | |
| 311 | }; | |
| 312 | ||
| 249 | 313 | const setState = (state: "open" | "closed"): void => { |
| 250 | 314 | const at = row(); |
| 251 | 315 | if (at?.issue === undefined) return; |
| @@ -361,6 +425,7 @@ export function issueScreen( | ||
| 361 | 425 | ...(mayWrite() |
| 362 | 426 | ? [ |
| 363 | 427 | binding(["c"], "comment"), |
| 428 | ...(mayEdit() ? [binding(["e"], "edit")] : []), | |
| 364 | 429 | binding(["a"], "assign"), |
| 365 | 430 | binding(["x"], "close", { |
| 366 | 431 | disabled: detail?.issue.state === "closed", |
| @@ -375,7 +440,10 @@ export function issueScreen( | ||
| 375 | 440 | ? [binding(["p"], "private repo")] |
| 376 | 441 | : []), |
| 377 | 442 | ] |
| 378 | : liveBindings(src)), | |
| 443 | : [ | |
| 444 | ...(mayEdit() ? [binding(["e"], "edit")] : []), | |
| 445 | ...liveBindings(src), | |
| 446 | ]), | |
| 379 | 447 | binding(["r"], "refresh"), |
| 380 | 448 | ]; |
| 381 | 449 | }, |
| @@ -521,7 +589,10 @@ export function issueScreen( | ||
| 521 | 589 | key(e) { |
| 522 | 590 | if (composer.open) { |
| 523 | 591 | const out = composer.key(e); |
| 524 | if (out === "submit") submit(); | |
| 592 | if (editing !== undefined) { | |
| 593 | if (out === "submit") saveEdit(); | |
| 594 | else if (out === "cancel") stopEdit(); | |
| 595 | } else if (out === "submit") submit(); | |
| 525 | 596 | else if (out === "cancel") composer.close(); |
| 526 | 597 | return true; |
| 527 | 598 | } |
| @@ -544,6 +615,10 @@ export function issueScreen( | ||
| 544 | 615 | composer.start("Comment"); |
| 545 | 616 | return true; |
| 546 | 617 | } |
| 618 | if (e.key === "e" && mayEdit()) { | |
| 619 | startEdit(); | |
| 620 | return true; | |
| 621 | } | |
| 547 | 622 | if (e.key === "x" && mayWrite()) { |
| 548 | 623 | setState("closed"); |
| 549 | 624 | return true; |
test/tui-issue-edit.test.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | /** | |
| 2 | * The terminal's issue screen edits an issue's title and body with `e` | |
| 3 | * (#7235): the comment composer opens on the title, a blank line and the | |
| 4 | * body; `ctrl+d` saves through `editIssue`, the page then shows it and | |
| 5 | * its live viewers hear `issue.edited`; `esc` discards as the comment | |
| 6 | * composer cancels; a refusal reads in the action's words and keeps the | |
| 7 | * draft; and a reader `editIssue` refuses, or a mirror, has no `e`. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { assignIssue, openIssue, TAKEN_UP } from "../src/actions/issues.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { getIssue, type Issue } from "../src/core/issues.ts"; | |
| 18 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import { editText, issueScreen, readEditText } from "../src/tui/screens/issue.ts"; | |
| 24 | import { local } from "../src/tui/screens/where.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | ||
| 31 | describe("the terminal's issue screen edits with e", () => { | |
| 32 | let dataDir = ""; | |
| 33 | let app: App; | |
| 34 | let servers: Servers; | |
| 35 | let alice: User; | |
| 36 | let bob: User; | |
| 37 | let repo: Repo; | |
| 38 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 39 | ||
| 40 | const newIssue = (by: User, title: string, body: string): Issue => { | |
| 41 | const o = openIssue(app, by, repo, { title, body }); | |
| 42 | if (!o.ok) throw new Error(o.error); | |
| 43 | return o.value; | |
| 44 | }; | |
| 45 | ||
| 46 | /** A terminal signed in as `user`, on the issue's screen. */ | |
| 47 | const open = async (user: User, issue: Issue, on: Repo = fresh()) => { | |
| 48 | const terminal = headlessTerminal(140, 40); | |
| 49 | const { app: tui, session } = createForgeApp( | |
| 50 | app, | |
| 51 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 52 | { terminal, exitHooks: false }, | |
| 53 | ); | |
| 54 | tui.start(); | |
| 55 | await settleApp(tui); | |
| 56 | tui.push(issueScreen(session, tui.ctx, local(session, on), issue.number)); | |
| 57 | await settleApp(tui); | |
| 58 | const key = async (k: string, ctrl = false) => { | |
| 59 | tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 60 | await settleApp(tui); | |
| 61 | }; | |
| 62 | const paste = async (text: string) => { | |
| 63 | tui.dispatch({ type: "paste", text }); | |
| 64 | await settleApp(tui); | |
| 65 | }; | |
| 66 | return { tui, key, paste, text: () => tui.screen.toText() }; | |
| 67 | }; | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-issue-edit-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | servers = await serve(app); | |
| 73 | for (const username of ["alice", "bob"]) { | |
| 74 | const made = await createUser(app.db, { | |
| 75 | origin: "seed", | |
| 76 | trusted: true, | |
| 77 | username, | |
| 78 | email: `${username}@forge.local`, | |
| 79 | password: "a-good-passphrase", | |
| 80 | adminIfFirst: username === "alice", | |
| 81 | }); | |
| 82 | if (!made.ok) throw new Error(made.error); | |
| 83 | if (username === "alice") alice = made.value; | |
| 84 | else bob = made.value; | |
| 85 | } | |
| 86 | const r = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 87 | if (!r.ok) throw new Error(String(r.error)); | |
| 88 | repo = r.value; | |
| 89 | const seeded = await commitToBranch(app, alice, repo, { | |
| 90 | message: "seed", | |
| 91 | files: [{ path: "README.md", content: "# garden\n" }], | |
| 92 | }); | |
| 93 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 94 | }, 30_000); | |
| 95 | ||
| 96 | afterAll(async () => { | |
| 97 | await servers.stop(); | |
| 98 | app.close(); | |
| 99 | await rm(dataDir, { recursive: true, force: true }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("the editor's text is the title, a blank line, then the body, and reads back", () => { | |
| 103 | expect(editText("A chart", "Draw it.")).toBe("A chart\n\nDraw it."); | |
| 104 | expect(readEditText("A chart\n\nDraw it.\n")).toEqual({ title: "A chart", body: "Draw it." }); | |
| 105 | expect(readEditText(editText("Only a title", ""))).toEqual({ title: "Only a title", body: "" }); | |
| 106 | }); | |
| 107 | ||
| 108 | test("e opens the title and body, ctrl+d saves, and the page shows the new title", async () => { | |
| 109 | const issue = newIssue(alice, "A chart", "Draw the rainfall."); | |
| 110 | const t = await open(alice, issue); | |
| 111 | const heard: unknown[] = []; | |
| 112 | const off = app.bus.subscribe((event) => { | |
| 113 | if (event.kind === "issue.edited" && event.repoId === repo.id) | |
| 114 | heard.push({ actorId: event.actorId, ...event.payload }); | |
| 115 | }); | |
| 116 | try { | |
| 117 | expect(t.text()).toMatch(/\be edit/); | |
| 118 | await t.key("e"); | |
| 119 | expect(t.text()).toContain(`Edit #${issue.number}`); | |
| 120 | expect(t.text()).toContain("Draw the rainfall."); | |
| 121 | await t.paste("Monthly "); | |
| 122 | await t.key("d", true); | |
| 123 | const saved = getIssue(app.db, repo.id, issue.number)!; | |
| 124 | expect(saved.title).toBe("Monthly A chart"); | |
| 125 | expect(saved.body).toBe("Draw the rainfall."); | |
| 126 | // What a web viewer's live stream is fed: the edit, by whom, to what. | |
| 127 | expect(heard).toEqual([{ actorId: alice.id, number: issue.number, changed: ["title"] }]); | |
| 128 | expect(t.text()).toContain("Monthly A chart"); | |
| 129 | expect(t.text()).not.toContain(`Edit #${issue.number}`); | |
| 130 | } finally { | |
| 131 | off(); | |
| 132 | t.tui.stop(); | |
| 133 | } | |
| 134 | const page = await (await fetch(`${servers.url}/alice/garden/issues/${issue.number}`)).text(); | |
| 135 | expect(page).toContain("Monthly A chart"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("esc on an untouched draft discards without asking, and nothing changed", async () => { | |
| 139 | const issue = newIssue(alice, "A gate", "Hang it."); | |
| 140 | const t = await open(alice, issue); | |
| 141 | try { | |
| 142 | await t.key("e"); | |
| 143 | await t.key("esc"); | |
| 144 | expect(t.text()).not.toContain(`Edit #${issue.number}`); | |
| 145 | const kept = getIssue(app.db, repo.id, issue.number)!; | |
| 146 | expect(kept.title).toBe("A gate"); | |
| 147 | expect(kept.body).toBe("Hang it."); | |
| 148 | } finally { | |
| 149 | t.tui.stop(); | |
| 150 | } | |
| 151 | }); | |
| 152 | ||
| 153 | test("esc on a changed draft discards it as a comment's is, and nothing changed", async () => { | |
| 154 | const issue = newIssue(alice, "A fence", "Paint it."); | |
| 155 | const t = await open(alice, issue); | |
| 156 | try { | |
| 157 | await t.key("e"); | |
| 158 | await t.paste("Green "); | |
| 159 | await t.key("esc"); | |
| 160 | expect(t.text()).not.toContain(`Edit #${issue.number}`); | |
| 161 | expect(getIssue(app.db, repo.id, issue.number)!.title).toBe("A fence"); | |
| 162 | } finally { | |
| 163 | t.tui.stop(); | |
| 164 | } | |
| 165 | }); | |
| 166 | ||
| 167 | test("a refusal reads in the action's words and keeps the draft", async () => { | |
| 168 | const issue = newIssue(bob, "A shed", "Build it."); | |
| 169 | const t = await open(bob, issue); | |
| 170 | try { | |
| 171 | expect(t.text()).toMatch(/\be edit/); | |
| 172 | await t.key("e"); | |
| 173 | await t.paste("Tall "); | |
| 174 | // A writer adopts it while bob is typing: his words are now the crew's. | |
| 175 | const assigned = assignIssue(app, alice, fresh(), getIssue(app.db, repo.id, issue.number)!, alice); | |
| 176 | expect(assigned.ok).toBe(true); | |
| 177 | await t.key("d", true); | |
| 178 | expect(t.text()).toContain(TAKEN_UP); | |
| 179 | expect(t.text()).toContain("Tall A shed"); | |
| 180 | expect(t.text()).toContain(`Edit #${issue.number}`); | |
| 181 | expect(getIssue(app.db, repo.id, issue.number)!.title).toBe("A shed"); | |
| 182 | } finally { | |
| 183 | t.tui.stop(); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 187 | test("a reader who did not write it has no e, and pressing it changes nothing", async () => { | |
| 188 | const issue = newIssue(alice, "A pond", "Dig it."); | |
| 189 | const t = await open(bob, issue); | |
| 190 | try { | |
| 191 | expect(t.text()).not.toMatch(/\be edit/); | |
| 192 | await t.key("e"); | |
| 193 | expect(t.text()).not.toContain(`Edit #${issue.number}`); | |
| 194 | expect(getIssue(app.db, repo.id, issue.number)!.title).toBe("A pond"); | |
| 195 | } finally { | |
| 196 | t.tui.stop(); | |
| 197 | } | |
| 198 | }); | |
| 199 | ||
| 200 | test("a mirror has no e, even for its writer, since editIssue refuses there", async () => { | |
| 201 | const made = await createRepo(app, alice, { name: "widgets", visibility: "public" }); | |
| 202 | if (!made.ok) throw new Error(String(made.error)); | |
| 203 | const opened = openIssue(app, alice, made.value, { title: "A cog", body: "Turn it." }); | |
| 204 | if (!opened.ok) throw new Error(opened.error); | |
| 205 | const o = createOrigin(app.db, dataDir, { | |
| 206 | userId: alice.id, | |
| 207 | kind: "forgejo", | |
| 208 | apiUrl: "https://codeberg.org/api/v1", | |
| 209 | webUrl: "https://codeberg.org", | |
| 210 | login: "alice", | |
| 211 | token: "t0ken", | |
| 212 | }); | |
| 213 | if (!o.ok) throw new Error(o.error); | |
| 214 | linkRepoToOrigin(app.db, made.value.id, { | |
| 215 | originId: o.value.id, | |
| 216 | fullName: "octo/widgets", | |
| 217 | remoteId: "1", | |
| 218 | htmlUrl: "https://codeberg.org/octo/widgets", | |
| 219 | }); | |
| 220 | const t = await open(alice, opened.value, getRepoById(app.db, made.value.id)!); | |
| 221 | try { | |
| 222 | expect(t.text()).toContain("A cog"); | |
| 223 | expect(t.text()).not.toMatch(/\be edit/); | |
| 224 | await t.key("e"); | |
| 225 | expect(t.text()).not.toContain(`Edit #${opened.value.number}`); | |
| 226 | } finally { | |
| 227 | t.tui.stop(); | |
| 228 | } | |
| 229 | }); | |
| 230 | }); |
new above
offline
Unrequested confirm screen: beyond the issue. Drop it or justify it; esc on a changed draft should match how the comment composer cancels.