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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10560-a-note-s-sender-gets-the-issue-s-link-wh → main 4 commits fork
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 | ||
| 24 | 24 | 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. |
| 25 | 25 | |
| 26 | 26 | 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"; | ||
| 13 | 13 | import { mergedPullClosing } from "../core/pulls.ts"; |
| 14 | 14 | import { releaseName, releaseOfPull } from "../release/record.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { plainText } from "../core/reports.ts"; |
| 18 | 18 | import { listActiveRuns, workingAgentRuns } from "../core/runs.ts"; |
| 19 | 19 | import { getUserById, isActive, type User } from "../core/users.ts"; |
| @@ -98,7 +98,10 @@ export interface NoteView { | ||
| 98 | 98 | readonly by: string; |
| 99 | 99 | readonly at: string; |
| 100 | 100 | readonly status: NoteStatus; |
| 101 | /** The issue's number: shown to everyone, linked only to a reader of the repository. */ | |
| 102 | readonly number: number; | |
| 101 | 103 | readonly url?: string; |
| 104 | readonly repoUrl?: string; | |
| 102 | 105 | } |
| 103 | 106 | |
| 104 | 107 | /** 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 | ||
| 129 | 132 | return { title: issue.title, status: { state: "queued", label: "In the queue for a builder" } }; |
| 130 | 133 | } |
| 131 | 134 | |
| 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 { | |
| 133 | 136 | const found = statusOf(app, repo, note.issueNumber); |
| 134 | 137 | 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); | |
| 136 | 140 | return { |
| 137 | 141 | id: note.id, |
| 138 | 142 | text: found.title.startsWith(FROM_APP_TITLE) ? found.title.slice(FROM_APP_TITLE.length) : found.title, |
| 139 | 143 | by: note.name, |
| 140 | 144 | at: note.createdAt, |
| 141 | 145 | 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 | : {}), | |
| 143 | 153 | }; |
| 144 | 154 | } |
| 145 | 155 | |
| 146 | 156 | /** 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[] { | |
| 148 | 158 | 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); | |
| 150 | 160 | } |
| 151 | 161 | |
| 152 | 162 | function titleOf(text: string): string { |
| @@ -188,6 +198,7 @@ export async function sendNote( | ||
| 188 | 198 | sender: Sender, |
| 189 | 199 | input: NoteInput, |
| 190 | 200 | source: string, |
| 201 | viewer?: User, | |
| 191 | 202 | ): Promise<Result<NoteView, Refusal>> { |
| 192 | 203 | const text = plainText(input.text).trim(); |
| 193 | 204 | if (text === "") return err({ error: "empty", message: NOTE_EMPTY }); |
| @@ -244,7 +255,7 @@ export async function sendNote( | ||
| 244 | 255 | void import("../crew/first_look.ts").then((m) => m.firstLook(app, owner, repo, opened.value, facts, who)) |
| 245 | 256 | .catch((error) => app.log.warn("first look could not start", { error: String(error) })); |
| 246 | 257 | } |
| 247 | return ok(view(app, record, repo, forgeUrl, note)!); | |
| 258 | return ok(view(app, repo, forgeUrl, viewer, note)!); | |
| 248 | 259 | } |
| 249 | 260 | |
| 250 | 261 | /** |
src/apps/feedback.ts +2 −1
| @@ -183,6 +183,7 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> { | ||
| 183 | 183 | ask.app, ask.record, door.repo, ask.env, ask.forgeUrl, sender, |
| 184 | 184 | { text: typeof body.text === "string" ? body.text : "", page, viewport }, |
| 185 | 185 | ask.source, |
| 186 | door.viewer, | |
| 186 | 187 | ); |
| 187 | 188 | if (!sent.ok) { |
| 188 | 189 | const r = sent.error; |
| @@ -232,7 +233,7 @@ async function picture(ask: FeedbackAsk, door: Door): Promise<Response> { | ||
| 232 | 233 | |
| 233 | 234 | function list(ask: FeedbackAsk, door: Door, proven: boolean): Response { |
| 234 | 235 | 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); | |
| 236 | 237 | return json( |
| 237 | 238 | 200, |
| 238 | 239 | { 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 () { | ||
| 188 | 188 | st.style.cssText = "opacity:.85"; |
| 189 | 189 | row.appendChild(t); |
| 190 | 190 | 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); | |
| 203 | 193 | mine.appendChild(row); |
| 204 | 194 | }); |
| 205 | 195 | } |
| 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 | } | |
| 206 | 210 | function refresh(sent) { |
| 207 | 211 | call("/__forge/notes", { credentials: "same-origin", cache: "no-store" }) |
| 208 | 212 | .then(function (r) { return r.ok ? r.json() : null; }) |
| @@ -292,6 +296,7 @@ export const toolbarJs = `(function () { | ||
| 292 | 296 | tellText.value = ""; |
| 293 | 297 | tellPic.checked = false; |
| 294 | 298 | 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); | |
| 295 | 300 | refresh(true); |
| 296 | 301 | } |
| 297 | 302 | 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 | ||
| 71 | 71 | hint: "enter sends", |
| 72 | 72 | onSubmit: (said) => |
| 73 | 73 | 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"); | |
| 76 | 76 | else ctx.toast(sent.error.message, "danger"); |
| 77 | 77 | })()), |
| 78 | 78 | }), |
| @@ -83,20 +83,20 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R | ||
| 83 | 83 | title: `${name} — Notes from the app`, |
| 84 | 84 | hint: admin ? "every sender's; hide or ban" : "yours, with their status", |
| 85 | 85 | run: () => { |
| 86 | const notes = notesFor(f.app, record, repo, undefined, sender, admin); | |
| 86 | const notes = notesFor(f.app, record, repo, undefined, sender, admin, user); | |
| 87 | 87 | if (notes.length === 0) return ctx.toast("No notes yet."); |
| 88 | 88 | ctx.push( |
| 89 | 89 | pickerScreen(ctx, parent(), { |
| 90 | 90 | 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}` })), | |
| 92 | 92 | onPick: ([id]) => { |
| 93 | 93 | const picked = notes.find((n) => n.id === id); |
| 94 | 94 | if (picked === undefined) return; |
| 95 | if (!admin) return ctx.toast(`${picked.status.label}.`); | |
| 95 | if (!admin) return ctx.toast(`#${picked.number}: ${picked.status.label}.`); | |
| 96 | 96 | ctx.push( |
| 97 | 97 | confirmScreen(ctx, parent(), { |
| 98 | 98 | title: "Moderate this note", |
| 99 | body: [`${picked.by}: ${picked.text}`, picked.status.label], | |
| 99 | body: [`#${picked.number} ${picked.by}: ${picked.text}`, picked.status.label], | |
| 100 | 100 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Hide" }, { label: "Ban sender", variant: "danger" }], |
| 101 | 101 | onChoose: (i) => { |
| 102 | 102 | 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", () => { | ||
| 214 | 214 | ); |
| 215 | 215 | expect(toolbarJs).not.toContain("document.cookie"); |
| 216 | 216 | 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); | |
| 218 | 219 | }); |
| 219 | 220 | }); |
test/apps-feedback.test.ts +16 −2
| @@ -342,18 +342,32 @@ describe("the forge button takes notes from anyone", () => { | ||
| 342 | 342 | expect(await Bun.file(join(import.meta.dir, "../src/actions/app_notes.ts")).text()).not.toContain("x-forwarded-for"); |
| 343 | 343 | }); |
| 344 | 344 | |
| 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 | ||
| 345 | 353 | test("a private app takes notes from a signed-in reader, as themselves, and from no one else", async () => { |
| 346 | 354 | const reader = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!); |
| 347 | 355 | const first = await page("diary", reader); |
| 348 | 356 | const res = await postAs("diary", reader, first.proof, "A note on the diary"); |
| 349 | 357 | 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`); | |
| 351 | 364 | const repo = getRepo(app.db, "alice", "diary")!; |
| 352 | 365 | const issue = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 ORDER BY number DESC").get(repo.id)!; |
| 353 | 366 | expect(issue.body).toContain("by @alice."); |
| 354 | 367 | 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 }; | |
| 356 | 369 | expect(mine.notes.length).toBe(1); |
| 370 | expect(mine.notes[0]).toMatchObject({ number: sentJson.note.number, url: sentJson.note.url, repoUrl: sentJson.note.repoUrl }); | |
| 357 | 371 | expect(mine.admin).toBe(true); |
| 358 | 372 | expect((await at("diary", "/__forge/notes")).status).toBe(404); |
| 359 | 373 | }); |
test/apps-toolbar.test.ts +1 −1
| @@ -366,7 +366,7 @@ describe("the forge pill", () => { | ||
| 366 | 366 | expect(res.headers.get("content-type")).toContain("javascript"); |
| 367 | 367 | const code = await res.text(); |
| 368 | 368 | expect(code).toBe(toolbarJs); |
| 369 | expect(code.length).toBeLessThan(22 * 1024); | |
| 369 | expect(code.length).toBeLessThan(24 * 1024); | |
| 370 | 370 | expect(code).not.toMatch(/\beval\b|new Function|\son[a-z]+\s*=/); |
| 371 | 371 | expect(code).toContain("attachShadow"); |
| 372 | 372 | }); |
test/tui-notes.test.ts +5 −2
| @@ -88,8 +88,10 @@ describe("notes from the app, in the terminal", () => { | ||
| 88 | 88 | |
| 89 | 89 | test("a note files a from-app issue as the person", async () => { |
| 90 | 90 | const text = await palette(bob, notes, [":", "Tell the", "\r", ..."the logo is blurry".split(""), "\r"]); |
| 91 | expect(text).toContain("Sent"); | |
| 92 | 91 | 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}`); | |
| 93 | 95 | expect(row.name).toBe("@bob"); |
| 94 | 96 | const issue = getIssue(app.db, notes.id, row.issue_number)!; |
| 95 | 97 | 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", () => { | ||
| 97 | 99 | }, 30_000); |
| 98 | 100 | |
| 99 | 101 | 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`); | |
| 101 | 104 | expect(await palette(alice, notes, [":", "Notes from", "\r"])).toContain("the logo is blurry"); |
| 102 | 105 | // alice has none of her own, bob picks and is only told the status; alice bans. |
| 103 | 106 | const asBob = await palette(bob, notes, [":", "Notes from", "\r", "\r"]); |