Public: anyone can read this; the crew can change it.
A commit's check in the API says how many screens tall each look is and which phone look scrolls sideways #9490
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9487-a-commit-s-check-in-the-api-says-how-man → main 2 commits fork
4 files
changed +154
−2
docs/features/vitals-screens.md +2
| @@ -6,3 +6,5 @@ A check's per-look vitals say how many screens tall each look is (`scrollHeight | ||
| 6 | 6 | - Terminal: the checks line carries the same, `vitals home: LCP …, 2.4 screens; phone: …, 1.1 screens · phone /x scrolls sideways` (`vitalsLine`, `src/checks/progress.ts`). |
| 7 | 7 | |
| 8 | 8 | Report-only: neither can turn a check red. Skipped looks show no screens. |
| 9 | ||
| 10 | API: `GET /api/v1/repos/:owner/:name/commits/:oid/check` and `GET /api/v1/repos/:owner/:name/pulls/:number/checks` carry `looks: [{look, screens, sideways}]`, from the same file as the run page (a run with no file of its own falls back to the commit's filed looks). `screens` is the one-decimal string (`""` without a size), `sideways` is a boolean; `[]` when there are none or the reader may not read runs. |
src/actions/runs.ts +24
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { commitVerdicts, isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts"; |
| 12 | import { readLooks } from "../checks/looks.ts"; | |
| 13 | import { vitalsRows } from "../checks/summary.ts"; | |
| 12 | 14 | import { hasCommit, isAncestor } from "../git/local.ts"; |
| 13 | 15 | import { repoDir } from "../git/repos.ts"; |
| 14 | 16 | import { mayViewId } from "../core/sealed.ts"; |
| @@ -241,6 +243,28 @@ export async function checkOfCommit( | ||
| 241 | 243 | ); |
| 242 | 244 | } |
| 243 | 245 | |
| 246 | /** One look of a check as the API words it: how many screens tall, and whether a phone look scrolls sideways. */ | |
| 247 | export interface LookFigures { | |
| 248 | readonly look: string; | |
| 249 | readonly screens: string; | |
| 250 | readonly sideways: boolean; | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * The looks of the run that checked a commit, from the same file the run page | |
| 255 | * reads; `[]` for someone who may not read runs or when none were filed. | |
| 256 | */ | |
| 257 | export function looksOfCheck( | |
| 258 | app: App, | |
| 259 | actor: User | undefined, | |
| 260 | repo: Repo, | |
| 261 | run: { readonly id: string; readonly commitOid: string }, | |
| 262 | ): LookFigures[] { | |
| 263 | if (!mayReadRuns(app, actor, repo)) return []; | |
| 264 | const looks = readLooks(app.config.dataDir, { id: run.id, repoId: repo.id, commitOid: run.commitOid }); | |
| 265 | return vitalsRows(looks).map((g) => ({ look: g.look, screens: g.screens, sideways: g.sideways })); | |
| 266 | } | |
| 267 | ||
| 244 | 268 | /** The own green or red verdicts of a page of commits, for whoever may read the runs; empty otherwise. */ |
| 245 | 269 | export function verdictsOfCommits( |
| 246 | 270 | app: App, |
src/http/api.ts +15 −2
| @@ -223,7 +223,7 @@ import { | ||
| 223 | 223 | } from "../actions/crew.ts"; |
| 224 | 224 | import { assignIssue, editIssue, TAKEN_UP } from "../actions/issues.ts"; |
| 225 | 225 | import { requireReader } from "../actions/access.ts"; |
| 226 | import { mayReadRuns, runsViewKey, steerRepoRun } from "../actions/runs.ts"; | |
| 226 | import { looksOfCheck, mayReadRuns, runsViewKey, steerRepoRun } from "../actions/runs.ts"; | |
| 227 | 227 | import { openPlan, parsePlan, proposePlan } from "../actions/plan.ts"; |
| 228 | 228 | import { repoGraph } from "../core/dependencies.ts"; |
| 229 | 229 | import { mapHeldFor } from "../crew/held.ts"; |
| @@ -1877,7 +1877,17 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1877 | 1877 | ); |
| 1878 | 1878 | if (!found.ok) return notFound(); |
| 1879 | 1879 | return timePhase(ctx.request, "render", () => |
| 1880 | json(found.value, 200, { "cache-control": "no-store" }), | |
| 1880 | json( | |
| 1881 | { | |
| 1882 | ...found.value, | |
| 1883 | looks: looksOfCheck(ctx.app, ctx.auth.user, repo, { | |
| 1884 | id: found.value.runId ?? "", | |
| 1885 | commitOid: found.value.checkedCommit, | |
| 1886 | }), | |
| 1887 | }, | |
| 1888 | 200, | |
| 1889 | { "cache-control": "no-store" }, | |
| 1890 | ), | |
| 1881 | 1891 | ); |
| 1882 | 1892 | }); |
| 1883 | 1893 | |
| @@ -4033,6 +4043,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4033 | 4043 | state: latest?.state ?? "none", |
| 4034 | 4044 | required: found.repo.requireChecks, |
| 4035 | 4045 | run: latest ?? null, |
| 4046 | looks: latest | |
| 4047 | ? looksOfCheck(ctx.app, ctx.auth.user, found.repo, { id: latest.id, commitOid: latest.commitOid }) | |
| 4048 | : [], | |
| 4036 | 4049 | runs, |
| 4037 | 4050 | }), |
| 4038 | 4051 | ); |
test/check-looks-api.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** The check's looks in the API: screens and sideways, without the run page (#9487). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { openPull } from "../src/actions/pulls.ts"; | |
| 9 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { looksPath } from "../src/checks/looks.ts"; | |
| 11 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let cookie = ""; | |
| 23 | let repoId = ""; | |
| 24 | let pullId = ""; | |
| 25 | let number = 0; | |
| 26 | const cells = process.env["FORGE_CELLS"]; | |
| 27 | const OID = "b".repeat(40); | |
| 28 | ||
| 29 | const get = async (path: string) => { | |
| 30 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 31 | return (await res.json()) as any; | |
| 32 | }; | |
| 33 | ||
| 34 | const writeLooks = (runId: string, size: object) => { | |
| 35 | const path = looksPath(dataDir, runId); | |
| 36 | mkdirSync(join(path, ".."), { recursive: true }); | |
| 37 | writeFileSync( | |
| 38 | path, | |
| 39 | JSON.stringify([ | |
| 40 | { look: "phone /x", entries: {}, size }, | |
| 41 | { look: "desktop", entries: {} }, | |
| 42 | ]), | |
| 43 | ); | |
| 44 | }; | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | process.env["FORGE_CELLS"] = "off"; | |
| 48 | dataDir = await mkdtemp(join(tmpdir(), "forge-check-looks-api-")); | |
| 49 | app = createApp({ dataDir, port: 0 }); | |
| 50 | servers = await serve(app); | |
| 51 | const made = await createUser(app.db, { | |
| 52 | origin: "seed", | |
| 53 | trusted: true, | |
| 54 | username: "alice", | |
| 55 | email: "[email protected]", | |
| 56 | password: "a-good-passphrase", | |
| 57 | adminIfFirst: true, | |
| 58 | }); | |
| 59 | if (!made.ok) throw new Error(String(made.error)); | |
| 60 | const alice = made.value; | |
| 61 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 62 | const repo = await createRepo(app, alice, { name: "r", visibility: "public" }); | |
| 63 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 64 | repoId = repo.value.id; | |
| 65 | for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) { | |
| 66 | const done = await commitToBranch(app, alice, repo.value, { | |
| 67 | ...(branch === undefined ? {} : { branch }), | |
| 68 | message: "change", | |
| 69 | files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }], | |
| 70 | }); | |
| 71 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 72 | } | |
| 73 | const pull = await openPull(app, alice, repo.value, { title: "t", body: "", baseBranch: "main", headBranch: "topic" }); | |
| 74 | if (!pull.ok) throw new Error(JSON.stringify(pull.error)); | |
| 75 | number = pull.value.number; | |
| 76 | pullId = pull.value.id; | |
| 77 | }); | |
| 78 | ||
| 79 | afterAll(async () => { | |
| 80 | await servers.stop(); | |
| 81 | app.close(); | |
| 82 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 83 | else process.env["FORGE_CELLS"] = cells; | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the commit's check carries screens and sideways; [] without a file", async () => { | |
| 88 | const run = createRun(app.db, { repoId, kind: "check", commitOid: OID, env: "main", branch: "main" }); | |
| 89 | claimRun(app.db, run.id); | |
| 90 | finishRun(app.db, run.id, { state: "failed", summary: "x" }); | |
| 91 | expect((await get(`/api/v1/repos/alice/r/commits/${OID}/check`)).looks).toEqual([]); | |
| 92 | writeLooks(run.id, { scrollWidth: 500, scrollHeight: 2000, innerWidth: 390, innerHeight: 800 }); | |
| 93 | const body = await get(`/api/v1/repos/alice/r/commits/${OID}/check`); | |
| 94 | expect(body.runId).toBe(run.id); | |
| 95 | expect(body.looks).toEqual([ | |
| 96 | { look: "phone /x", screens: "2.5", sideways: true }, | |
| 97 | { look: "desktop", screens: "", sideways: false }, | |
| 98 | ]); | |
| 99 | }); | |
| 100 | ||
| 101 | test("the pull's checks carry the same looks beside run", async () => { | |
| 102 | const head = (await get(`/api/v1/repos/alice/r/pulls/${number}/checks`)).head as string; | |
| 103 | expect((await get(`/api/v1/repos/alice/r/pulls/${number}/checks`)).looks).toEqual([]); | |
| 104 | const run = createRun(app.db, { repoId, kind: "check", issueId: pullId, commitOid: head, branch: "topic" }); | |
| 105 | expect((await get(`/api/v1/repos/alice/r/pulls/${number}/checks`)).looks).toEqual([]); | |
| 106 | writeLooks(run.id, { scrollWidth: 390, scrollHeight: 1000, innerWidth: 390, innerHeight: 800 }); | |
| 107 | const body = await get(`/api/v1/repos/alice/r/pulls/${number}/checks`); | |
| 108 | expect(body.run.id).toBe(run.id); | |
| 109 | expect(body.looks).toEqual([ | |
| 110 | { look: "phone /x", screens: "1.3", sideways: false }, | |
| 111 | { look: "desktop", screens: "", sideways: false }, | |
| 112 | ]); | |
| 113 | }); |