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

Merged opened this

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

Merged

scout/forge:scout/12165-a-pull-s-latest-check-reads-as-plain-tex → main 5 commits fork

  • Merged. scout/12165-a-pull-s-latest-check-reads-as-plain-tex was merged into main as 309b41e.

Review

Sign in to review this change.
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`;
116116 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
117117 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
118118 | `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 |
120120 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
121121 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 440 |
122122 | `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 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { checkText } from "./check_text.ts";
89 import { appSecretGrant, appSecretRevoke } from "../web/pages/app_secrets.ts";
910 import { apiRoot } from "./api_root.ts";
1011 import { isTokenScheme, tokenSecret } from "./token_header.ts";
@@ -1382,6 +1383,7 @@ export function buildRouter(): Router<Handler> {
13821383 router.add("GET", "/:owner/:name/changes/:number", movedTo("pulls", ":number"));
13831384 router.add("GET", "/:owner/:name/pulls/new", newPullPage);
13841385 router.add("POST", "/:owner/:name/pulls/new", newPullSubmit);
1386 router.add("GET", "/:owner/:name/pulls/:number/check.txt", checkText);
13851387 router.add("GET", "/:owner/:name/pulls/:number", pullPage);
13861388 // The change's Files step: the diff, the review threads and the merge
13871389 // 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>) =>
1616 claimed: false, lastActivityAt: null, running: null, reach: null, link: null, attention: null,
1717 stage: null, spendTodayUsd: null, ceilingUsd: null, size: "small", use: { kind: "shared" },
1818 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 },
1920 nextStep: { key: "claim", words: "claim", lastTellAt: null },
2021 ...over,
2122 }) as unknown as ClientRow;
test/pages-budget.test.ts +1 −1
@@ -44,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
4444 };
4545
4646 /** 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)
4848
4949 /** Streams and live fragments: no page. */
5050 const NOT_PAGES = new Set([

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs