Public: anyone can read this; the crew can change it.
A pull's latest check reads as plain text at /<owner>/<repo>/pulls/<n>/check.txt for anyone who can read the pull #12226
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12165-a-pull-s-latest-check-reads-as-plain-tex → main 5 commits fork
6 files
changed +146
−2
docs/MCP.md +1 −1
| @@ -116,7 +116,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 116 | 116 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 117 | 117 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 118 | 118 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 282 | |
| 119 | | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 475 | | |
| 119 | | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 537 | | |
| 120 | 120 | | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 | |
| 121 | 121 | | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 440 | |
| 122 | 122 | | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 480 | |
src/http/check_text.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * GET /:owner/:name/pulls/:number/check.txt — a pull's latest check as plain | |
| 3 | * text, for a script or an agent. Line 1 is pending, green or red; line 2 is | |
| 4 | * `head: <oid>`; on red, a blank line and the last 80 lines of the log, but | |
| 5 | * only when the log door (`runLog`) would show that reader the log. | |
| 6 | */ | |
| 7 | ||
| 8 | import { runLog } from "../actions/runs.ts"; | |
| 9 | import { checkSummary } from "../core/runs.ts"; | |
| 10 | import { loadPull } from "./api.ts"; | |
| 11 | import type { Ctx } from "./context.ts"; | |
| 12 | import { securityFloor } from "./respond.ts"; | |
| 13 | ||
| 14 | export const TAIL_MAX = 80; | |
| 15 | ||
| 16 | export function checkText(ctx: Ctx): Response { | |
| 17 | const found = loadPull(ctx); | |
| 18 | if ("error" in found) return found.error; | |
| 19 | const { repo, pull } = found; | |
| 20 | const { state, run } = checkSummary(ctx.app.db, pull.id, pull.headOid); | |
| 21 | const word = state === "ok" ? "green" : state === "failed" || state === "cancelled" ? "red" : "pending"; | |
| 22 | let body = `${word}\nhead: ${pull.headOid}\n`; | |
| 23 | if (word === "red" && run) { | |
| 24 | const log = runLog(ctx.app, ctx.auth.user, repo, run.id, TAIL_MAX); | |
| 25 | if (log.ok && !log.value.logHidden && log.value.lines.length > 0) | |
| 26 | body += `\n${log.value.lines.slice(-TAIL_MAX).join("\n")}\n`; | |
| 27 | } | |
| 28 | return securityFloor( | |
| 29 | new Response(body, { | |
| 30 | headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }, | |
| 31 | }), | |
| 32 | ); | |
| 33 | } |
src/http/server.ts +2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * verdict. Auth is resolved once per request and handed to handlers as data. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { checkText } from "./check_text.ts"; | |
| 8 | 9 | import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts"; |
| 9 | 10 | import { apiRoot } from "./api_root.ts"; |
| 10 | 11 | import { isTokenScheme, tokenSecret } from "./token_header.ts"; |
| @@ -1382,6 +1383,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1382 | 1383 | router.add("GET", "/:owner/:name/changes/:number", movedTo("pulls", ":number")); |
| 1383 | 1384 | router.add("GET", "/:owner/:name/pulls/new", newPullPage); |
| 1384 | 1385 | router.add("POST", "/:owner/:name/pulls/new", newPullSubmit); |
| 1386 | router.add("GET", "/:owner/:name/pulls/:number/check.txt", checkText); | |
| 1385 | 1387 | router.add("GET", "/:owner/:name/pulls/:number", pullPage); |
| 1386 | 1388 | // The change's Files step: the diff, the review threads and the merge |
| 1387 | 1389 | // box, one tab under the change itself. |
test/contribute-check-text.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** A pull's latest check reads as plain text at /<owner>/<repo>/pulls/<n>/check.txt. */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { newApp } from "../src/actions/apps.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { appendRunLog, flushRunLogs } from "../src/runs/log.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | const SLOW = 60_000; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let repoId = ""; | |
| 22 | let owner = ""; | |
| 23 | let bob = ""; | |
| 24 | let seq = 0; | |
| 25 | ||
| 26 | const addPull = (head: string) => { | |
| 27 | const made = createIssue(app.db, { repoId, authorId: owner, title: `p-${head}`, kind: "pull" }); | |
| 28 | if (!made.ok) throw new Error(made.error); | |
| 29 | app.db | |
| 30 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', ?)") | |
| 31 | .run(made.value.id, `h-${head}`, head); | |
| 32 | return made.value; | |
| 33 | }; | |
| 34 | const run = (issueId: string, oid: string, state: string) => { | |
| 35 | const id = `zzzz-${String(++seq).padStart(6, "0")}`; | |
| 36 | app.db | |
| 37 | .query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES (?, ?, 'check', ?, ?, ?)") | |
| 38 | .run(id, repoId, issueId, oid, state); | |
| 39 | return id; | |
| 40 | }; | |
| 41 | const get = (n: number, cookie?: string) => | |
| 42 | fetch(`${servers.url}/alice/notes/pulls/${n}/check.txt`, cookie ? { headers: { cookie } } : {}); | |
| 43 | ||
| 44 | beforeAll(async () => { | |
| 45 | dataDir = await mkdtemp(join(tmpdir(), "forge-check-text-")); | |
| 46 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 47 | servers = await serve(app); | |
| 48 | const u = await createUser(app.db, { | |
| 49 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 50 | password: "a-good-passphrase", adminIfFirst: true, | |
| 51 | }); | |
| 52 | if (!u.ok) throw new Error(u.error); | |
| 53 | owner = u.value.id; | |
| 54 | const b = await createUser(app.db, { | |
| 55 | origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase", | |
| 56 | }); | |
| 57 | if (!b.ok) throw new Error(b.error); | |
| 58 | bob = `forge_session=${createSession(app.db, b.value.id).token}`; | |
| 59 | const made = await newApp(app, u.value, { name: "notes", description: "x", visibility: "public" }); | |
| 60 | if (!made.ok) throw new Error(String(made.error)); | |
| 61 | repoId = made.value.repo.id; | |
| 62 | }, 120_000); | |
| 63 | ||
| 64 | afterAll(async () => { | |
| 65 | servers.stop(); | |
| 66 | app.close(); | |
| 67 | await rm(dataDir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a green check reads green with its head", async () => { | |
| 71 | const p = addPull("g1"); | |
| 72 | run(p.id, "g1", "ok"); | |
| 73 | const res = await get(p.number); | |
| 74 | expect(res.status).toBe(200); | |
| 75 | expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); | |
| 76 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 77 | expect(await res.text()).toBe("green\nhead: g1\n"); | |
| 78 | }, SLOW); | |
| 79 | ||
| 80 | test("queued, running and no check all read pending", async () => { | |
| 81 | const none = addPull("n1"); | |
| 82 | const running = addPull("n2"); | |
| 83 | run(running.id, "n2", "running"); | |
| 84 | const queued = addPull("n3"); | |
| 85 | run(queued.id, "n3", "queued"); | |
| 86 | for (const [p, head] of [[none, "n1"], [running, "n2"], [queued, "n3"]] as const) | |
| 87 | expect(await (await get(p.number)).text()).toBe(`pending\nhead: ${head}\n`); | |
| 88 | }, SLOW); | |
| 89 | ||
| 90 | test("a red check carries the last 80 lines of its log, only for a reader the log door shows", async () => { | |
| 91 | const p = addPull("r1"); | |
| 92 | const id = run(p.id, "r1", "failed"); | |
| 93 | for (let i = 1; i <= 100; i++) appendRunLog(dataDir, id, `line ${i}`); | |
| 94 | flushRunLogs(); | |
| 95 | const text = await (await get(p.number, `forge_session=${createSession(app.db, owner).token}`)).text(); | |
| 96 | const [word, head, blank, ...tail] = text.split("\n"); | |
| 97 | expect([word, head, blank]).toEqual(["red", "head: r1", ""]); | |
| 98 | expect(tail.filter((l) => l !== "")).toHaveLength(80); | |
| 99 | expect(tail[0]).toBe("line 21"); | |
| 100 | expect(text.endsWith("line 100\n")).toBe(true); | |
| 101 | // A plain reader may see the pull but not its log: the verdict, no tail. | |
| 102 | expect(await (await get(p.number, bob)).text()).toBe("red\nhead: r1\n"); | |
| 103 | expect(await (await get(p.number)).text()).toBe("red\nhead: r1\n"); | |
| 104 | }, SLOW); | |
| 105 | ||
| 106 | test("an unknown pull is 404", async () => { | |
| 107 | expect((await get(9999)).status).toBe(404); | |
| 108 | }, SLOW); |
test/first-screen-line.test.ts +1
| @@ -16,6 +16,7 @@ const row = (over: Partial<ClientRow>) => | ||
| 16 | 16 | claimed: false, lastActivityAt: null, running: null, reach: null, link: null, attention: null, |
| 17 | 17 | stage: null, spendTodayUsd: null, ceilingUsd: null, size: "small", use: { kind: "shared" }, |
| 18 | 18 | disk: { own: false, path: "/d" }, lastHeartbeatAt: null, |
| 19 | placement: "cell", where: { placement: "cell", inProcess: false, says: "runs in its own sandbox", movable: false, held: false }, | |
| 19 | 20 | nextStep: { key: "claim", words: "claim", lastTellAt: null }, |
| 20 | 21 | ...over, |
| 21 | 22 | }) as unknown as ClientRow; |
test/pages-budget.test.ts +1 −1
| @@ -44,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = { | ||
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */ |
| 47 | const MAX_UNFIXTURED = 147; | |
| 47 | const MAX_UNFIXTURED = 148; // +1: /:owner/:name/pulls/:number/check.txt, plain text with no page to fixture (#12165) | |
| 48 | 48 | |
| 49 | 49 | /** Streams and live fragments: no page. */ |
| 50 | 50 | const NOT_PAGES = new Set([ |
new above
offline