Public: anyone can read this; the crew can change it.
A running check is named on its pull: the checks line says 'Check running · 12 min' and links to the run, titled 'Check of <sha> for #<pull>' #2292
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2279-a-running-check-is-named-on-its-pull-the → main 1 commit fork
4 files
changed +103
−5
src/checks/summary.ts +40 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * the run's log, and never a guess at *why* a test failed. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import type { CheckDetail } from "../core/runs.ts"; | |
| 10 | import type { CheckDetail, Run } from "../core/runs.ts"; | |
| 11 | 11 | import { parseBunTestFailure } from "./bun-test.ts"; |
| 12 | 12 | |
| 13 | 13 | /** |
| @@ -69,3 +69,42 @@ export function summariseCheckFailure( | ||
| 69 | 69 | } |
| 70 | 70 | return { summary: fallback, detail: undefined }; |
| 71 | 71 | } |
| 72 | ||
| 73 | /** `45s`, `12 min`, `1h 5m`: a span in the fewest words that stay true. */ | |
| 74 | export function spanWords(ms: number): string { | |
| 75 | const seconds = Math.max(0, Math.round(ms / 1000)); | |
| 76 | if (seconds < 60) return `${seconds}s`; | |
| 77 | const minutes = Math.floor(seconds / 60); | |
| 78 | if (minutes < 60) return `${minutes} min`; | |
| 79 | const rest = minutes % 60; | |
| 80 | return rest === 0 | |
| 81 | ? `${Math.floor(minutes / 60)}h` | |
| 82 | : `${Math.floor(minutes / 60)}h ${rest}m`; | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * What a pull's checks line says of a *check* run, in the check's own | |
| 87 | * words — never "deploy". `undefined` for a state the line words itself | |
| 88 | * (queued, cancelled, none) or when the run is not a check. | |
| 89 | */ | |
| 90 | export function checkWords( | |
| 91 | run: Pick< | |
| 92 | Run, | |
| 93 | "kind" | "state" | "createdAt" | "startedAt" | "finishedAt" | "summary" | "checkDetail" | |
| 94 | >, | |
| 95 | now = Date.now(), | |
| 96 | ): string | undefined { | |
| 97 | if (run.kind !== "check") return undefined; | |
| 98 | const began = Date.parse(run.startedAt ?? run.createdAt); | |
| 99 | if (run.state === "running") | |
| 100 | return `Check running · ${spanWords(now - began)}`; | |
| 101 | if (run.state === "ok") | |
| 102 | return run.finishedAt === null | |
| 103 | ? "Check passed" | |
| 104 | : `Check passed in ${spanWords(Date.parse(run.finishedAt) - began)}`; | |
| 105 | if (run.state === "failed") { | |
| 106 | const first = run.checkDetail?.names[0] ?? run.summary; | |
| 107 | return first === "" ? "Check failed" : `Check failed: ${first}`; | |
| 108 | } | |
| 109 | return undefined; | |
| 110 | } |
src/web/pages/pull.ts +6 −4
| @@ -34,6 +34,7 @@ import { | ||
| 34 | 34 | type PullStatus, |
| 35 | 35 | } from "../../core/pulls.ts"; |
| 36 | 36 | import { isProposed, pullMayRun } from "../../core/pull_trust.ts"; |
| 37 | import { checkWords } from "../../checks/summary.ts"; | |
| 37 | 38 | import { checkStale } from "../../checks/stale.ts"; |
| 38 | 39 | import { checkSummary, type CheckSummary } from "../../core/runs.ts"; |
| 39 | 40 | import { |
| @@ -228,8 +229,9 @@ export function checksLine( | ||
| 228 | 229 | ? null |
| 229 | 230 | : html`<span class="where-sep" aria-hidden="true">·</span |
| 230 | 231 | ><a href="${base}/runs/${run.id}" |
| 231 | >deploy ${run.commitOid.slice(0, 7)}</a | |
| 232 | >${run.kind === "check" ? "Check of" : "deploy"} ${run.commitOid.slice(0, 7)}</a | |
| 232 | 233 | >`; |
| 234 | const words = run === null ? undefined : checkWords(run); | |
| 233 | 235 | const against = |
| 234 | 236 | run !== null && run.baseOid !== "" && baseBranch !== "" |
| 235 | 237 | ? html`<span class="where-sep" aria-hidden="true">·</span |
| @@ -247,16 +249,16 @@ export function checksLine( | ||
| 247 | 249 | >${icon("clock")}Checked against an older ${baseBranch || "base"}</span |
| 248 | 250 | >${link}` |
| 249 | 251 | : check.state === "ok" |
| 250 | ? html`<span class="ok">${icon("check")}Checks passed</span>${link}` | |
| 252 | ? html`<span class="ok">${icon("check")}${words ?? "Checks passed"}</span>${link}` | |
| 251 | 253 | : check.state === "failed" |
| 252 | ? html`<span class="bad">${icon("x")}Checks failed</span>${link}` | |
| 254 | ? html`<span class="bad">${icon("x")}${words ?? "Checks failed"}</span>${link}` | |
| 253 | 255 | : check.state === "queued" |
| 254 | 256 | ? html`<span class="now" |
| 255 | 257 | >${icon("loader")}Checks |
| 256 | 258 | ${run ? queuedPhrase(run.createdAt) : "queued"}</span |
| 257 | 259 | >${link}` |
| 258 | 260 | : check.state === "running" |
| 259 | ? html`<span class="now">${icon("loader")}Checks running</span | |
| 261 | ? html`<span class="now">${icon("loader")}${words ?? "Checks running"}</span | |
| 260 | 262 | >${link}` |
| 261 | 263 | : check.state === "cancelled" |
| 262 | 264 | ? html`<span>${icon("ban")}Checks cancelled</span>${link}` |
src/web/pages/runs.ts +5
| @@ -68,6 +68,11 @@ export function runTitle( | ||
| 68 | 68 | return number === undefined ? "" : `#${number}`; |
| 69 | 69 | })() |
| 70 | 70 | : `#${issue.number} ${issue.title}`; |
| 71 | if (run.kind === "check") { | |
| 72 | const number = issue?.number ?? envNumber(run.env); | |
| 73 | const of = `Check of ${run.commitOid.slice(0, 7)}`; | |
| 74 | return number === undefined ? of : `${of} for #${number}`; | |
| 75 | } | |
| 71 | 76 | if (run.kind === "deploy") |
| 72 | 77 | return about === "" ? "Deploy production" : `Deploy preview ${about}`; |
| 73 | 78 | const who = actor ?? "an agent"; |
test/check-named.test.ts added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { checkWords } from "../src/checks/summary.ts"; | |
| 3 | import { runTitle } from "../src/web/pages/runs.ts"; | |
| 4 | import { checksLine } from "../src/web/pages/pull.ts"; | |
| 5 | import type { Run } from "../src/core/runs.ts"; | |
| 6 | ||
| 7 | const T0 = Date.parse("2026-10-16T10:00:00Z"); | |
| 8 | const run = (over: Partial<Run> = {}): Run => | |
| 9 | ({ | |
| 10 | id: "r1", | |
| 11 | kind: "check", | |
| 12 | env: "pull-7", | |
| 13 | commitOid: "abcdef1234567", | |
| 14 | state: "running", | |
| 15 | summary: "", | |
| 16 | createdAt: "2026-10-16T10:00:00Z", | |
| 17 | startedAt: "2026-10-16T10:00:00Z", | |
| 18 | finishedAt: null, | |
| 19 | checkDetail: null, | |
| 20 | baseOid: "", | |
| 21 | ...over, | |
| 22 | }) as Run; | |
| 23 | const line = (r: Run) => | |
| 24 | checksLine("/a/b", { state: r.state, run: r }, false, true).text; | |
| 25 | ||
| 26 | test("a running check is named with its age and links to its run", () => { | |
| 27 | expect(checkWords(run(), T0 + 12 * 60_000)).toBe("Check running · 12 min"); | |
| 28 | const text = line(run()); | |
| 29 | expect(text).toContain("Check running · "); | |
| 30 | expect(text).toContain('href="/a/b/runs/r1"'); | |
| 31 | expect(text).toContain("Check of"); | |
| 32 | expect(text.toLowerCase()).not.toContain("deploy"); | |
| 33 | }); | |
| 34 | ||
| 35 | test("a finished check says how long it took or what failed first", () => { | |
| 36 | const done = run({ state: "ok", finishedAt: "2026-10-16T10:03:00Z" }); | |
| 37 | expect(checkWords(done)).toBe("Check passed in 3 min"); | |
| 38 | expect(line(done)).toContain("Check passed in 3 min"); | |
| 39 | const bad = run({ | |
| 40 | state: "failed", | |
| 41 | summary: "1 of 9 tests", | |
| 42 | checkDetail: { passed: 8, failed: 1, names: ["it adds"] } as Run["checkDetail"], | |
| 43 | }); | |
| 44 | expect(checkWords(bad)).toBe("Check failed: it adds"); | |
| 45 | expect(line(bad)).toContain("Check failed: it adds"); | |
| 46 | expect(line(bad).toLowerCase()).not.toContain("deploy"); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a check's run page is titled by its commit and pull", () => { | |
| 50 | expect(runTitle(run(), undefined, undefined)).toBe("Check of abcdef1 for #7"); | |
| 51 | expect(runTitle(run({ env: "" }), undefined, undefined)).toBe("Check of abcdef1"); | |
| 52 | }); |
new above
offline