Public: anyone can read this; the crew can change it.
A guest pull's "What happens next" reads as plain lines under a heading, not a boxed card #10013
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10004-a-guest-pull-s-what-happens-next-reads-a → main 2 commits fork
2 files
changed +145
−24
src/web/pages/pull-guest.ts +19 −24
| @@ -22,16 +22,16 @@ import { readForm } from "../form.ts"; | ||
| 22 | 22 | import { html, type Html } from "../html.ts"; |
| 23 | 23 | import { flashTo } from "../layout.ts"; |
| 24 | 24 | import { pullContext } from "../studio/change.ts"; |
| 25 | import { btn, card, icon } from "../ui.ts"; | |
| 25 | import { btn } from "../ui.ts"; | |
| 26 | 26 | |
| 27 | 27 | export const GUEST_WAITS = |
| 28 | 28 | "This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it."; |
| 29 | 29 | export const GUEST_APPROVED = "Approved as a guest: its preview starts again with the app's variables, and the crew takes it up."; |
| 30 | 30 | |
| 31 | 31 | /** |
| 32 | * A stranger's open pull, as one notice (#9575): what happens next, in one | |
| 33 | * sentence read from the diff on the server; for a guest nobody approved | |
| 34 | * yet, the guest's words and the writer's approval in the same card, with | |
| 32 | * A stranger's open pull, as one plain headed section (#9575, #10004): what | |
| 33 | * happens next, in one sentence read from the diff on the server; for a guest | |
| 34 | * nobody approved yet, the guest's words and the writer's approval in the same section, with | |
| 35 | 35 | * the walls a person should weigh when nothing is guarded (a guarded path |
| 36 | 36 | * is named in the sentence already). Empty for anyone else's pull. |
| 37 | 37 | */ |
| @@ -43,27 +43,22 @@ export async function strangerCard(ctx: Ctx, repo: Repo, pull: Pull, action: str | ||
| 43 | 43 | const guarded = await strangerGuard(ctx.app, repo, pull); |
| 44 | 44 | const next = stranger ? html`<p ${guarded.length > 0 ? html`role="alert"` : null}>${strangerNext(repo, guarded)}</p>` : null; |
| 45 | 45 | if (!waits && next !== null) |
| 46 | return card(next, { | |
| 47 | extra: "mb", | |
| 48 | head: html`${icon(guarded.length > 0 ? "ban" : "clock")} | |
| 49 | <h2>What happens next</h2>`, | |
| 50 | }); | |
| 46 | return html`<section class="settings-block mb"> | |
| 47 | <h2>What happens next</h2> | |
| 48 | ${next} | |
| 49 | </section>`; | |
| 51 | 50 | const writer = mayApproveGuest(ctx.app.db, repo, ctx.auth.user); |
| 52 | return card( | |
| 53 | html`<p>${GUEST_WAITS}</p> | |
| 54 | <div class="mt">${next}</div> | |
| 55 | ${guarded.length > 0 ? null : walls.map((wall) => html`<p class="mt" role="alert">Look closely: ${wall}.</p>`)} | |
| 56 | ${writer | |
| 57 | ? html`<form method="post" action="${action}"> | |
| 58 | ${btn("Approve this guest", { size: "sm", icon: "check" })} | |
| 59 | </form>` | |
| 60 | : null}`, | |
| 61 | { | |
| 62 | extra: "mb", | |
| 63 | head: html`${icon("globe")} | |
| 64 | <h2>A guest</h2>`, | |
| 65 | }, | |
| 66 | ); | |
| 51 | return html`<section class="settings-block mb"> | |
| 52 | <h2>A guest</h2> | |
| 53 | <p>${GUEST_WAITS}</p> | |
| 54 | ${next} | |
| 55 | ${guarded.length > 0 ? null : walls.map((wall) => html`<p role="alert">Look closely: ${wall}.</p>`)} | |
| 56 | ${writer | |
| 57 | ? html`<form class="mt" method="post" action="${action}"> | |
| 58 | ${btn("Approve this guest", { size: "sm", icon: "check" })} | |
| 59 | </form>` | |
| 60 | : null} | |
| 61 | </section>`; | |
| 67 | 62 | } |
| 68 | 63 | |
| 69 | 64 | /** What a person should weigh before approving a guest: paths nobody but a person decides on. */ |
test/pull-guest-language.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * A stranger's pull says what happens next in the home page's language | |
| 3 | * (#10004): a plain headed section with its sentences, no card box. Only a | |
| 4 | * person who can write the repository is offered "Approve this guest". | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 15 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createSession } from "../src/core/sessions.ts"; | |
| 17 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 18 | import { markVerified } from "../src/core/verify.ts"; | |
| 19 | import { strangerNext } from "../src/crew/stranger.ts"; | |
| 20 | import { forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 21 | import { approveGuest } from "../src/origins/guest.ts"; | |
| 22 | import { setLimit } from "../src/settings/limits.ts"; | |
| 23 | import { GUEST_WAITS } from "../src/web/pages/pull-guest.ts"; | |
| 24 | import { quietLogs } from "./helpers/globals.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | const url = "http://forge.test"; | |
| 29 | let dataDir = ""; | |
| 30 | let app: App; | |
| 31 | let http: ForgeHttp; | |
| 32 | let owner: User; | |
| 33 | let author: User; | |
| 34 | let lib: Repo; | |
| 35 | let fork: Repo; | |
| 36 | let n = 0; | |
| 37 | ||
| 38 | async function person(username: string, first = false): Promise<User> { | |
| 39 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@test.dev`, password: "correct-horse-battery", adminIfFirst: first }); | |
| 40 | if (!made.ok) throw new Error(made.error); | |
| 41 | markVerified(app.db, made.value.id, new Date().toISOString()); | |
| 42 | app.db.query("UPDATE user SET created_at = ?1 WHERE id = ?2").run("2026-01-01T00:00:00.000Z", made.value.id); | |
| 43 | return getUserByName(app.db, username)!; | |
| 44 | } | |
| 45 | ||
| 46 | /** A new pull by the stranger from their fork of `lib`. */ | |
| 47 | async function strangerPull(): Promise<Pull> { | |
| 48 | const branch = `change${++n}`; | |
| 49 | const committed = await commitToBranch(app, author, fork, { branch, base: "main", message: "Change", files: [{ path: `src/${branch}.ts`, content: "export {};\n" }] }); | |
| 50 | if (!committed.ok) throw new Error(JSON.stringify(committed.error)); | |
| 51 | const opened = await openPull(app, author, lib, { title: "A change", body: "", baseBranch: "main", headBranch: branch, headRepo: fork }); | |
| 52 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 53 | return getPull(app.db, lib.id, opened.value.number)!; | |
| 54 | } | |
| 55 | ||
| 56 | /** The notice's section on `user`'s view of `pull`: from its `<section` to its `</section>`. */ | |
| 57 | async function section(user: User, pull: Pull, heading: string): Promise<string> { | |
| 58 | const res = await http.handle( | |
| 59 | new Request(`${url}/${lib.ownerName}/${lib.name}/pulls/${pull.number}`, { | |
| 60 | headers: { cookie: `forge_session=${createSession(app.db, user.id).token}` }, | |
| 61 | }), | |
| 62 | ); | |
| 63 | const page = await res.text(); | |
| 64 | const at = page.indexOf(`<h2>${heading}</h2>`); | |
| 65 | expect(at).toBeGreaterThan(0); | |
| 66 | return page.slice(page.lastIndexOf("<section", at), page.indexOf("</section>", at)); | |
| 67 | } | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-pull-guest-lang-")); | |
| 71 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 72 | http = forgeHttp(app); | |
| 73 | owner = await person("olive", true); | |
| 74 | for (const [key, value] of [ | |
| 75 | ["signup.per_source", 1000], | |
| 76 | ["guest.forks_per_day", 1000], | |
| 77 | ["guest.forks_forge_per_day", 1000], | |
| 78 | ["guest.pulls_per_day", 1000], | |
| 79 | ] as const) | |
| 80 | setLimit(app.db, key, value, "setup"); | |
| 81 | const made = await createRepo(app, owner, { name: "lib", visibility: "public" }); | |
| 82 | if (!made.ok) throw new Error(String(made.error)); | |
| 83 | const seeded = await commitToBranch(app, owner, made.value, { message: "Start", files: [{ path: "README.md", content: "hi\n" }] }); | |
| 84 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 85 | lib = getRepoById(app.db, made.value.id)!; | |
| 86 | author = await person("stranger"); | |
| 87 | const forked = await forkRepo(app, author, lib); | |
| 88 | if (!forked.ok) throw new Error(String(forked.error)); | |
| 89 | fork = forked.value; | |
| 90 | }, 60_000); | |
| 91 | ||
| 92 | afterAll(async () => { | |
| 93 | http.close(); | |
| 94 | app.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a waiting guest's pull is a plain section; only the writer is offered Approve", async () => { | |
| 99 | const pull = await strangerPull(); | |
| 100 | const sentence = strangerNext(lib, []); | |
| 101 | for (const user of [owner, author]) { | |
| 102 | const block = await section(user, pull, "A guest"); | |
| 103 | expect(block).toContain('class="settings-block'); | |
| 104 | expect(block).not.toContain('class="card'); | |
| 105 | expect(block).toContain(GUEST_WAITS.slice(0, 40)); | |
| 106 | expect(block).toContain(sentence.slice(0, 40)); | |
| 107 | } | |
| 108 | const writer = await section(owner, pull, "A guest"); | |
| 109 | expect(writer).toContain("Approve this guest"); | |
| 110 | expect(writer).toContain(`action="/olive/lib/pulls/${pull.number}/approve-guest"`); | |
| 111 | expect(writer).toMatch(/<form class="mt" method="post"/); | |
| 112 | expect(await section(author, pull, "A guest")).not.toContain("Approve this guest"); | |
| 113 | }, 30_000); | |
| 114 | ||
| 115 | test("an approved stranger's pull says what happens next under its heading, no card, no Approve", async () => { | |
| 116 | const pull = await strangerPull(); | |
| 117 | expect(approveGuest(app.db, lib, pull, owner).ok).toBe(true); | |
| 118 | const sentence = strangerNext(lib, []); | |
| 119 | for (const user of [owner, author]) { | |
| 120 | const block = await section(user, pull, "What happens next"); | |
| 121 | expect(block).toContain('class="settings-block'); | |
| 122 | expect(block).not.toContain('class="card'); | |
| 123 | expect(block).toContain(sentence.slice(0, 40)); | |
| 124 | expect(block).not.toContain("Approve this guest"); | |
| 125 | } | |
| 126 | }, 30_000); |