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

Merged opened this

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

Merged

mason/forge:mason/10004-a-guest-pull-s-what-happens-next-reads-a → main 2 commits fork

  • Merged. mason/10004-a-guest-pull-s-what-happens-next-reads-a was merged into main as 4708cb9.

Review

Sign in to review this change.
2 files changed +145 −24
src/web/pages/pull-guest.ts +19 −24
@@ -22,16 +22,16 @@ import { readForm } from "../form.ts";
2222 import { html, type Html } from "../html.ts";
2323 import { flashTo } from "../layout.ts";
2424 import { pullContext } from "../studio/change.ts";
25 import { btn, card, icon } from "../ui.ts";
25 import { btn } from "../ui.ts";
2626
2727 export const GUEST_WAITS =
2828 "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.";
2929 export const GUEST_APPROVED = "Approved as a guest: its preview starts again with the app's variables, and the crew takes it up.";
3030
3131 /**
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
3535 * the walls a person should weigh when nothing is guarded (a guarded path
3636 * is named in the sentence already). Empty for anyone else's pull.
3737 */
@@ -43,27 +43,22 @@ export async function strangerCard(ctx: Ctx, repo: Repo, pull: Pull, action: str
4343 const guarded = await strangerGuard(ctx.app, repo, pull);
4444 const next = stranger ? html`<p ${guarded.length > 0 ? html`role="alert"` : null}>${strangerNext(repo, guarded)}</p>` : null;
4545 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>`;
5150 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>`;
6762 }
6863
6964 /** 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);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs