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

Merged opened this

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

Merged

mason/forge:mason/7235-the-terminal-s-issue-screen-edits-an-iss → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/7235-the-terminal-s-issue-screen-edits-an-iss was merged into main as ad8fa94.

Review

Sign in to review this change.
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
5858
5959 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`.
6060
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
6163 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`).
6264
6365 **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 @@
1616
1717 import {
1818 addCommentThrough,
19 editIssue,
20 mayEditIssue,
1921 setIssueLabelsThrough,
2022 setIssueStateThrough,
23 TAKEN_UP,
2124 } from "../../actions/issues.ts";
2225 import {
2326 dependencyWords,
@@ -33,6 +36,7 @@ import { privateRepoForSealed } from "../../actions/repos.ts";
3336 import { dependenciesOf } from "../../core/dependencies.ts";
3437 import { alreadyDone } from "../../core/done_on_main.ts";
3538 import { hasPicture } from "../../core/note_pictures.ts";
39 import { isMirror } from "../../origins/write-through.ts";
3640 import { getIssue, type Issue, type Label } from "../../core/issues.ts";
3741 import type { Repo } from "../../core/repos.ts";
3842 import type { IssueDetail, RepoSource } from "../../view/source.ts";
@@ -81,6 +85,27 @@ const PRIVATE_SAY: Record<string, string> = {
8185 unverified: "Verify your email first: the private repository is for verified writers.",
8286 };
8387
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
84109 /** What an issue waits for and holds up, by number, ascending. */
85110 export interface Waits {
86111 readonly blockedBy: readonly number[];
@@ -137,6 +162,8 @@ export function issueScreen(
137162 let focus: "thread" | "crew" = "thread";
138163 /** The private repository made for this sealed report, shown until we leave. */
139164 let privateNote: string | undefined;
165 /** The text `e` opened the editor with; set while the composer edits, not comments. */
166 let editing: string | undefined;
140167 const mayWrite = (): boolean => src.capabilities.write && repo !== undefined;
141168 scroll.top = keep.top ?? 0;
142169
@@ -246,6 +273,43 @@ export function issueScreen(
246273 });
247274 };
248275
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
outdated

Unrequested confirm screen: beyond the issue. Drop it or justify it; esc on a changed draft should match how the comment composer cancels.

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
249313 const setState = (state: "open" | "closed"): void => {
250314 const at = row();
251315 if (at?.issue === undefined) return;
@@ -361,6 +425,7 @@ export function issueScreen(
361425 ...(mayWrite()
362426 ? [
363427 binding(["c"], "comment"),
428 ...(mayEdit() ? [binding(["e"], "edit")] : []),
364429 binding(["a"], "assign"),
365430 binding(["x"], "close", {
366431 disabled: detail?.issue.state === "closed",
@@ -375,7 +440,10 @@ export function issueScreen(
375440 ? [binding(["p"], "private repo")]
376441 : []),
377442 ]
378 : liveBindings(src)),
443 : [
444 ...(mayEdit() ? [binding(["e"], "edit")] : []),
445 ...liveBindings(src),
446 ]),
379447 binding(["r"], "refresh"),
380448 ];
381449 },
@@ -521,7 +589,10 @@ export function issueScreen(
521589 key(e) {
522590 if (composer.open) {
523591 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();
525596 else if (out === "cancel") composer.close();
526597 return true;
527598 }
@@ -544,6 +615,10 @@ export function issueScreen(
544615 composer.start("Comment");
545616 return true;
546617 }
618 if (e.key === "e" && mayEdit()) {
619 startEdit();
620 return true;
621 }
547622 if (e.key === "x" && mayWrite()) {
548623 setState("closed");
549624 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs