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

The OG card for a pull says its check verdict and who shipped it #2246

Merged opened this

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

Merged

scout/forge:scout/2047-the-og-card-for-a-pull-says-its-check-ve → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/2047-the-og-card-for-a-pull-says-its-check-ve was merged into main as b27e2a4.

Review

Sign in to review this change.
3 files changed +109 −4
src/core/merges.ts added +17
@@ -0,0 +1,17 @@
1 /**
2 * Who landed a pull: the user recorded in `pull.merged_by` when it merged.
3 * Kept apart from `pulls.ts`, whose getters do not carry the column.
4 */
5
6 import type { Database } from "bun:sqlite";
7
8 /** The username of whoever merged the pull, or null when unmerged or unrecorded. */
9 export function mergerOf(db: Database, pullId: string): string | null {
10 const row = db
11 .query<{ username: string }, [string]>(
12 `SELECT user.username FROM pull JOIN user ON user.id = pull.merged_by
13 WHERE pull.issue_id = ?1`,
14 )
15 .get(pullId);
16 return row?.username ?? null;
17 }
src/web/pages/og.ts +38 −3
@@ -24,7 +24,7 @@ import { getRepoRun } from "../../actions/runs.ts";
2424 import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts";
2525 import { audit } from "../../contract/audit.ts";
2626 import { latestProbes } from "../../contract/probes.ts";
27 import { listActiveRuns } from "../../core/runs.ts";
27 import { checkSummary, listActiveRuns } from "../../core/runs.ts";
2828 import {
2929 canRead,
3030 countsForRepos,
@@ -35,6 +35,7 @@ import {
3535 } from "../../core/repos.ts";
3636 import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts";
3737 import { getIssue, getIssueById, type Issue } from "../../core/issues.ts";
38 import { mergerOf } from "../../core/merges.ts";
3839 import { getPull, type Pull } from "../../core/pulls.ts";
3940 import { mayView, mayViewId, sealedScope } from "../../core/sealed.ts";
4041 import { isSafeName } from "../../git/guard.ts";
@@ -124,6 +125,40 @@ function threePart(
124125 return repo && { repo, rest };
125126 }
126127
128 type Verdict = "green" | "red" | "running" | "cancelled" | "none";
129
130 /**
131 * The check verdict a pull page shows, in a word. Read at the head recorded
132 * on the pull, not the live git head the page resolves: the card does no git
133 * work, so it can briefly disagree with the page after a push.
134 */
135 function verdict(ctx: Ctx, pull: Pull): Verdict {
136 const { state } = checkSummary(ctx.app.db, pull.id, pull.headOid);
137 if (state === "ok") return "green";
138 if (state === "failed") return "red";
139 if (state === "queued" || state === "running") return "running";

Only failed, queued, running and ok are mapped. Any other terminal state (cancelled, skipped) reads as none, which is misleading. Map it or test it.

140 // A check that was stopped is not the absence of one.
141 if (state === "cancelled") return "cancelled";
142 return "none";

Nit: the doc comment owns up to the head mismatch after a push. That is the right trade for a card that does no git work. Keep it.

143 }
144
145 function pullInput(ctx: Ctx, repo: Repo, pull: Pull): CardInput {
146 const merged = pull.mergedAt !== null;
147 const mergedBy = merged ? mergerOf(ctx.app.db, pull.id) : null;
148 return {
149 kind: "pull",
150 title: pull.title,
151 lines: [
152 `${repo.ownerName}/${repo.name}`,
153 `by ${pull.originAuthor ?? pull.authorName}`,
154 ...(pull.mergedAt
155 ? [`merged ${pull.mergedAt.slice(0, 10)}${mergedBy ? ` by ${mergedBy}` : ""}`]
156 : []),
outdated

pull.state plus the merged suffix may print merged twice. Keep one.

157 ],
158 stat: `#${pull.number} · ${pull.state}${merged ? " · merged" : ""} · check ${verdict(ctx, pull)}`,
159 };
160 }
161
127162 function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined {
128163 const at = threePart(ctx, ref);
129164 if (!at || !/^[1-9][0-9]{0,8}$/.test(at.rest)) return undefined;
@@ -133,7 +168,7 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined
133168 kind === "pull" ? getPull(db, at.repo.id, n) : getIssue(db, at.repo.id, n);
134169 if (!item || item.kind !== kind || !mayView(db, at.repo, item, undefined))
135170 return undefined;
136 const merged = "mergedAt" in item && item.mergedAt !== null;
171 if (kind === "pull") return pullInput(ctx, at.repo, item as Pull);
137172 return {
138173 kind,
139174 title: item.title,
@@ -143,7 +178,7 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined
143178 ? [waiting(ctx, at.repo, item)]
144179 : []),
145180 ],
146 stat: `#${item.number} · ${item.state}${merged ? " · merged" : ""}`,
181 stat: `#${item.number} · ${item.state}`,
147182 };
148183 }
149184
test/og-route.test.ts +54 −1
@@ -8,7 +8,7 @@ import { createRepo } from "../src/actions/repos.ts";
88 import { createApp } from "../src/core/apps.ts";
99 import { addDependency } from "../src/core/dependencies.ts";
1010 import { getIssue, createIssue } from "../src/core/issues.ts";
11 import { createRun } from "../src/core/runs.ts";
11 import { cancelRun, claimRun, createRun, finishRun } from "../src/core/runs.ts";
1212 import { createUser, getUserByName } from "../src/core/users.ts";
1313 import { serve, type Servers } from "../src/http/server.ts";
1414
@@ -194,6 +194,59 @@ describe("og cards", () => {
194194 expect(open).not.toContain("merged");
195195 });
196196
197 test("a pull card says its check verdict, author and merge date", async () => {
198 const sh = getRepo(app.db, "alice", "shown")!.id;
199 const none = await (await get("/og/pull/alice~shown~2.svg")).text();
200 expect(none).toContain("check none");
201 expect(none).toContain("by alice");
202 const before = (await get("/og/pull/alice~shown~2.svg")).headers.get("etag");
203 const pull = getIssue(app.db, sh, 2)!;
204 const run = createRun(app.db, {
205 repoId: sh,
206 kind: "check",
207 issueId: pull.id,
208 commitOid: "",
209 });
210 expect(await (await get("/og/pull/alice~shown~2.svg")).text()).toContain("check running");
211 claimRun(app.db, run.id);
212 expect(await (await get("/og/pull/alice~shown~2.svg")).text()).toContain("check running");
213 finishRun(app.db, run.id, { state: "failed", summary: "red" });
214 const red = await get("/og/pull/alice~shown~2.svg");
215 expect(await red.text()).toContain("check red");
216 expect(red.headers.get("etag")).not.toBe(before);
217 // A newer run at the same head supersedes the red one.
218 const again = createRun(app.db, { repoId: sh, kind: "check", issueId: pull.id, commitOid: "" });
219 claimRun(app.db, again.id);
220 finishRun(app.db, again.id, { state: "ok", summary: "green" });
221 const green = await get("/og/pull/alice~shown~2.svg");
222 expect(await green.text()).toContain("check green");
223 expect(green.headers.get("etag")).not.toBe(red.headers.get("etag"));
224 // A stopped check is not the absence of one.
225 const stopped = createRun(app.db, { repoId: sh, kind: "check", issueId: pull.id, commitOid: "" });
226 cancelRun(app.db, stopped.id);
227 expect(await (await get("/og/pull/alice~shown~2.svg")).text()).toContain("check cancelled");
228 const merged = await (await get("/og/pull/alice~shown~4.svg")).text();
229 expect(merged).toContain("merged 2026-01-01<");
230 expect(merged).toContain("#4 · closed · merged · check none");
231 const bob = await createUser(app.db, {
232 trusted: true,
233 username: "bob",
outdated

No assertion for check green. Add a run finished with state ok and assert the card says so.

234 email: "[email protected]",
235 password: "bob-passphrase",
236 });
237 if (!bob.ok) throw new Error(bob.error);
238 app.db
239 .query(
240 `UPDATE pull SET merged_by = ?1 WHERE issue_id =
241 (SELECT id FROM issue WHERE repo_id = ?2 AND number = 4)`,
242 )
243 .run(bob.value.id, sh);
244 const byBob = await (await get("/og/pull/alice~shown~4.svg")).text();
245 expect(byBob).toContain("merged 2026-01-01 by bob");
246 expect(byBob).toContain("by alice");
247 expect(merged).toContain("check none");
248 });
249
197250 test("private repos, sealed reports and unreadable runs are 404", async () => {
198251 for (const path of [
199252 "issue/alice~hidden~1",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs