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

Reply on a feed post comments on the issue or pull it is about, without leaving the feed #8218

Merged opened this

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

Merged

mason/forge:mason/8211-reply-on-a-feed-post-comments-on-the-iss → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/8211-reply-on-a-feed-post-comments-on-the-iss was merged into main as 223c837.

Review

Sign in to review this change.
11 files changed +647 −14
README.md +1 −1
@@ -469,7 +469,7 @@ This page is the front door. Everything else is a file of its own under
469469 - [`docs/STUDIO.md`](docs/STUDIO.md) — the front door in full: the five words and where each is decided, the box, the three lists, a tell, a run and a change each on its own page, and the board.
470470 - [`docs/TOUR.md`](docs/TOUR.md) — every other page and screen, with each of its doors: the terminal over SSH, the home and its figures, review, signing in, the page-view beacon, the board's notes, long threads, who can see a repository, archive, where a repository came from, big directories, search, sharing to the forge, link-preview cards and choosing among variants.
471471 - [`docs/ASKS.md`](docs/ASKS.md) — what waits on you and how to answer it: waiting pulls, mutes, wave proposals, feed chips and the decider.
472 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload.
472 - [`docs/FEED.md`](docs/FEED.md) — the feed: what happened, ranked, each item with why it is there. The home composer keeps your draft across a reload. A post about an issue or pull offers Reply, which comments on it and returns to the feed; the terminal's Feed screen replies with `r`.
473473 - [`docs/PULLS.md`](docs/PULLS.md) — resolving a conflicted pull without a clone.
474474 - [`docs/GIT.md`](docs/GIT.md) — cloning over HTTPS and SSH, and partial clones of big repositories.
475475 - [`docs/CLI.md`](docs/CLI.md) — the `forge` command on your machine: install, log in, and every command with its flags and one example.
docs/FEED.md +8
@@ -79,6 +79,14 @@ region for the live-regions client (etag, 304). A post: who did it, what it
7979 says as a link into the thing, a small line of why, Steer (run
8080 cards, posts to the run's `steer`) or Tell the crew (everything else, posts to
8181 the app's `tell`), Dismiss and Mute app (the interactions API, same-origin).
82 Reply (#8211): a post whose link is an issue or a pull (`/:owner/:name/issues/:n`
83 or `/pulls/:n`) also offers Reply, a plain form posting the comment to that
84 page's own `/comments` handler with `back=feed`, so the same-origin check, body
85 limit and escaping apply; it returns to `/feed` with the flash "Reply posted."
86 (a fixed word, never a URL from the form); an empty or refused reply returns there too, with the reason. Reply is the card's one primary button; Tell drops to plain beside it. Only a signed-in viewer who can read
87 the repository sees it (`src/feed/reply.ts`, the comment action's own rule). In
88 the terminal, `Feed` in the palette lists the same posts and `r` replies through
89 the same `addCommentThrough`.
8290 Keys: `j`/`k`/Enter are the shell's, `x` dismisses the focused card.
8391 The `choose` card comes from `src/feed/choose.ts`, a run card offers Stop
8492 (`src/web/studio/cards.ts`), and `?cursor=` pages past the first 20
src/feed/reply.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * Who may reply on a feed post, and to what. A reply is a comment on the
3 * issue or pull the post links to, so the rule is the comment action's own:
4 * a signed-in viewer who can read the repository (`requireParticipant`).
5 * The page and the terminal both ask here, so they offer Reply to the same
6 * people on the same posts.
7 */
8 import type { Database } from "bun:sqlite";
9 import { canRead, getRepo, type Repo } from "../core/repos.ts";
10 import type { User } from "../core/users.ts";
11 import type { FeedItem } from "./rank.ts";
12
13 const THREAD_LINK = /^\/([^/]+)\/([^/#?]+)\/(issues|pulls)\/(\d+)(?:#[\w-]*)?$/;
14
15 export interface Thread {
16 readonly owner: string;
17 readonly name: string;
18 readonly kind: "issues" | "pulls";
19 readonly number: number;
20 }
21
22 /** The issue or pull a post's link names, or undefined when it names anything else. */
23 export function threadOf(link: string): Thread | undefined {
24 const m = THREAD_LINK.exec(link);
25 return m === null
26 ? undefined
27 : { owner: m[1]!, name: m[2]!, kind: m[3] as Thread["kind"], number: Number(m[4]) };
28 }
29
30 /** The path a reply posts to: the thread's comment form, without any anchor. */
31 export function replyPath(thread: Thread): string {
32 return `/${thread.owner}/${thread.name}/${thread.kind}/${thread.number}/comments`;
33 }
34
35 /** The repository a viewer may reply in, or undefined: signed out, or one they cannot read. */
36 export function replyRepo(db: Database, viewer: User | undefined, app: string): Repo | undefined {
37 if (viewer === undefined) return undefined;
38 const [owner = "", name = ""] = app.split("/");
39 const repo = getRepo(db, owner, name);
40 return repo !== undefined && canRead(db, repo, viewer) ? repo : undefined;
41 }
42
43 /** The thread a viewer may reply on from this post, with its repository; undefined when there is none. */
44 export function replyTarget(
45 db: Database,
46 viewer: User | undefined,
47 item: Pick<FeedItem, "app" | "link">,
48 ): { thread: Thread; repo: Repo } | undefined {
49 const thread = threadOf(item.link);
50 if (thread === undefined || `${thread.owner}/${thread.name}` !== item.app) return undefined;
51 const repo = replyRepo(db, viewer, item.app);
52 return repo === undefined ? undefined : { thread, repo };
53 }
src/tui/screens/entries.ts +2
@@ -19,6 +19,7 @@ import { entry as connections } from "./connections.ts";
1919 import { entry as crewHandover } from "./crewhandover.ts";
2020 import { entry as crewSplits } from "./crewsplits.ts";
2121 import { entry as domains } from "./domains.ts";
22 import { entry as feed } from "./feed.ts";
2223 import { entry as front } from "./front.ts";
2324 import { entry as forges } from "./forges.ts";
2425 import { entry as invites } from "./invites.ts";
@@ -83,6 +84,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
8384 cloud,
8485 connections,
8586 domains,
87 feed,
8688 front,
8789 owner,
8890 forges,
src/tui/screens/feed.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The feed in the terminal — what happened, ranked, as the page and MCP
3 * `feed` give it (`readFeed`), and `r` to reply.
4 *
5 * A reply is a comment on the issue or pull the post links to: the same
6 * `addCommentThrough` the issue and pull screens call, offered on the same
7 * posts to the same people as the page's Reply (`feed/reply.ts`). Twenty
8 * posts at a time; `n` reads the next page from the cursor the last returned.
9 */
10
11 import { addCommentThrough } from "../../actions/issues.ts";
12 import { getIssue } from "../../core/issues.ts";
13 import { parseCursor, readFeed } from "../../feed/feed.ts";
14 import type { FeedItem } from "../../feed/rank.ts";
15 import { replyTarget } from "../../feed/reply.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import type { Session } from "../session.ts";
19 import { fill, len, split } from "../ui/layout.ts";
20 import { emptyState } from "../ui/widgets.ts";
21 import { ListState, ago, drawList, errorText, runAsync, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23 import { Composer } from "./thread.ts";
24
25 const PAGE = 20;
26
27 const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ");
28
29 export function feedScreen(f: Session, ctx: Ctx): Screen {
30 const state = new ListState();
31 const composer = new Composer();
32 const items: FeedItem[] = [];
33 let next: string | null = null;
34
35 const read = (cursor?: string): void => {
36 if (f.user === undefined) return;
37 const parsed = cursor === undefined ? undefined : parseCursor(cursor);
38 const page = readFeed(f.db, f.user, { limit: PAGE, ...(parsed ? { cursor: parsed } : {}) });
39 items.push(...page.items);
40 next = page.next_cursor;
41 ctx.invalidate();
42 };
43 read();
44
45 /** The post under the cursor, when it is about a thread this person may reply on. */
46 const selected = () => {
47 const item = items[state.index];
48 return item === undefined ? undefined : replyTarget(f.db, f.user, item);
49 };
50
51 const submit = (): void => {
52 const at = selected();
53 if (at === undefined) return;
54 const issue = getIssue(f.db, at.repo.id, at.thread.number);
55 if (issue === undefined) return;
56 const body = composer.value;
57 runAsync(ctx, async () => {
58 const added = await addCommentThrough(f.app, f.user, at.repo, issue, body);
59 if (!added.ok) {
60 ctx.toast(errorText(added.error), "danger");
61 return;
62 }
63 composer.close();
64 ctx.toast("reply posted", "success");
65 });
66 };
67
68 return {
69 title: "Feed",
70 get keys() {
71 return [
72 binding(["j", "k"], "move"),
73 ...(selected() === undefined ? [] : [binding(["r"], "reply")]),
74 ...(next === null ? [] : [binding(["n"], "next page")]),
75 binding(["esc", "q"], "back"),
76 ];
77 },
78 render(area, s) {
79 const c = ctx.theme.c;
80 if (items.length === 0) {
81 emptyState(s, area, c, { title: "Nothing in the feed yet", body: "Posts about the apps you can see land here." });
82 return;
83 }
84 const [list, box] = split(area, "v", [fill(1), len(composer.height())]);
85 const rows: ListRow[] = items.map((item) => ({
86 title: oneLine(`${item.actor ?? item.app} ${item.what}`),
87 secondary: item.app,
88 meta: ago(item.at),
89 }));
90 drawList(s, list!, c, state, rows);
91 if (composer.open) composer.render(s, box!, c, ctx);
92 },
93 paste: (text) => composer.paste(text),
94 key(e) {
95 if (composer.open) {
96 const out = composer.key(e);
97 if (out === "submit") submit();
98 else if (out === "cancel") composer.close();
99 return true;
100 }
101 if (e.key === "esc" || e.key === "q") ctx.pop();
102 else if (e.key === "r") {
103 const at = selected();
104 if (at !== undefined) composer.start(`Reply on ${at.thread.owner}/${at.thread.name} #${at.thread.number}`);
105 } else if (e.key === "n") {
106 if (next !== null) read(next);
107 } else state.key(e, items.length);
108 return true;
109 },
110 };
111 }
112
113 /** The feed, as the page's `/feed` has it. */
114 export const entry: PaletteEntry = {
115 id: "feed",
116 title: "Feed",
117 hint: "what happened, ranked; r replies on a post about an issue or pull",
118 offered: (session) => session.user !== undefined,
119 run: (session, ctx) => ctx.push(feedScreen(session, ctx)),
120 };
src/web/pages/issues.ts +16 −5
@@ -264,6 +264,16 @@ export async function issuePrivateSubmit(ctx: Ctx): Promise<Response> {
264264 });
265265 }
266266
267 /**
268 * Where a comment returns to when the form names a fixed word: never a URL
269 * from the form. A refused reply from the feed returns there too, as the
270 * pull's does; one from the board keeps the issue page, where it always went.
271 */
272 const BACK = new Map<string, { to: string; said: (n: number) => string; refusedTo?: string }>([
273 ["board", { to: "/board", said: (n) => `Answered on #${n}. The planner plans again.` }],
274 ["feed", { to: "/feed", said: () => "Reply posted.", refusedTo: "/feed" }],
275 ]);
276
267277 // POST /:owner/:name/issues/:number/comments
268278 export async function issueCommentSubmit(ctx: Ctx): Promise<Response> {
269279 const found = threadContext(ctx, "issue");
@@ -274,11 +284,12 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> {
274284
275285 // A comment posted from the board's "the planner declined" line returns to
276286 // the board. A fixed word, never a URL from the form: no open redirect.
277 const back = field(form, "back") === "board" ? "/board" : undefined;
287 const back = BACK.get(field(form, "back"));
278288 const dest = `${repoBase(found.repo)}/issues/${found.issue.number}`;
289 const refusedTo = back?.refusedTo ?? dest;
279290 const body = field(form, "body");
280291 if (body.trim() === "")
281 return flashTo(ctx, dest, {
292 return flashTo(ctx, refusedTo, {
282293 tone: "danger",
283294 message: COMMENT_EMPTY,
284295 });
@@ -290,14 +301,14 @@ export async function issueCommentSubmit(ctx: Ctx): Promise<Response> {
290301 body,
291302 );
292303 if (!added.ok)
293 return flashTo(ctx, dest, {
304 return flashTo(ctx, refusedTo, {
294305 tone: "danger",
295306 message: originError(added.error) ?? COMMENT_NOT_POSTED,
296307 });
297308 return back === undefined
298309 ? flashTo(ctx, `${dest}#conversation`, { message: "Comment posted." })
299 : flashTo(ctx, back, {
300 message: `Answered on #${found.issue.number}. The planner plans again.`,
310 : flashTo(ctx, back.to, {
311 message: back.said(found.issue.number),
301312 });
302313 }
303314
src/web/pages/pull.ts +7 −3
@@ -1057,9 +1057,11 @@ export async function pullCommentSubmit(ctx: Ctx): Promise<Response> {
10571057 if (form instanceof Response) return form;
10581058
10591059 const dest = `${repoBase(found.repo)}/pulls/${found.pull.number}`;
1060 // A reply from the feed returns there: a fixed word, never a URL from the form.
1061 const fromFeed = field(form, "back") === "feed";
10601062 const bodyText = field(form, "body");
10611063 if (bodyText.trim() === "")
1062 return flashTo(ctx, dest, {
1064 return flashTo(ctx, fromFeed ? "/feed" : dest, {
10631065 tone: "danger",
10641066 message: COMMENT_EMPTY,
10651067 });
@@ -1071,11 +1073,13 @@ export async function pullCommentSubmit(ctx: Ctx): Promise<Response> {
10711073 bodyText,
10721074 );
10731075 if (!added.ok)
1074 return flashTo(ctx, dest, {
1076 return flashTo(ctx, fromFeed ? "/feed" : dest, {
10751077 tone: "danger",
10761078 message: originError(added.error) ?? COMMENT_NOT_POSTED,
10771079 });
1078 return flashTo(ctx, `${dest}#conversation`, { message: "Comment posted." });
1080 return fromFeed
1081 ? flashTo(ctx, "/feed", { message: "Reply posted." })
1082 : flashTo(ctx, `${dest}#conversation`, { message: "Comment posted." });
10791083 }
10801084
10811085 /** Match the diff viewer's per-file anchor id, so the redirect lands there. */
src/web/studio/feed.ts +24 −5
@@ -18,6 +18,7 @@ import { placementsOf, type Placement } from "../../core/placements.ts";
1818 import { recentThoughts, type Thought } from "../../core/thoughts.ts";
1919 import { parseCursor, readFeedPage } from "../../feed/feed.ts";
2020 import type { FeedItem } from "../../feed/rank.ts";
21 import { replyPath, replyRepo, threadOf } from "../../feed/reply.ts";
2122 import { getAppByRepo } from "../../core/apps.ts";
2223 import { getRepo, getRepoById } from "../../core/repos.ts";
2324 import { DEFAULT_PAGE, looksForEnv, lookUrl } from "../../core/looks.ts";
@@ -109,7 +110,12 @@ export function feedCard(
109110 item: FeedItem,
110111 marks: ReadonlyMap<string, Mark> = new Map(),
111112 picture?: FeedPicture,
113 mayReply = false,
112114 ): Html {
115 // The thread must be in the post's own app: that is the repository `mayReply` was asked about.
116 const linked = mayReply ? threadOf(item.link) : undefined;
117 const thread = linked !== undefined && `${linked.owner}/${linked.name}` === item.app ? linked : undefined;
118 const reply = thread !== undefined;
113119 const steer = item.kind === "run" || item.kind === "cost";
114120 const target = steer && RUN_LINK.test(item.link) ? `${item.link}/steer` : null;
115121 const head = html`${avatar(item.actor ?? item.app)}
@@ -130,16 +136,22 @@ export function feedCard(
130136 /></a>`}
131137 ${saysMore(item) ? html`<p class="small muted break">${item.reason}</p>` : null}`;
132138 const foot = disclosure(
133 `${target ? "Steer" : "Tell"} · more`,
134 html`<form method="post" action="${target ?? `/${item.app}/tell`}" class="stack">
139 `${reply ? "Reply or " : ""}${target ? "Steer" : "Tell"} · more`,
140 html`${reply
outdated

data-reply is dead markup; remove. Also the Reply button here is primary and the Tell/Steer button below is primary too: two accents in one card. Make Tell/Steer non-primary when Reply is shown.

141 ? html`<form method="post" action="${thread === undefined ? "" : replyPath(thread)}" class="stack">
142 <input type="hidden" name="back" value="feed" />
143 <textarea class="input" name="body" rows="2" maxlength="20000" required aria-label="Reply on ${item.what}"></textarea>
144 <span class="bar">${btn("Reply", { size: "sm", variant: "primary" })}</span>
145 </form>`
146 : null}<form method="post" action="${target ?? `/${item.app}/tell`}" class="stack">
135147 <textarea class="input" name="text" rows="2" maxlength="2000" required aria-label="${target ? "Steer this run" : "Tell the crew"}"></textarea>
136 <span class="bar">${btn(target ? "Steer" : "Tell", { size: "sm", variant: "primary" })}</span>
148 <span class="bar">${btn(target ? "Steer" : "Tell", reply ? { size: "sm" } : { size: "sm", variant: "primary" })}</span>
137149 </form>
138150 <span class="bar">
139151 <button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="dismissed" title="Dismiss (x)">Dismiss</button>
140152 ${item.kind === "ask" || item.kind === "milestone" || item.kind === "choose" ? null : html`<button type="button" class="btn" data-variant="ghost" data-size="sm" data-feed="muted" title="Mute ${item.app}">Mute app</button>`}
141153 </span>`,
142 { small: true, label: target ? "Steer, dismiss or mute" : "Tell the crew, dismiss or mute" },
154 { small: true, label: target ? "Steer, dismiss or mute" : reply ? "Reply, tell the crew, dismiss or mute" : "Tell the crew, dismiss or mute" },
143155 );
144156 return card(body, {
145157 head,
@@ -359,10 +371,17 @@ function drawRegion(user: User, ctx: Ctx, read: ReturnType<typeof readRegion>):
359371 // One divider per change of day between neighbours, in the order the feed already has.
360372 const posts: Html[] = [];
361373 let before = lastDayOnPage;
374 // Whether this viewer may reply in a repository, asked once per app on the page.
375 const mayReply = new Map<string, boolean>();
376 const mayReplyOn = (app: string): boolean => {
377 let may = mayReply.get(app);
378 if (may === undefined) mayReply.set(app, (may = replyRepo(ctx.app.db, user, app) !== undefined));
379 return may;
380 };
362381 for (const item of items) {
363382 const [label] = dividers([item.at], now, before);
364383 if (label !== undefined) posts.push(html`<p class="muted small" role="separator" aria-label="${label}" data-day-divider="${label}">${label}</p>`);
365 posts.push(feedCard(item, marks, pictures.get(item.id)));
384 posts.push(feedCard(item, marks, pictures.get(item.id), mayReplyOn(item.app)));
366385 before = item.at;
367386 }
368387 const liveQuery = [
test/feed-reply.test.ts added +263
@@ -0,0 +1,263 @@
1 /**
2 * A reply on a feed post is a comment on the issue or pull it is about: the
3 * card offers it, the same handler takes it, and the person lands back on the
4 * home with a flash. A reader who cannot see the repository is refused.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { openIssue } from "../src/actions/issues.ts";
11 import { openPull } from "../src/actions/pulls.ts";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { feedCard } from "../src/web/studio/feed.ts";
15 import type { FeedItem } from "../src/feed/rank.ts";
16 import { replyRepo } from "../src/feed/reply.ts";
17 import { listComments } from "../src/core/issues.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { createUser } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { promote } from "./helpers/members.ts";
23
24 quietLogs();
25
26 let root = "";
27 let app: App;
28 let servers: Servers;
29 let aliceCookie = "";
30 let bobCookie = "";
31 let publicIssue = 0;
32 let secretIssue = 0;
33 let publicId = "";
34 let secretId = "";
35 let pullNumber = 0;
36 let pullId = "";
37
38 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
39 if (!r.ok) throw new Error(JSON.stringify(r.error));
40 return r.value;
41 };
42
43 beforeAll(async () => {
44 root = mkdtempSync(join(tmpdir(), "forge-feed-reply-"));
45 app = createApp({ dataDir: root, port: 0, disk: () => ({ freeBytes: 95, totalBytes: 100 }) });
46 servers = await serve(app);
47 const person = async (username: string, first: boolean) =>
48 must(
49 await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 allowReserved: true,
53 username,
54 email: `${username}@forge.local`,
55 password: "a-good-passphrase",
56 adminIfFirst: first,
57 }),
58 );
59 await person("root", true);
60 const alice = await person("alice", false);
61 const bob = await person("bob", false);
62 promote(app.db, alice, bob);
63 aliceCookie = `forge_session=${createSession(app.db, alice.id).token}`;
64 bobCookie = `forge_session=${createSession(app.db, bob.id).token}`;
65 for (const [name, visibility] of [["open", "public"], ["secret", "private"]] as const) {
66 const repo = must(await createRepo(app, alice, { name, visibility }));
67 must(
68 await commitToBranch(app, alice, repo, {
69 message: "seed",
70 files: [{ path: "README.md", content: `# ${name}\n` }],
71 }),
72 );
73 const issue = must(openIssue(app, alice, repo, { title: `Question for ${name}`, body: "why?" }));
74 if (name === "open") {
75 must(
76 await commitToBranch(app, alice, repo, {
77 branch: "idea",
78 base: "main",
79 message: "an idea",
80 files: [{ path: "idea.md", content: "idea\n" }],
81 }),
82 );
83 const pull = must(
84 await openPull(app, alice, repo, { title: "An idea", body: "", baseBranch: "main", headBranch: "idea" }),
85 );
86 pullNumber = pull.number;
87 pullId = pull.id;
88 publicIssue = issue.number;
89 publicId = issue.id;
90 } else {
91 secretIssue = issue.number;
92 secretId = issue.id;
93 }
94 }
95 }, 30_000);
96
97 afterAll(async () => {
98 await servers.stop();
99 app.close();
100 rmSync(root, { recursive: true, force: true });
101 });
102
103 const post = (path: string, cookie: string, fields: Record<string, string>) =>
104 fetch(`${servers.url}${path}`, {
105 method: "POST",
106 redirect: "manual",
107 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
108 body: new URLSearchParams(fields),
109 });
110
111 test("a card about an issue offers Reply, as a plain form", async () => {
112 const res = await fetch(`${servers.url}/feed`, { headers: { cookie: aliceCookie } });
113 const page = await res.text();
114 expect(page).toContain(`action="/alice/open/issues/${publicIssue}/comments"`);
115 expect(page).toContain('name="back" value="feed"');
116 });
117
118 test("a reply posts the comment and returns to the feed with a flash", async () => {
119 const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, {
120 body: "On it.",
121 back: "feed",
122 });
123 expect(res.status).toBe(303);
124 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed");
125 expect(listComments(app.db, publicId).map((c) => c.body)).toContain("On it.");
126 });
127
128 const flashOf = (res: Response): { message?: string; tone?: string } => {
129 const cookie = res.headers.getSetCookie().find((c) => c.startsWith("forge_flash=")) ?? "";
130 return JSON.parse(decodeURIComponent(cookie.slice("forge_flash=".length).split(";")[0]!));
131 };
132
133 test("a reader of a repository they cannot see is refused", async () => {
134 const res = await post(`/alice/secret/issues/${secretIssue}/comments`, bobCookie, {
135 body: "Let me in.",
136 back: "feed",
137 });
138 expect(res.status).toBe(404);
139 expect(listComments(app.db, secretId)).toHaveLength(0);
140 });
141
142 test("Reply is offered only to a signed-in viewer who may comment there", () => {
143 const db = app.db;
144 const bob = db.query("SELECT * FROM user WHERE username = 'bob'").get() as never;
145 expect(replyRepo(db, bob, "alice/open")).toBeDefined();
146 expect(replyRepo(db, bob, "alice/secret")).toBeUndefined();
147 expect(replyRepo(db, undefined, "alice/open")).toBeUndefined();
148 expect(replyRepo(db, bob, "alice/missing")).toBeUndefined();
149 });
150
151 test("a card drawn for a viewer who may not reply has no Reply form", () => {
152 const item: FeedItem = {
153 id: "x",
154 kind: "tell",
155 app: "alice/open",
156 actor: "alice",
157 at: new Date().toISOString(),
158 link: `/alice/open/issues/${publicIssue}`,
159 what: "opened an issue: Question",
160 reason: "",
161 score: 1,
162 } as FeedItem;
163 expect(String(feedCard(item, new Map(), undefined, false))).not.toContain("/comments");
164 expect(String(feedCard(item, new Map(), undefined, true))).toContain(`/alice/open/issues/${publicIssue}/comments`);
165 });
166
167 const cardOf = (over: Partial<FeedItem>): string =>
168 String(
169 feedCard(
170 {
171 id: "y",
172 kind: "tell",
173 app: "alice/open",
174 actor: "alice",
175 at: new Date().toISOString(),
176 link: `/alice/open/issues/${publicIssue}`,
177 what: "opened an issue",
178 reason: "",
179 score: 1,
180 ...over,
181 } as FeedItem,
182 new Map(),
183 undefined,
184 true,
185 ),
186 );
187
188 test("the Reply form posts back=feed, and its label escapes a hostile title", () => {
189 const card = cardOf({ what: 'opened an issue: "><script>alert(1)</script>' });
190 const form = card.slice(card.indexOf(`action="/alice/open/issues/${publicIssue}/comments"`));
191 expect(form).toContain('name="back" value="feed"');
192 expect(form).toContain('name="body"');
193 expect(card).not.toContain("<script>alert(1)</script>");
194 expect(card).toContain("&lt;script&gt;");
195 });
196
197 test("one primary button per card: Reply is the accent, Tell is not", () => {
198 const card = cardOf({});
199 expect(card.match(/data-variant="primary"/g)).toHaveLength(1);
200 const told = cardOf({ link: "/alice/open" });
201 expect(told).not.toContain("/comments");
202 expect(told.match(/data-variant="primary"/g)).toHaveLength(1);
203 });
204
205 test("a post linking to a thread in another repository offers no Reply", () => {
206 expect(cardOf({ app: "alice/secret" })).not.toContain("/comments");
207 });
208
209 test("an empty reply on an issue returns to the feed with a danger flash", async () => {
210 const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: " ", back: "feed" });
211 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed");
212 expect(flashOf(res).tone).toBe("danger");
213 });
214
215 test("an empty comment from the issue page stays on it", async () => {
216 const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: " " });
217 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/issues/${publicIssue}`);
218 });
219
220 test("a signed-out visitor to the feed sees no Reply form", async () => {
221 const page = await (await fetch(`${servers.url}/feed`, { redirect: "manual" })).text();
222 expect(page).not.toContain('name="back" value="feed"');
223 });
224
225 test("a reader of a public repository sees the card and may reply", async () => {
226 const page = await (await fetch(`${servers.url}/feed`, { headers: { cookie: bobCookie } })).text();
227 expect(page).toContain(`action="/alice/open/issues/${publicIssue}/comments"`);
228 expect(page).not.toContain("/alice/secret/issues");
229 });
230
231 test("a reply on a pull comments on it and returns to the feed with a flash", async () => {
232 const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: "Looks good.", back: "feed" });
233 expect(res.status).toBe(303);
234 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed");
235 expect(flashOf(res).message).toBe("Reply posted.");
236 expect(listComments(app.db, pullId).map((c) => c.body)).toContain("Looks good.");
237 });
238
239 test("an empty reply on a pull returns to the feed with a danger flash", async () => {
240 const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: " ", back: "feed" });
241 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/feed");
242 expect(flashOf(res).tone).toBe("danger");
243 });
244
245 test("a pull comment with an unknown back returns to the pull page", async () => {
246 for (const back of ["https://evil.example/", "constructor", ""]) {
247 const res = await post(`/alice/open/pulls/${pullNumber}/comments`, aliceCookie, { body: "Fine.", back });
248 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/pulls/${pullNumber}`);
249 }
250 });
251
252 test("the issue reply flashes Reply posted.", async () => {
253 const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, { body: "Noted.", back: "feed" });
254 expect(flashOf(res).message).toBe("Reply posted.");
255 });
256
257 test("a back that is not the feed's word goes nowhere new", async () => {
258 const res = await post(`/alice/open/issues/${publicIssue}/comments`, aliceCookie, {
259 body: "Again.",
260 back: "constructor",
261 });
262 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe(`/alice/open/issues/${publicIssue}`);
263 });
test/palette-roster.test.ts +3
@@ -110,6 +110,7 @@ test("a site-admin person is offered the whole roster", () => {
110110 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
111111 ["origins", "Connected origins", "the hosts your mirrors come from"],
112112 ["domains", "Failure domains", "failure domains and their last drill"],
113 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
113114 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
114115 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
115116 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
@@ -159,6 +160,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
159160 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
160161 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
161162 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
163 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
162164 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
163165 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
164166 ["family", "Family", "the forge that sent this one, and the forges it sent"],
@@ -208,6 +210,7 @@ test("a member person is offered the signed-in roster without the admin entries"
208210 ["archived", "Archived repositories", "put away, not gone: open one or wake it"],
209211 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
210212 ["origins", "Connected origins", "the hosts your mirrors come from"],
213 ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull"],
211214 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
212215 ["profile", "Your profile", "display name and email"],
213216 ["family", "Family", "the forge that sent this one, and the forges it sent"],
test/tui-feed-reply.test.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * The feed in the terminal: `r` on a post about an issue replies through the
3 * same comment action as the page's Reply; a person who cannot read the
4 * repository has no such post and no way to reply.
5 */
6 import { afterAll, beforeAll, 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 { openPull } from "../src/actions/pulls.ts";
12 import { readFeed } from "../src/feed/feed.ts";
13 import { replyTarget } from "../src/feed/reply.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { listComments } from "../src/core/issues.ts";
17 import { createUser, getUserByName } from "../src/core/users.ts";
18 import { createForgeApp } from "../src/tui/forge.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { promote } from "./helpers/members.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 let dir = "";
27 let forge: App;
28 let issueId = "";
29 let pullId = "";
30 let pullNumber = 0;
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-tui-feed-reply-"));
34 forge = createApp({ dataDir: dir, port: 0 });
35 for (const [username, first] of [["root", true], ["alice", false], ["bob", false]] as const) {
36 const made = await createUser(forge.db, {
37 origin: "seed", trusted: true, allowReserved: true, username, email: `${username}@forge.local`,
38 password: "a-good-passphrase", adminIfFirst: first,
39 });
40 if (!made.ok) throw new Error(made.error);
41 promote(forge.db, made.value);
42 }
43 const alice = getUserByName(forge.db, "alice")!;
44 const repo = await createRepo(forge, alice, { name: "secret", visibility: "private" });
45 if (!repo.ok) throw new Error(String(repo.error));
46 await commitToBranch(forge, alice, repo.value, { message: "seed", files: [{ path: "README.md", content: "# s\n" }] });
47 const issue = openIssue(forge, alice, repo.value, { title: "Hidden question", body: "why?" });
48 if (!issue.ok) throw new Error(String(issue.error));
49 issueId = issue.value.id;
50 const open = await createRepo(forge, alice, { name: "open", visibility: "public" });
51 if (!open.ok) throw new Error(String(open.error));
52 await commitToBranch(forge, alice, open.value, { message: "seed", files: [{ path: "README.md", content: "# o\n" }] });
53 await commitToBranch(forge, alice, open.value, {
54 branch: "idea", base: "main", message: "an idea", files: [{ path: "idea.md", content: "idea\n" }],
55 });
56 const pull = await openPull(forge, alice, open.value, { title: "An idea", body: "", baseBranch: "main", headBranch: "idea" });
57 if (!pull.ok) throw new Error(String(pull.error));
58 pullId = pull.value.id;
59 pullNumber = pull.value.number;
60 });
61
62 /** Moves the cursor onto the post linking to `link` in this person's feed. */
63 const downTo = (username: string, link: string): string[] => {
64 const items = readFeed(forge.db, getUserByName(forge.db, username)!, { limit: 20 }).items;
65 const at = items.findIndex((i) => i.link === link);
66 if (at < 0) throw new Error(`no post for ${link}`);
67 return Array.from({ length: at }, () => "j");
68 };
69
70 afterAll(async () => {
71 forge.db.close();
72 await rm(dir, { recursive: true, force: true });
73 });
74
75 function boot(username: string) {
76 const terminal = headlessTerminal(140, 32);
77 const identity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, username)! };
78 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
79 app.start();
80 const press = async (...keys: string[]) => {
81 for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
82 await settleApp(app);
83 };
84 const open = async () => {
85 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
outdated

Bob never sees this post, so the test passes with or without the gate. Test the gate where the post is present (replyRepo/replyTarget), and add a pull-post reply and an empty-body case.

86 await settleApp(app);
87 await press(..."Feed", "enter");
88 };
89 const submit = async () => {
90 app.dispatch({ type: "key", key: "d", ctrl: true, alt: false, shift: false });
91 await settleApp(app);
92 };
93 return { text: () => app.screen.toText(), press, open, submit, stop: () => app.stop() };
94 }
95
96 test("r on a post about an issue posts a comment on it", async () => {
97 const t = boot("alice");
98 await t.open();
99 expect(t.text()).toContain("Hidden question");
100 await t.press("r");
101 await t.press(..."Taking it.");
102 await t.submit();
103 expect(listComments(forge.db, issueId).map((c) => c.body)).toContain("Taking it.");
104 t.stop();
105 });
106
107 test("r on a post about a pull posts a comment on it", async () => {
108 const t = boot("alice");
109 await t.open();
110 await t.press(...downTo("alice", `/alice/open/pulls/${pullNumber}`));
111 await t.press("r");
112 expect(t.text()).toContain(`Reply on alice/open #${pullNumber}`);
113 await t.press(..."Merging soon.");
114 await t.submit();
115 expect(listComments(forge.db, pullId).map((c) => c.body)).toContain("Merging soon.");
116 t.stop();
117 });
118
119 test("an empty reply says so and posts nothing", async () => {
120 const before = listComments(forge.db, pullId).length;
121 const t = boot("alice");
122 await t.open();
123 await t.press(...downTo("alice", `/alice/open/pulls/${pullNumber}`));
124 await t.press("r");
125 await t.submit();
126 expect(t.text()).toContain("write something first");
127 expect(listComments(forge.db, pullId)).toHaveLength(before);
128 t.stop();
129 });
130
131 test("a post about a thread the person cannot read has no reply target, even when present", () => {
132 const db = forge.db;
133 const post = { app: "alice/secret", link: "/alice/secret/issues/1" };
134 expect(replyTarget(db, getUserByName(db, "bob")!, post)).toBeUndefined();
135 expect(replyTarget(db, undefined, post)).toBeUndefined();
136 expect(replyTarget(db, getUserByName(db, "alice")!, post)?.thread.number).toBe(1);
137 // A link into another repository than the post's own is no target either.
138 expect(replyTarget(db, getUserByName(db, "bob")!, { app: "alice/open", link: "/alice/secret/issues/1" })).toBeUndefined();
139 });
140
141 test("a person who cannot read the repository is not shown its post", async () => {
142 const t = boot("bob");
143 await t.open();
144 expect(t.text()).not.toContain("Hidden question");
145 await t.press("r");
146 await t.press(..."Let me in.");
147 await t.submit();
148 expect(listComments(forge.db, issueId).map((c) => c.body)).not.toContain("Let me in.");
149 t.stop();
150 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs