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

A note's sender gets the issue's link whenever they can read the repository #10697

Merged opened this

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

Merged

scout/forge:scout/10560-a-note-s-sender-gets-the-issue-s-link-wh → main 4 commits fork

  • Merged. scout/10560-a-note-s-sender-gets-the-issue-s-link-wh was merged into main as b3f8a6e.

Review

Sign in to review this change.
10 files changed +150 −32
docs/features/notes.md +4
@@ -24,3 +24,7 @@ Nothing waits for you to accept a note. The planner gives each one a verdict and
2424 An overrule is `POST /notes/:id/overrule` (`do=build|decline`), kept as the setting `note.<thought id>` on you; the planner's own placement is never rewritten, only added to.
2525
2626 The terminal has the same door: the palette's "Notes" (`src/tui/screens/thoughtnotes.ts`) writes with `n`, lists your own notes with the same one-line state, and `b` takes the tap the line offers. An overrule is once per note and only what the line offers; a repeat or a stale form does nothing. "Snoozed" is judged per idea in a dump, and "backlog" is not a snooze word.
27
28 ## Notes from an app's forge button
29
30 A note sent from an app's forge button (or the terminal's "Tell the builders") files a from-app issue, and the sender is told its number at once: "Filed as #N". Anyone the repository would show the issue to (`canRead`, so a signed-in reader of a private repository too, not only a public app on a public repository) also gets the issue's link and the repository's; everyone else gets the number and no link. The list shows `#N` beside each note. The browser button links only to the forge's own origin. In the terminal (`src/tui/screens/notes.ts`) the sender is the SSH user: the confirmation reads "Sent as #N, owner/repo/issues/N" and the list and moderation screens carry `#N`; the terminal has no clickable link.
src/actions/app_notes.ts +18 −7
@@ -13,7 +13,7 @@ import { createLabel, getIssue, getLabel, type Issue } from "../core/issues.ts";
1313 import { mergedPullClosing } from "../core/pulls.ts";
1414 import { releaseName, releaseOfPull } from "../release/record.ts";
1515 import { RateLimiter } from "../core/ratelimit.ts";
16 import { canAdmin, type Repo } from "../core/repos.ts";
16 import { canAdmin, canRead, type Repo } from "../core/repos.ts";
1717 import { plainText } from "../core/reports.ts";
1818 import { listActiveRuns, workingAgentRuns } from "../core/runs.ts";
1919 import { getUserById, isActive, type User } from "../core/users.ts";
@@ -98,7 +98,10 @@ export interface NoteView {
9898 readonly by: string;
9999 readonly at: string;
100100 readonly status: NoteStatus;
101 /** The issue's number: shown to everyone, linked only to a reader of the repository. */
102 readonly number: number;
101103 readonly url?: string;
104 readonly repoUrl?: string;
102105 }
103106
104107 /** Where a note stands, read from the issue as it moves. */
@@ -129,24 +132,31 @@ export function statusOf(app: App, repo: Repo, number: number): { status: NoteSt
129132 return { title: issue.title, status: { state: "queued", label: "In the queue for a builder" } };
130133 }
131134
132 function view(app: App, record: AppRecord, repo: Repo, forgeUrl: string | undefined, note: AppNote): NoteView | undefined {
135 function view(app: App, repo: Repo, forgeUrl: string | undefined, viewer: User | undefined, note: AppNote): NoteView | undefined {
133136 const found = statusOf(app, repo, note.issueNumber);
134137 if (found === undefined) return undefined;
135 const open = forgeUrl !== undefined && record.access === "public" && repo.visibility === "public";
138 // A link only to someone the repository would show the issue to.
139 const open = forgeUrl !== undefined && canRead(app.db, repo, viewer);
136140 return {
137141 id: note.id,
138142 text: found.title.startsWith(FROM_APP_TITLE) ? found.title.slice(FROM_APP_TITLE.length) : found.title,
139143 by: note.name,
140144 at: note.createdAt,
141145 status: found.status,
142 ...(open ? { url: new URL(`/${repo.ownerName}/${repo.name}/issues/${note.issueNumber}`, forgeUrl).href } : {}),
146 number: note.issueNumber,
147 ...(open
148 ? {
149 url: new URL(`/${repo.ownerName}/${repo.name}/issues/${note.issueNumber}`, forgeUrl).href,
150 repoUrl: new URL(`/${repo.ownerName}/${repo.name}`, forgeUrl).href,
151 }
152 : {}),
143153 };
144154 }
145155
146156 /** A sender's own notes; an admin of the repository may ask for every sender's. */
147 export function notesFor(app: App, record: AppRecord, repo: Repo, forgeUrl: string | undefined, sender: Sender, everyone = false): NoteView[] {
157 export function notesFor(app: App, record: AppRecord, repo: Repo, forgeUrl: string | undefined, sender: Sender, everyone = false, viewer?: User): NoteView[] {
148158 const rows = everyone ? notesOfApp(app.db, record.id) : notesOf(app.db, record.id, sender.key);
149 return rows.map((n) => view(app, record, repo, forgeUrl, n)).filter((n): n is NoteView => n !== undefined);
159 return rows.map((n) => view(app, repo, forgeUrl, viewer, n)).filter((n): n is NoteView => n !== undefined);
150160 }
151161
152162 function titleOf(text: string): string {
@@ -188,6 +198,7 @@ export async function sendNote(
188198 sender: Sender,
189199 input: NoteInput,
190200 source: string,
201 viewer?: User,
191202 ): Promise<Result<NoteView, Refusal>> {
192203 const text = plainText(input.text).trim();
193204 if (text === "") return err({ error: "empty", message: NOTE_EMPTY });
@@ -244,7 +255,7 @@ export async function sendNote(
244255 void import("../crew/first_look.ts").then((m) => m.firstLook(app, owner, repo, opened.value, facts, who))
245256 .catch((error) => app.log.warn("first look could not start", { error: String(error) }));
246257 }
247 return ok(view(app, record, repo, forgeUrl, note)!);
258 return ok(view(app, repo, forgeUrl, viewer, note)!);
248259 }
249260
250261 /**
src/apps/feedback.ts +2 −1
@@ -183,6 +183,7 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> {
183183 ask.app, ask.record, door.repo, ask.env, ask.forgeUrl, sender,
184184 { text: typeof body.text === "string" ? body.text : "", page, viewport },
185185 ask.source,
186 door.viewer,
186187 );
187188 if (!sent.ok) {
188189 const r = sent.error;
@@ -232,7 +233,7 @@ async function picture(ask: FeedbackAsk, door: Door): Promise<Response> {
232233
233234 function list(ask: FeedbackAsk, door: Door, proven: boolean): Response {
234235 const { sender, fresh } = senderOf(ask, door);
235 const notes = notesFor(ask.app, ask.record, door.repo, ask.forgeUrl, sender);
236 const notes = notesFor(ask.app, ask.record, door.repo, ask.forgeUrl, sender, false, door.viewer);
236237 return json(
237238 200,
238239 { name: sender.name, notes, admin: proven && door.viewer !== undefined && canAdmin(ask.app.db, door.repo, door.viewer) },
src/apps/toolbar.ts +17 −12
@@ -188,21 +188,25 @@ export const toolbarJs = `(function () {
188188 st.style.cssText = "opacity:.85";
189189 row.appendChild(t);
190190 row.appendChild(st);
191 try {
192 var u = new URL(n.url);
193 if (u.origin === forgeOrigin) {
194 var al = document.createElement("a");
195 al.textContent = "See it";
196 al.href = u.href;
197 al.target = "_blank";
198 al.rel = "noopener";
199 al.style.cssText = "color:#8ab4ff";
200 row.appendChild(al);
201 }
202 } catch (e) {}
191 var al = n.url && link(n.url, "See it");
192 if (al) row.appendChild(al);
203193 mine.appendChild(row);
204194 });
205195 }
196 function link(u, text) {
197 try {
198 var l = new URL(u), el = document.createElement("a");
199 if (l.origin !== forgeOrigin) return null;
200 el.textContent = text; el.href = l.href; el.target = "_blank"; el.rel = "noopener"; el.style.color = "#8ab4ff";
201 return el;
202 } catch (e) { return null; }
203 }
204 function filed(n) {
205 if (!n || typeof n.number !== "number") return;
206 var t = "Filed as #" + n.number, a = n.url && link(n.url, t), r = n.repoUrl && link(n.repoUrl, "The repository");
207 tellSay.append(" ", a || t);
208 if (r) tellSay.append(" ", r);
209 }
206210 function refresh(sent) {
207211 call("/__forge/notes", { credentials: "same-origin", cache: "no-store" })
208212 .then(function (r) { return r.ok ? r.json() : null; })
@@ -292,6 +296,7 @@ export const toolbarJs = `(function () {
292296 tellText.value = "";
293297 tellPic.checked = false;
294298 tellSay.textContent = wantPicture && x.pic !== true ? "Sent, without the picture. The builders will see it." : "Sent. The builders will see it.";
299 filed(a.j && a.j.note);
295300 refresh(true);
296301 }
297302 else tellSay.textContent = a.j && a.j.message ? a.j.message : ${JSON.stringify(NOTE_FAILED)};
src/tui/screens/notes.ts +6 −6
@@ -71,8 +71,8 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R
7171 hint: "enter sends",
7272 onSubmit: (said) =>
7373 ctx.track((async () => {
74 const sent = await sendNote(f.app, record, repo, "production", undefined, sender, { text: said, page: "", viewport: "unknown" }, `ssh:${user.id}`);
75 if (sent.ok) ctx.toast("Sent. The builders will see it.", "success");
74 const sent = await sendNote(f.app, record, repo, "production", undefined, sender, { text: said, page: "", viewport: "unknown" }, `ssh:${user.id}`, user);
75 if (sent.ok) ctx.toast(`Sent as #${sent.value.number}, ${name}/issues/${sent.value.number}. The builders will see it.`, "success");
7676 else ctx.toast(sent.error.message, "danger");
7777 })()),
7878 }),
@@ -83,20 +83,20 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R
8383 title: `${name} — Notes from the app`,
8484 hint: admin ? "every sender's; hide or ban" : "yours, with their status",
8585 run: () => {
86 const notes = notesFor(f.app, record, repo, undefined, sender, admin);
86 const notes = notesFor(f.app, record, repo, undefined, sender, admin, user);
8787 if (notes.length === 0) return ctx.toast("No notes yet.");
8888 ctx.push(
8989 pickerScreen(ctx, parent(), {
9090 title: "Notes from the app",
91 items: notes.map((n) => ({ id: n.id, label: n.text, hint: `${n.status.label} — ${n.by}` })),
91 items: notes.map((n) => ({ id: n.id, label: `#${n.number} ${n.text}`, hint: `${n.status.label} — ${n.by}` })),
9292 onPick: ([id]) => {
9393 const picked = notes.find((n) => n.id === id);
9494 if (picked === undefined) return;
95 if (!admin) return ctx.toast(`${picked.status.label}.`);
95 if (!admin) return ctx.toast(`#${picked.number}: ${picked.status.label}.`);
9696 ctx.push(
9797 confirmScreen(ctx, parent(), {
9898 title: "Moderate this note",
99 body: [`${picked.by}: ${picked.text}`, picked.status.label],
99 body: [`#${picked.number} ${picked.by}: ${picked.text}`, picked.status.label],
100100 actions: [{ label: "Keep", variant: "ghost" }, { label: "Hide" }, { label: "Ban sender", variant: "danger" }],
101101 onChoose: (i) => {
102102 if (i < 1) return;
test/app_notes.test.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Who gets the issue's link with a note (#10560): anyone who can read the
3 * repository, with the number shown to everyone.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { newApp } from "../src/actions/apps.ts";
10 import { notesFor, sendNote, type Sender } from "../src/actions/app_notes.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { getAppBySlug } from "../src/core/apps.ts";
13 import { getRepo } from "../src/core/repos.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 const FORGE = "https://forge.test";
20 const input = { text: "make it blue", page: "", viewport: "390x844" };
21
22 describe("the link a note's sender gets", () => {
23 let dataDir: string;
24 let app: App;
25 let alice: User;
26 let seq = 0;
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-app-notes-link-"));
30 app = createApp({ dataDir, port: 0, host: "forge.test" });
31 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", adminIfFirst: true });
32 if (!made.ok) throw new Error(String(made.error));
33 alice = made.value;
34 for (const name of ["sandbox-game", "open-door"]) {
35 const a = await newApp(app, alice, { name, description: "Words", visibility: "private" });
36 if (!a.ok) throw new Error(String(a.error));
37 }
38 });
39
40 afterAll(async () => {
41 app.close();
42 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
43 });
44
45 const person: Sender = { key: "", name: "@alice", person: true };
46 const visitor = (): Sender => ({ key: `v:link${++seq}`, name: `visitor-${seq}`, person: false });
47
48 test("the owner of a private app gets the number, the issue's link and the repository's", async () => {
49 const record = getAppBySlug(app.db, "sandbox-game")!;
50 const repo = getRepo(app.db, "alice", "sandbox-game")!;
51 const sender = { ...person, key: `u:${alice.id}` };
52 const sent = await sendNote(app, record, repo, "production", FORGE, sender, input, "10.1.0.1", alice);
53 if (!sent.ok) throw new Error(sent.error.message);
54 expect(sent.value.number).toBeGreaterThan(0);
55 expect(sent.value.url).toBe(`${FORGE}/alice/sandbox-game/issues/${sent.value.number}`);
56 expect(sent.value.repoUrl).toBe(`${FORGE}/alice/sandbox-game`);
57 const listed = notesFor(app, record, repo, FORGE, sender, false, alice);
58 expect(listed[0]?.url).toBe(sent.value.url);
59 expect(listed[0]?.repoUrl).toBe(sent.value.repoUrl);
60 });
61
62 test("a signed-out visitor of a public app on a private repo gets the number and no link", async () => {
63 const record = getAppBySlug(app.db, "open-door")!;
64 const repo = getRepo(app.db, "alice", "open-door")!;
65 app.db.query("UPDATE app SET access = 'public' WHERE id = ?1").run(record.id);
66 const open = getAppBySlug(app.db, "open-door")!;
67 expect(open.access).toBe("public");
68 expect(repo.visibility).toBe("private");
69 const who = visitor();
70 const sent = await sendNote(app, open, repo, "production", FORGE, who, input, "10.1.0.2");
71 if (!sent.ok) throw new Error(sent.error.message);
72 expect(sent.value.number).toBeGreaterThan(0);
73 expect(sent.value.url).toBeUndefined();
74 expect(sent.value.repoUrl).toBeUndefined();
75 const listed = notesFor(app, open, repo, FORGE, who);
76 expect(listed[0]?.number).toBe(sent.value.number);
77 expect(listed[0]?.url).toBeUndefined();
78 });
79 });
test/apps-crew-pill.test.ts +2 −1
@@ -214,6 +214,7 @@ describe("the crew line", () => {
214214 );
215215 expect(toolbarJs).not.toContain("document.cookie");
216216 expect(toolbarJs).toContain('aria-live", "polite"');
217 expect(toolbarJs.length).toBeLessThan(22 * 1024);
217 // 24 KB: the script keeps its comments, and the filed number and links (#10560) added about 0.8 KB.
218 expect(toolbarJs.length).toBeLessThan(24 * 1024);
218219 });
219220 });
test/apps-feedback.test.ts +16 −2
@@ -342,18 +342,32 @@ describe("the forge button takes notes from anyone", () => {
342342 expect(await Bun.file(join(import.meta.dir, "../src/actions/app_notes.ts")).text()).not.toContain("x-forwarded-for");
343343 });
344344
345 test("the button shows 'Filed as #N' linked, at once, only to forge-origin links", () => {
346 expect(toolbarJs).toContain('"Filed as #" + n.number');
347 expect(toolbarJs).toContain("filed(a.j && a.j.note);");
348 expect(toolbarJs).toContain("n.repoUrl && link(n.repoUrl");
349 // A link is built only after its origin is checked against the forge's.
350 expect(toolbarJs).toMatch(/if \(l\.origin !== forgeOrigin\) return null;/);
351 });
352
345353 test("a private app takes notes from a signed-in reader, as themselves, and from no one else", async () => {
346354 const reader = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!);
347355 const first = await page("diary", reader);
348356 const res = await postAs("diary", reader, first.proof, "A note on the diary");
349357 expect(res.status).toBe(201);
350 expect(((await res.json()) as { name: string }).name).toBe("@alice");
358 const sentJson = (await res.json()) as { name: string; note: { number: number; url: string; repoUrl: string } };
359 expect(sentJson.name).toBe("@alice");
360 // A reader of the repository is given the number, the issue's link and the repository's (#10560).
361 expect(sentJson.note.number).toBeGreaterThan(0);
362 expect(sentJson.note.url).toBe(`${servers.url}/alice/diary/issues/${sentJson.note.number}`);
363 expect(sentJson.note.repoUrl).toBe(`${servers.url}/alice/diary`);
351364 const repo = getRepo(app.db, "alice", "diary")!;
352365 const issue = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 ORDER BY number DESC").get(repo.id)!;
353366 expect(issue.body).toContain("by @alice.");
354367 expect(issue.body).toContain("Untrusted data");
355 const mine = (await (await at("diary", "/__forge/notes", { headers: withProof(res.headers.get("x-forge-proof")!) }, reader)).json()) as { notes: unknown[]; admin: boolean };
368 const mine = (await (await at("diary", "/__forge/notes", { headers: withProof(res.headers.get("x-forge-proof")!) }, reader)).json()) as { notes: { number: number; url?: string; repoUrl?: string }[]; admin: boolean };
356369 expect(mine.notes.length).toBe(1);
370 expect(mine.notes[0]).toMatchObject({ number: sentJson.note.number, url: sentJson.note.url, repoUrl: sentJson.note.repoUrl });
357371 expect(mine.admin).toBe(true);
358372 expect((await at("diary", "/__forge/notes")).status).toBe(404);
359373 });
test/apps-toolbar.test.ts +1 −1
@@ -366,7 +366,7 @@ describe("the forge pill", () => {
366366 expect(res.headers.get("content-type")).toContain("javascript");
367367 const code = await res.text();
368368 expect(code).toBe(toolbarJs);
369 expect(code.length).toBeLessThan(22 * 1024);
369 expect(code.length).toBeLessThan(24 * 1024);
370370 expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/);
371371 expect(code).toContain("attachShadow");
372372 });
test/tui-notes.test.ts +5 −2
@@ -88,8 +88,10 @@ describe("notes from the app, in the terminal", () => {
8888
8989 test("a note files a from-app issue as the person", async () => {
9090 const text = await palette(bob, notes, [":", "Tell the", "\r", ..."the logo is blurry".split(""), "\r"]);
91 expect(text).toContain("Sent");
9291 const row = app.db.query<{ issue_number: number; name: string }, []>("SELECT issue_number, name FROM app_note").get()!;
92 // The sender is told the issue's number and where it is.
93 expect(text).toContain(`Sent as #${row.issue_number}`);
94 expect(text).toContain(`alice/notes/issues/${row.issue_number}`);
9395 expect(row.name).toBe("@bob");
9496 const issue = getIssue(app.db, notes.id, row.issue_number)!;
9597 expect(labelsFor(app.db, issue.id).map((l) => l.name)).toEqual(["from-app"]);
@@ -97,7 +99,8 @@ describe("notes from the app, in the terminal", () => {
9799 }, 30_000);
98100
99101 test("a person sees their own with its status; the admin sees all and bans", async () => {
100 expect(await palette(bob, notes, [":", "Notes from", "\r"])).toContain("the logo is blurry");
102 const number = app.db.query<{ issue_number: number }, []>("SELECT issue_number FROM app_note").get()!.issue_number;
103 expect(await palette(bob, notes, [":", "Notes from", "\r"])).toContain(`#${number} the logo is blurry`);
101104 expect(await palette(alice, notes, [":", "Notes from", "\r"])).toContain("the logo is blurry");
102105 // alice has none of her own, bob picks and is only told the status; alice bans.
103106 const asBob = await palette(bob, notes, [":", "Notes from", "\r", "\r"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs