Public: anyone can read this; the crew can change it.
Looks before/after for overlays at phone size (#11831 item 5) #11897
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11856-looks-before-after-for-overlays-at-phone → main 3 commits fork 4 threads · 4 unresolved
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 | ||
| 45 | 45 | used to file nothing, and the next round filed only what it still lacked, so |
| 46 | 46 | the check's list stayed `[]` or partial. The list now fills as the round goes, |
| 47 | 47 | 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. | |
src/actions/pulls.ts +33 −1
| @@ -67,6 +67,10 @@ import { | ||
| 67 | 67 | } from "../core/reviews.ts"; |
| 68 | 68 | import { isProposed } from "../core/pull_trust.ts"; |
| 69 | 69 | 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"; | |
| 70 | 74 | import { grantRun } from "../core/run_grant.ts"; |
| 71 | 75 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 72 | 76 | import { getUserById, getUserByName, type User } from "../core/users.ts"; |
| @@ -681,7 +685,9 @@ export type SubmitReviewActionError = | ||
| 681 | 685 | | "empty_body" |
| 682 | 686 | /** A line comment's anchor is not a row of the pull's current diff. */ |
| 683 | 687 | | "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"; | |
| 685 | 691 | |
| 686 | 692 | /** The most line comments one review may carry. */ |
| 687 | 693 | export const MAX_REVIEW_LINES = 50; |
| @@ -749,6 +755,10 @@ export async function submitReview( | ||
| 749 | 755 | |
| 750 | 756 | const through = writeThroughFor(app, user, repo); |
| 751 | 757 | 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 | } | |
| 752 | 762 | if (through.value !== undefined) { |
| 753 | 763 | const sent = await through.value.provider.submitReview( |
| 754 | 764 | through.value.fullName, |
| @@ -864,6 +874,28 @@ export async function submitReview( | ||
| 864 | 874 | return recorded; |
| 865 | 875 | } |
| 866 | 876 | |
| 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 | ||
| 867 | 899 | export type ThreadActionError = AccessError; |
| 868 | 900 | |
| 869 | 901 | /** |
src/actions/review_words.ts +3
| @@ -10,4 +10,7 @@ export const reviewWords = { | ||
| 10 | 10 | tooLong: "That review is too long (64 KB at most).", |
| 11 | 11 | /** `forbidden`: a reader cannot pass a verdict that gates a merge. */ |
| 12 | 12 | 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.", | |
| 13 | 16 | } as const; |
src/http/api.ts +3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { reviewWords } from "../actions/review_words.ts"; | |
outdated
Use the same path style as the neighbouring imports: |
||
| 11 | 12 | import { growth, MAX_DAYS as MAX_GROWTH_DAYS } from "../core/growth.ts"; |
| 12 | 13 | import { PAUSE_REFUSAL, pausedUntil } from "../core/planner_pause.ts"; |
| 13 | 14 | import { teamHoldRefusal } from "../core/teams_words.ts"; |
| @@ -3172,6 +3173,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3172 | 3173 | 422, |
| 3173 | 3174 | "you cannot approve your own pull request", |
| 3174 | 3175 | ); |
| 3176 | if (submitted.error === "looks_required") | |
| 3177 | return apiError("looks_required", 422, reviewWords.looksRequired); | |
| 3175 | 3178 | if (submitted.error === "bad_anchor") |
| 3176 | 3179 | return apiError( |
| 3177 | 3180 | "bad_anchor", |
src/http/mcp/tools/review.ts +3 −1
| @@ -53,7 +53,9 @@ export const tool: Tool = { | ||
| 53 | 53 | ? reviewWords.forbidden |
| 54 | 54 | : error === "body_too_long" |
| 55 | 55 | ? reviewWords.tooLong |
| 56 | : "That review could not be recorded."), | |
| 56 | : error === "looks_required" | |
| 57 | ? reviewWords.looksRequired | |
| 58 | : "That review could not be recorded."), | |
| 57 | 59 | ); |
| 58 | 60 | } |
| 59 | 61 | 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> = { | ||
| 999 | 999 | review_required: "this needs an approval of its current head first", |
| 1000 | 1000 | self_review: "you cannot approve your own change", |
| 1001 | 1001 | 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", | |
| 1002 | 1003 | // The fork flow. Each of these leaves the world in a different place, so |
| 1003 | 1004 | // each keeps its own sentence. |
| 1004 | 1005 | 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> { | ||
| 1456 | 1456 | ? `${reviewWords.forbidden} You can still leave a comment.` |
| 1457 | 1457 | : error === "body_too_long" |
| 1458 | 1458 | ? 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 ( |
||
| 1460 | ? reviewWords.looksRequired | |
| 1461 | : error === "invalid_state" | |
| 1462 | ? "That is not a verdict this forge knows." | |
| 1463 | : "That review could not be recorded."), | |
| 1462 | 1464 | }); |
| 1463 | 1465 | } |
| 1464 | 1466 | 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 |
||
| 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 | }); |
new above
offline
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.