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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2047-the-og-card-for-a-pull-says-its-check-ve → main 3 commits fork 4 threads · 4 unresolved
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"; | ||
| 24 | 24 | import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts"; |
| 25 | 25 | import { audit } from "../../contract/audit.ts"; |
| 26 | 26 | import { latestProbes } from "../../contract/probes.ts"; |
| 27 | import { listActiveRuns } from "../../core/runs.ts"; | |
| 27 | import { checkSummary, listActiveRuns } from "../../core/runs.ts"; | |
| 28 | 28 | import { |
| 29 | 29 | canRead, |
| 30 | 30 | countsForRepos, |
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | } from "../../core/repos.ts"; |
| 36 | 36 | import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts"; |
| 37 | 37 | import { getIssue, getIssueById, type Issue } from "../../core/issues.ts"; |
| 38 | import { mergerOf } from "../../core/merges.ts"; | |
| 38 | 39 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| 39 | 40 | import { mayView, mayViewId, sealedScope } from "../../core/sealed.ts"; |
| 40 | 41 | import { isSafeName } from "../../git/guard.ts"; |
| @@ -124,6 +125,40 @@ function threePart( | ||
| 124 | 125 | return repo && { repo, rest }; |
| 125 | 126 | } |
| 126 | 127 | |
| 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"; | |
| 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
|
||
| 157 | ], | |
| 158 | stat: `#${pull.number} · ${pull.state}${merged ? " · merged" : ""} · check ${verdict(ctx, pull)}`, | |
| 159 | }; | |
| 160 | } | |
| 161 | ||
| 127 | 162 | function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined { |
| 128 | 163 | const at = threePart(ctx, ref); |
| 129 | 164 | 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 | ||
| 133 | 168 | kind === "pull" ? getPull(db, at.repo.id, n) : getIssue(db, at.repo.id, n); |
| 134 | 169 | if (!item || item.kind !== kind || !mayView(db, at.repo, item, undefined)) |
| 135 | 170 | return undefined; |
| 136 | const merged = "mergedAt" in item && item.mergedAt !== null; | |
| 171 | if (kind === "pull") return pullInput(ctx, at.repo, item as Pull); | |
| 137 | 172 | return { |
| 138 | 173 | kind, |
| 139 | 174 | title: item.title, |
| @@ -143,7 +178,7 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined | ||
| 143 | 178 | ? [waiting(ctx, at.repo, item)] |
| 144 | 179 | : []), |
| 145 | 180 | ], |
| 146 | stat: `#${item.number} · ${item.state}${merged ? " · merged" : ""}`, | |
| 181 | stat: `#${item.number} · ${item.state}`, | |
| 147 | 182 | }; |
| 148 | 183 | } |
| 149 | 184 |
test/og-route.test.ts +54 −1
| @@ -8,7 +8,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 8 | 8 | import { createApp } from "../src/core/apps.ts"; |
| 9 | 9 | import { addDependency } from "../src/core/dependencies.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 13 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 14 | |
| @@ -194,6 +194,59 @@ describe("og cards", () => { | ||
| 194 | 194 | expect(open).not.toContain("merged"); |
| 195 | 195 | }); |
| 196 | 196 | |
| 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 |
||
| 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 | ||
| 197 | 250 | test("private repos, sealed reports and unreadable runs are 404", async () => { |
| 198 | 251 | for (const path of [ |
| 199 | 252 | "issue/alice~hidden~1", |
new above
offline
Only
failed,queued,runningandokare mapped. Any other terminal state (cancelled, skipped) reads asnone, which is misleading. Map it or test it.