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

Looks before/after for overlays at phone size (#11831 item 5) #11897

Merged opened this

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

Merged

mason/forge:mason/11856-looks-before-after-for-overlays-at-phone → main 3 commits fork 4 threads · 4 unresolved

  • Merged. mason/11856-looks-before-after-for-overlays-at-phone was merged into main as a659038.

Review

Sign in to review this change.
9 files changed +221 −5
docs/features/pull-looks.md +16
@@ -45,3 +45,19 @@ round of about fifty ends: a round cut short by a newer head or a restart
4545 used to file nothing, and the next round filed only what it still lacked, so
4646 the check's list stayed `[]` or partial. The list now fills as the round goes,
4747 and is complete when `…/looks` lists every page.
48
49 ## A stylesheet change is approved beside its looks
50
51 A pull that changes a stylesheet source (`src/web/style/`) cannot be approved
52 until its preview has a home-page look at phone and desktop width taken at its
53 head (`missingHomeLooks` in `src/looks/style_looks.ts`; `submitReview` answers
54 `looks_required`, in the same words on the page, API, MCP and terminal). Main's
55 picture of the page sits beside them on the pull page, so the reviewer sees
56 before and after. A comment or a request for changes is never held. A
57 repository with no app has no looks to wait for.
58
59 If the preview never takes a home look (a failed boot, say), the approval
60 stays held. Only looks the forge took count, so one attached by hand does not
61 clear it. Fix the boot and push: the new head gets its looks. There is no
62 override: a change that cannot be previewed is sent back to be fixed. A repository with no app is never
63 held.

Non-blocking: "A repository with no app is never held." is already said a few lines above (line 56). Drop this repeat and rewrap the paragraph.

src/actions/pulls.ts +33 −1
@@ -67,6 +67,10 @@ import {
6767 } from "../core/reviews.ts";
6868 import { isProposed } from "../core/pull_trust.ts";
6969 import { approveGuest } from "../origins/guest.ts";
70 import { previewEnv } from "../apps/data.ts";
71 import { getAppByRepo } from "../core/apps.ts";
72 import { looksForEnv } from "../core/looks.ts";
73 import { missingHomeLooks, touchesStylesheet } from "../looks/style_looks.ts";
7074 import { grantRun } from "../core/run_grant.ts";
7175 import { commitIdentity } from "../core/commit_identity.ts";
7276 import { getUserById, getUserByName, type User } from "../core/users.ts";
@@ -681,7 +685,9 @@ export type SubmitReviewActionError =
681685 | "empty_body"
682686 /** A line comment's anchor is not a row of the pull's current diff. */
683687 | "bad_anchor"
684 | "too_many_lines";
688 | "too_many_lines"
689 /** A stylesheet change is approved only beside the home page's looks at its head. */
690 | "looks_required";
685691
686692 /** The most line comments one review may carry. */
687693 export const MAX_REVIEW_LINES = 50;
@@ -749,6 +755,10 @@ export async function submitReview(
749755
750756 const through = writeThroughFor(app, user, repo);
751757 if (!through.ok) return through;
758 if (through.value === undefined && input.state === "approved") {
759 const unseen = await styleLooksMissing(app, repo, pull, status.headOid);
760 if (unseen) return err("looks_required");
761 }
752762 if (through.value !== undefined) {
753763 const sent = await through.value.provider.submitReview(
754764 through.value.fullName,
@@ -864,6 +874,28 @@ export async function submitReview(
864874 return recorded;
865875 }
866876
877 /**
878 * Whether the pull changes a stylesheet source and its app's preview has no
879 * home-page look at phone and desktop width for this head: such a change is
880 * not approved unseen. A repository with no app has no looks to wait for.
881 */
882 async function styleLooksMissing(
883 app: App,
884 repo: Repo,
885 pull: Pull,
886 headOid: string,
887 ): Promise<boolean> {
888 const record = getAppByRepo(app.db, repo.id);
889 if (!record) return false;
890 const { diff } = await pullDiff(
891 await pullDir(app.db, app.reposRoot, repo, pull),
892 pull,
893 );
894 if (!touchesStylesheet(diff.files.map((f) => f.path))) return false;
895 const taken = looksForEnv(app.db, record.id, previewEnv(pull.number));
896 return missingHomeLooks(taken, headOid).length > 0;
897 }
898
867899 export type ThreadActionError = AccessError;
868900
869901 /**
src/actions/review_words.ts +3
@@ -10,4 +10,7 @@ export const reviewWords = {
1010 tooLong: "That review is too long (64 KB at most).",
1111 /** `forbidden`: a reader cannot pass a verdict that gates a merge. */
1212 forbidden: "A verdict that gates a merge needs write access.",
13 /** `looks_required`: a stylesheet change is approved beside the home page's looks. */
14 looksRequired:
15 "This pull changes a stylesheet: wait for its preview's phone and desktop looks of the home page (beside main's on the pull page), then approve.",
1316 } as const;
src/http/api.ts +3
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { reviewWords } from "../actions/review_words.ts";
outdated

Use the same path style as the neighbouring imports: ./../actions/review_words.ts should be ../actions/review_words.ts.

1112 import { growth, MAX_DAYS as MAX_GROWTH_DAYS } from "../core/growth.ts";
1213 import { PAUSE_REFUSAL, pausedUntil } from "../core/planner_pause.ts";
1314 import { teamHoldRefusal } from "../core/teams_words.ts";
@@ -3172,6 +3173,8 @@ export function apiRoutes(router: Router<Handler>): void {
31723173 422,
31733174 "you cannot approve your own pull request",
31743175 );
3176 if (submitted.error === "looks_required")
3177 return apiError("looks_required", 422, reviewWords.looksRequired);
31753178 if (submitted.error === "bad_anchor")
31763179 return apiError(
31773180 "bad_anchor",
src/http/mcp/tools/review.ts +3 −1
@@ -53,7 +53,9 @@ export const tool: Tool = {
5353 ? reviewWords.forbidden
5454 : error === "body_too_long"
5555 ? reviewWords.tooLong
56 : "That review could not be recorded."),
56 : error === "looks_required"
57 ? reviewWords.looksRequired
58 : "That review could not be recorded."),
5759 );
5860 }
5961 return good({
src/looks/style_looks.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * The looks a stylesheet change must carry before it is approved (#11831
3 * item 5): the home page at phone and desktop width, taken at the pull's head.
4 * Main's picture of the same page sits beside them on the pull page
5 * (`pairs.ts`), so the reviewer sees before and after. Plain data in, plain
6 * data out; it reads nothing.
7 */
8
9 import { DEFAULT_PAGE, type Look, type LookKind } from "../core/looks.ts";
10
11 /** Where the forge's stylesheet sources live. */
12 const STYLE_ROOT = "src/web/style/";
13
14 const WIDTHS: readonly LookKind[] = ["phone", "desktop"];
15
16 /** Whether any changed path is a stylesheet source. */
17 export const touchesStylesheet = (paths: readonly string[]): boolean =>
18 paths.some((p) => p.startsWith(STYLE_ROOT));
19
20 /**
21 * The widths of the home page the head has no picture of: `looks` are the
22 * pull preview's forge-taken looks. A look that failed, or was taken at an
23 * earlier head, does not count.
24 */
25 export function missingHomeLooks(
26 looks: readonly Look[],
27 headOid: string,
28 ): LookKind[] {
29 return WIDTHS.filter(
30 (kind) =>
31 !looks.some(
32 (l) =>
33 l.page === DEFAULT_PAGE &&
34 l.kind === kind &&
35 l.theme === "light" &&
36 l.error === null &&
37 l.attachedBy === null &&
38 l.commitOid === headOid,
39 ),
40 );
41 }
src/tui/screens/common.ts +1
@@ -999,6 +999,7 @@ const MESSAGES: Record<string, string> = {
999999 review_required: "this needs an approval of its current head first",
10001000 self_review: "you cannot approve your own change",
10011001 invalid_state: "that is not a review verdict",
1002 looks_required: "a stylesheet change waits for its home-page looks at phone and desktop width",
10021003 // The fork flow. Each of these leaves the world in a different place, so
10031004 // each keeps its own sentence.
10041005 fork_timeout: "the origin took the fork and it never appeared — try again",
src/web/pages/pull.ts +5 −3
@@ -1456,9 +1456,11 @@ export async function pullReviewStateSubmit(ctx: Ctx): Promise<Response> {
14561456 ? `${reviewWords.forbidden} You can still leave a comment.`
14571457 : error === "body_too_long"
14581458 ? reviewWords.tooLong
1459 : error === "invalid_state"
1460 ? "That is not a verdict this forge knows."
1461 : "That review could not be recorded."),
1459 : error === "looks_required"

The arm after this one (invalid_state) lost its indentation; format the ternary consistently.

1460 ? reviewWords.looksRequired
1461 : error === "invalid_state"
1462 ? "That is not a verdict this forge knows."
1463 : "That review could not be recorded."),
14621464 });
14631465 }
14641466 return flashTo(ctx, `${dest}#conversation`, {
test/style-looks-gate.test.ts added +116
@@ -0,0 +1,116 @@
1 /** A stylesheet change is approved only beside the home page's phone and desktop looks at its head (#11831 item 5). */

Add tests for the API 422 looks_required and the MCP review tool message; those branches have none.

2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull, submitReview } from "../src/actions/pulls.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { previewEnv } from "../src/apps/data.ts";
10 import { createApp as insertApp } from "../src/core/apps.ts";
11 import { recordLook, type Look } from "../src/core/looks.ts";
12 import { getPull, pullDir, pullStatus } from "../src/core/pulls.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { serve } from "../src/http/server.ts";
16 import { reviewWords } from "../src/actions/review_words.ts";
17 import { missingHomeLooks, touchesStylesheet } from "../src/looks/style_looks.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { promote } from "./helpers/members.ts";
20
21 quietLogs();
22
23 let dataDir: string;
24 let app: App;
25
26 beforeAll(async () => {
27 dataDir = await mkdtemp(join(tmpdir(), "forge-style-looks-"));
28 app = createApp({ dataDir, port: 0 });
29 });
30 afterAll(async () => {
31 app.close();
32 await rm(dataDir, { recursive: true, force: true });
33 });
34
35 test("only a stylesheet source path counts", () => {
36 expect(touchesStylesheet(["README.md", "src/web/style/components.ts"])).toBe(true);
37 expect(touchesStylesheet(["src/web/pages/home.ts", "src/web/stylish.ts"])).toBe(false);
38 });
39
40 test("missing widths are those with no light, forge-taken home look at the head", () => {
41 const look = (over: Partial<Look>): Look =>
42 ({ page: "/", kind: "phone", theme: "light", error: null, attachedBy: null, commitOid: "h", ...over }) as Look;
43 expect(missingHomeLooks([], "h")).toEqual(["phone", "desktop"]);
44 expect(missingHomeLooks([look({}), look({ kind: "desktop" })], "h")).toEqual([]);
45 expect(missingHomeLooks([look({}), look({ kind: "desktop", commitOid: "old" })], "h")).toEqual(["desktop"]);
46 expect(missingHomeLooks([look({ error: "x" }), look({ kind: "desktop", page: "/a" })], "h")).toEqual(["phone", "desktop"]);
47 });
48
49 async function pullChanging(path: string, name: string) {
50 const owner = await createUser(app.db, {
51 origin: "seed", trusted: true, username: `own${name}`, email: `own${name}@forge.local`,
52 password: "a-good-passphrase", adminIfFirst: true,
53 });
54 const author = await createUser(app.db, {
55 origin: "seed", trusted: true, username: `au${name}`, email: `au${name}@forge.local`,
56 password: "a-good-passphrase",
57 });
58 if (!owner.ok || !author.ok) throw new Error("users");
59 promote(app.db, owner.value, author.value);
60 const r = await createRepo(app, owner.value, { name, visibility: "public" });
61 if (!r.ok) throw new Error(String(r.error));
62 await commitToBranch(app, owner.value, r.value, { message: "seed", files: [{ path: "README.md", content: "# g\n" }] });
63 await commitToBranch(app, owner.value, r.value, { branch: "one", base: "main", message: "one", files: [{ path, content: "x\n" }] });
64 const o = await openPull(app, owner.value, r.value, { title: "One", body: "", baseBranch: "main", headBranch: "one" });
65 if (!o.ok) throw new Error(String(o.error));
66 app.db.query("UPDATE issue SET author_id = ? WHERE id = ?").run(author.value.id, o.value.id);
67 const pull = getPull(app.db, r.value.id, o.value.number)!;
68 const a = insertApp(app.db, { repoId: r.value.id, slug: name, access: "public" });
69 if (!a.ok) throw new Error(a.error);
70 const head = (await pullStatus(await pullDir(app.db, app.reposRoot, r.value, pull), pull)).headOid;
71 return { owner: owner.value, repo: r.value, pull, appId: a.value.id, head };
72 }
73
74 test("approving a stylesheet change waits for its home looks; any other change does not", async () => {
75 const css = await pullChanging("src/web/style/x.ts", "css");
76 const refused = await submitReview(app, css.owner, css.repo, css.pull, { state: "approved" });
77 expect(refused.ok ? "ok" : refused.error).toBe("looks_required");
78 // A comment and a request for changes are not an approval.
79 expect((await submitReview(app, css.owner, css.repo, css.pull, { state: "changes_requested", body: "x" })).ok).toBe(true);
80 for (const kind of ["phone", "desktop"] as const)
81 recordLook(app.db, { appId: css.appId, env: previewEnv(css.pull.number), commitOid: css.head, path: "looks/x.png", takenAs: "nobody", kind });
82 const done = await submitReview(app, css.owner, css.repo, css.pull, { state: "approved" });
83 expect(done.ok).toBe(true);
84
85 const plain = await pullChanging("notes.md", "plain");
86 expect((await submitReview(app, plain.owner, plain.repo, plain.pull, { state: "approved" })).ok).toBe(true);
87 });
88
89 test("the API answers 422 looks_required and the MCP review tool says why", async () => {
90 const css = await pullChanging("src/web/style/y.ts", "cssdoors");
91 const servers = await serve(app);
92 try {
93 const token = createToken(app.db, css.owner.id, "t", "admin").token;
94 const headers = { "content-type": "application/json", authorization: `Bearer ${token}` };
95 const api = await fetch(
96 `${servers.url}/api/v1/repos/${css.repo.ownerName}/${css.repo.name}/pulls/${css.pull.number}/review-states`,
97 { method: "POST", headers, body: JSON.stringify({ state: "approved" }) },
98 );
99 expect(api.status).toBe(422);
100 expect(((await api.json()) as { error: string }).error).toBe("looks_required");
101
102 const mcp = await fetch(`${servers.url}/mcp`, {
103 method: "POST",
104 headers: { ...headers, accept: "application/json, text/event-stream" },
105 body: JSON.stringify({
106 jsonrpc: "2.0", id: 1, method: "tools/call",
107 params: { name: "review", arguments: { repo: `${css.repo.ownerName}/${css.repo.name}`, number: css.pull.number, verdict: "approve" } },
108 }),
109 });
110 const body = (await mcp.json()) as { result: { isError: boolean; content: { text: string }[] } };
111 expect(body.result.isError).toBe(true);
112 expect(body.result.content[0]!.text).toContain(reviewWords.looksRequired);
113 } finally {
114 servers.stop();
115 }
116 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs