Public: anyone can read this; the crew can change it.
The commits page shows each commit's check beside it, green, red or not run, and a commit's own page says why, as MCP `commits` does #7528
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7507-the-commits-page-shows-each-commit-s-che → main 4 commits fork 5 threads · 5 unresolved
README.md +2
| @@ -77,6 +77,8 @@ Reports from outside (sent with no account) are decided in the terminal too: ope | ||
| 77 | 77 | |
| 78 | 78 | The terminal's history says each commit's check, as MCP `commits` with `oid` does: a ✓ or ✗ mark on a log row where the commit's own check on the default branch passed or failed (blank where it has no verdict yet), and a `check:` line under one commit's title: `green`, `red · <first line of the failing step>`, `running`, `superseded` or `not run`, with `· via <sha>` when a later main commit's check answers for it. Nothing shows to a viewer who may not read the repository's runs (`src/checks/commit_check.ts`, `src/tui/screens/commits.ts`). |
| 79 | 79 | |
| 80 | The web history says it too (`/:owner/:name/commits`): a commit whose own check passed or failed wears the check badge, Passed or Failed, linked to that check's run with the commit and the failing step in its accessible name; one with no verdict wears none, and nothing shows to a viewer who may not read the repository's runs. One commit's page (`/:owner/:name/commit/:oid`) opens with the check in a sentence: passed, failed with `first failing <step>`, running or superseded, with "(the check of <sha>, which contains it)" when a later main commit's check answers for it, and nothing where no check ran. The list reads its verdicts in one query per page (`verdictsOfCommits`); live-browse pages of another forge show none. | |
| 81 | ||
| 80 | 82 | In the terminal workspace a `.csv` or `.tsv` file opens as a table (columns padded to their widest cell, the header kept on top, `N rows` in the status line; at most 1,000 rows and 64 columns are drawn and the rest is counted; a cell wider than 32 columns is cut with `…`, and the source view shows it whole). `s` flips between the table and the source, and a file that does not parse opens as source with `not a table: line N` in the status line (`src/text/csv.ts`, `src/tui/ui/grid.ts`). |
| 81 | 83 | |
| 82 | 84 | Under the agents, each team that has a pull has one pooled row (its members' |
src/web/pages/commits.ts +92 −26
| @@ -10,6 +10,8 @@ | ||
| 10 | 10 | * list can do neither, so the live browse pages by cursor and says so. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { checkOfCommit, verdictsOfCommits } from "../../actions/runs.ts"; | |
| 14 | import type { CommitCheck } from "../../checks/commit_check.ts"; | |
| 13 | 15 | import { diffCommit, type Diff } from "../../git/diff.ts"; |
| 14 | 16 | import { isSafeOid, isSafeRef, isSafeTreePath } from "../../git/guard.ts"; |
| 15 | 17 | import { commitLog } from "../../git/read.ts"; |
| @@ -34,6 +36,7 @@ import { | ||
| 34 | 36 | messageText, |
| 35 | 37 | originTag, |
| 36 | 38 | pager, |
| 39 | runStateBadge, | |
| 37 | 40 | sha, |
| 38 | 41 | time, |
| 39 | 42 | } from "../ui.ts"; |
| @@ -62,51 +65,107 @@ function pageNumber(raw: string | null): number { | ||
| 62 | 65 | * it wears that forge's mark instead of a local avatar — the same rule every |
| 63 | 66 | * imported author follows. |
| 64 | 67 | */ |
| 65 | export function commitRows(scope: RepoScope, commits: readonly Commit[]): Html { | |
| 68 | export function commitRows( | |
| 69 | scope: RepoScope, | |
| 70 | commits: readonly Commit[], | |
| 71 | verdicts: ReadonlyMap<string, CommitCheck> = new Map(), | |
| 72 | ): Html { | |
| 66 | 73 | const glyph = scope.origin?.kind ?? scope.repo?.origin?.kind ?? "globe"; |
| 74 | const lead = (commit: Commit): Html => | |
| 75 | html`<span class="row-lead" | |
| 76 | >${scope.kind === "origin" && commit.login | |
| 77 | ? icon(glyph) | |
| 78 | : avatar(commit.author)}</span | |
| 79 | >`; | |
| 80 | const text = (commit: Commit): Html => | |
| 81 | html`<span class="row-title" title="${commit.subject}">${commit.subject}</span> | |
| 82 | <span class="row-meta" | |
| 83 | >${commit.author} committed | |
| 84 | ${time(commit.date)}${when( | |
| 85 | commit.parents.length > 1, | |
| 86 | () => html` · ${badge("merge", { variant: "secondary" })}`, | |
| 87 | )}</span | |
| 88 | >`; | |
| 67 | 89 | return listCard( |
| 68 | commits.map( | |
| 69 | (commit) => | |
| 70 | html`<li> | |
| 90 | commits.map((commit) => { | |
| 91 | const verdict = verdicts.get(commit.oid); | |
| 92 | const step = verdict?.summary.split("\n")[0]?.trim() ?? ""; | |
| 93 | // A link cannot sit inside a link: a row with a verdict is a row whose | |
| 94 | // title links to the commit and whose badge links to the check. | |
| 95 | if (verdict === undefined || verdict.runId === null) | |
| 96 | return html`<li> | |
| 71 | 97 | <a |
| 72 | 98 | class="row" |
| 73 | 99 | data-nav |
| 74 | 100 | tabindex="-1" |
| 75 | 101 | href="${scope.base}/commit/${commit.oid}" |
| 76 | 102 | > |
| 77 | <span class="row-lead" | |
| 78 | >${scope.kind === "origin" && commit.login | |
| 79 | ? icon(glyph) | |
| 80 | : avatar(commit.author)}</span | |
| 81 | > | |
| 82 | <span class="row-main"> | |
| 83 | <span class="row-title" title="${commit.subject}" | |
| 84 | >${commit.subject}</span | |
| 85 | > | |
| 86 | <span class="row-meta" | |
| 87 | >${commit.author} committed | |
| 88 | ${time(commit.date)}${when( | |
| 89 | commit.parents.length > 1, | |
| 90 | () => html` · ${badge("merge", { variant: "secondary" })}`, | |
| 91 | )}</span | |
| 92 | > | |
| 93 | </span> | |
| 103 | ${lead(commit)}<span class="row-main">${text(commit)}</span> | |
| 94 | 104 | <span class="row-trail">${sha(commit.oid)}</span> |
| 95 | 105 | </a> |
| 96 | </li>`, | |
| 97 | ), | |
| 106 | </li>`; | |
| 107 | return html`<li> | |
| 108 | <div class="row" data-nav tabindex="-1"> | |
| 109 | ${lead(commit)} | |
| 110 | <a class="row-main" href="${scope.base}/commit/${commit.oid}" | |
| 111 | >${text(commit)}</a | |
| 112 | > | |
| 113 | <span class="row-trail" | |
| 114 | ><a | |
| 115 | href="${scope.base}/runs/${verdict.runId}" | |
| 116 | title="${step}" | |
outdated
This should be |
||
| 117 | aria-label="Check ${verdict.state === "ok" ? "passed" : "failed"} for ${commit.oid.slice(0, 7)}${step === "" ? "" : `: ${step}`}" | |
| 118 | >${runStateBadge(verdict.state === "ok" ? "ok" : "failed", "check")}</a | |
| 119 | >${sha(commit.oid)}</span | |
| 120 | > | |
| 121 | </div> | |
| 122 | </li>`; | |
| 123 | }), | |
| 98 | 124 | ); |
| 99 | 125 | } |
| 100 | 126 | |
| 127 | /** A commit's check in a sentence; on red, the first failing step as the check named it. */ | |
| 128 | export function checkSentence(scope: RepoScope, check: CommitCheck): Html { | |
| 129 | const run = check.runId === null ? null : `${scope.base}/runs/${check.runId}`; | |
| 130 | const via = | |
| 131 | check.checkedCommit === check.commit | |
| 132 | ? "" | |
| 133 | : ` (the check of ${check.checkedCommit.slice(0, 7)}, which contains it)`; | |
| 134 | const link = (text: string): Html => | |
| 135 | run === null ? html`${text}` : html`<a href="${run}">${text}</a>`; | |
| 136 | switch (check.state) { | |
| 137 | case "ok": | |
| 138 | return html`${runStateBadge("ok", "check")} ${link("The check passed")}${via}.`; | |
| 139 | case "failed": { | |
outdated
This badge has no icon, unlike the one in the list. Use |
||
| 140 | const step = check.summary.split("\n")[0]?.trim() ?? ""; | |
| 141 | return html`${runStateBadge("failed", "check")} ${link("The check failed")}${via}${when( | |
| 142 | step !== "", | |
| 143 | () => html`: first failing ${step}`, | |
| 144 | )}.`; | |
| 145 | } | |
| 146 | case "pending": | |
| 147 | return html`${runStateBadge("running", "check")} ${link("The check is running")}${via}.`; | |
| 148 | case "superseded": | |
| 149 | return html`${runStateBadge("cancelled", "check")} The check was superseded and none will follow.`; | |
| 150 | default: | |
| 151 | return html`No check ran on this commit.`; | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 101 | 155 | /** One commit's header card and its diff. */ |
| 102 | 156 | export function renderCommit( |
| 103 | 157 | scope: RepoScope, |
| 104 | 158 | commit: Commit, |
| 105 | 159 | diff: Diff, |
| 106 | 160 | syntax: SyntaxMap, |
| 161 | check: CommitCheck | null = null, | |
| 107 | 162 | ): Html { |
| 163 | const verdict: Html = | |
| 164 | check !== null && check.state !== "none" | |
| 165 | ? html`<div class="card-body small">${checkSentence(scope, check)}</div>` | |
| 166 | : html``; | |
| 108 | 167 | const head: Html = card( |
| 109 | html`${when( | |
| 168 | html`${verdict}${when( | |
| 110 | 169 | commit.body.trim() !== "", |
| 111 | 170 | () => html`<div class="card-body">${messageText(commit.body)}</div>`, |
| 112 | 171 | )} |
| @@ -230,7 +289,11 @@ export async function commitsPage(ctx: Ctx): Promise<Response> { | ||
| 230 | 289 | const hasMore = log.value.length > PER_PAGE; |
| 231 | 290 | |
| 232 | 291 | const body = html`${scopeHeader(ctx, scope, "commits")}${switcher} |
| 233 | ${commitRows(scope, commits)} | |
| 292 | ${commitRows( | |
| 293 | scope, | |
| 294 | commits, | |
| 295 | verdictsOfCommits(ctx.app, ctx.auth.user, repo, commits.map((c) => c.oid)), | |
| 296 | )} | |
| 234 | 297 | ${pager({ |
| 235 | 298 | ...(num > 1 ? { prev: query(num - 1) } : {}), |
| 236 | 299 | ...(hasMore ? { next: query(num + 1) } : {}), |
| @@ -264,6 +327,8 @@ export async function commitPage(ctx: Ctx): Promise<Response> { | ||
| 264 | 327 | if (!log.ok || log.value.length === 0) return notFoundPage(ctx); |
| 265 | 328 | const commit = log.value[0]!; |
| 266 | 329 | |
| 330 | // The ancestry walk behind a verdict runs beside the diff, not after it. | |
| 331 | const checking = checkOfCommit(ctx.app, ctx.auth.user, repo, commit.oid); | |
| 267 | 332 | const diffResult = await diffCommit(dir, commit.oid); |
| 268 | 333 | notePhase(ctx.request, "git", performance.now() - reading); |
| 269 | 334 | const diff = diffResult.ok |
| @@ -273,9 +338,10 @@ export async function commitPage(ctx: Ctx): Promise<Response> { | ||
| 273 | 338 | const syntax = hunkSyntax(diff); |
| 274 | 339 | notePhase(ctx.request, "highlight", performance.now() - highlighting); |
outdated
This adds serial git ancestry checks to every commit page view when the commit has no check of its own. Start it alongside |
||
| 275 | 340 | |
| 341 | const checked = await checking; | |
| 276 | 342 | const rendering = performance.now(); |
| 277 | 343 | const body = html`${scopeHeader(ctx, scope, "commits", { compact: true })} |
| 278 | ${renderCommit(scope, commit, diff, syntax)}`; | |
| 344 | ${renderCommit(scope, commit, diff, syntax, checked.ok ? checked.value : null)}`; | |
| 279 | 345 | const response = page({ |
| 280 | 346 | ctx, |
| 281 | 347 | title: `${commit.subject} · ${repo.name}`, |
test/web-commits-check.test.ts added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | /** The web commits page marks each commit's check beside it; a commit's own page says why. */ | |
| 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 { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 9 | import { createRun, finishRun } from "../src/core/runs.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import type { CommitCheck } from "../src/checks/commit_check.ts"; | |
| 13 | import { checkSentence } from "../src/web/pages/commits.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { local } from "../src/tui/screens/where.ts"; | |
| 16 | import { Session } from "../src/tui/session.ts"; | |
| 17 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let alice: User; | |
| 26 | const oids: Record<string, string> = {}; | |
| 27 | const runs: Record<string, string> = {}; | |
| 28 | let ownerCookie = ""; | |
| 29 | let bobCookie = ""; | |
| 30 | const repos: Record<string, Repo> = {}; | |
| 31 | ||
| 32 | function check(r: Repo, oid: string, state: "ok" | "failed", summary: string): string { | |
| 33 | const run = createRun(app.db, { repoId: r.id, kind: "check", env: r.defaultBranch, commitOid: oid }); | |
| 34 | app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(run.id); | |
| 35 | finishRun(app.db, run.id, { state, summary }); | |
| 36 | return run.id; | |
| 37 | } | |
| 38 | ||
| 39 | async function seed(name: string, visibility: "public" | "private") { | |
| 40 | const made = await createRepo(app, alice, { name, visibility }); | |
| 41 | if (!made.ok) throw new Error("repo creation failed"); | |
| 42 | const r = getRepo(app.db, "alice", name)!; | |
| 43 | repos[name] = r; | |
| 44 | for (const n of ["green", "red", "none"]) { | |
| 45 | const c = await commitToBranch(app, alice, r, { | |
| 46 | message: `${n} change`, | |
| 47 | files: [{ path: `${n}.txt`, content: `${n}\n` }], | |
| 48 | }); | |
| 49 | if (!c.ok) throw new Error("commit failed"); | |
| 50 | const s = new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }); | |
| 51 | const log = await local(s, r).commits(r.defaultBranch); | |
| 52 | if (!log.ok) throw new Error("log failed"); | |
| 53 | oids[`${name}/${n}`] = log.value.items[0]!.oid; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dir = await mkdtemp(join(tmpdir(), "forge-web-commits-check-")); | |
| 59 | app = createApp({ dataDir: dir, port: 0 }); | |
outdated
Only a public repo is seeded, and every fetch is anonymous. Add a private repo with a check and assert that a guest cannot see the verdict (no badge, no run link) while the owner can. Also cover the |
||
| 60 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 61 | if (!a.ok) throw new Error("signup failed"); | |
| 62 | alice = a.value; | |
| 63 | servers = await serve(app); | |
| 64 | await seed("pub", "public"); | |
| 65 | runs.green = check(repos.pub!, oids["pub/green"]!, "ok", ""); | |
| 66 | runs.red = check(repos.pub!, oids["pub/red"]!, "failed", "step typecheck failed"); | |
| 67 | await seed("priv", "private"); | |
| 68 | runs.priv = check(repos.priv!, oids["priv/red"]!, "failed", "step secret failed"); | |
| 69 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-long-passphrase", origin: "seed", trusted: true }); | |
| 70 | if (!bob.ok) throw new Error("bob failed"); | |
| 71 | ownerCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 72 | bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 73 | }, 120_000); | |
| 74 | ||
| 75 | afterAll(async () => { | |
| 76 | servers.stop(); | |
| 77 | app.close(); | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | const get = async (path: string, cookie?: string) => | |
| 82 | (await fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {} })).text(); | |
| 83 | ||
| 84 | const rowOf = (page: string, subject: string): string => | |
| 85 | (page.split("<li>").find((l) => l.includes(subject)) ?? "").split("</li>")[0]!; | |
| 86 | ||
| 87 | test("the list shows green and red linked to their runs, and nothing without a check", async () => { | |
| 88 | const page = await get("/alice/pub/commits"); | |
| 89 | const green = rowOf(page, "green change"); | |
| 90 | const red = rowOf(page, "red change"); | |
| 91 | const none = rowOf(page, "none change"); | |
| 92 | expect(green).toContain("Passed"); | |
| 93 | expect(green).toContain(`href="/alice/pub/runs/${runs.green}"`); | |
| 94 | expect(red).toContain("Failed"); | |
| 95 | expect(red).toContain(`href="/alice/pub/runs/${runs.red}"`); | |
| 96 | expect(none).not.toContain("/runs/"); | |
| 97 | expect(none).not.toContain("badge"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a commit's page says the verdict and names the failing step", async () => { | |
| 101 | const red = await get(`/alice/pub/commit/${oids["pub/red"]}`); | |
| 102 | expect(red).toContain("The check failed"); | |
| 103 | expect(red).toContain("first failing step typecheck failed"); | |
| 104 | expect(red).toContain(`href="/alice/pub/runs/${runs.red}"`); | |
| 105 | const green = await get(`/alice/pub/commit/${oids["pub/green"]}`); | |
| 106 | expect(green).toContain("The check passed"); | |
| 107 | const none = await get(`/alice/pub/commit/${oids["pub/none"]}`); | |
| 108 | expect(none).not.toContain("The check"); | |
| 109 | }); | |
| 110 | ||
| 111 | test("every row of the list is reachable by keyboard, checked or not", async () => { | |
| 112 | const page = await get("/alice/pub/commits"); | |
| 113 | const rows = page.split("<li>").slice(1).map((l) => l.split("</li>")[0]!).filter((l) => l.includes('class="row"')); | |
| 114 | expect(rows.length).toBe(3); | |
| 115 | for (const row of rows) expect(row).toMatch(/data-nav\s+tabindex="-1"/); | |
| 116 | }); | |
| 117 | ||
| 118 | test("a private repository's verdict is the owner's alone", async () => { | |
| 119 | const list = "/alice/priv/commits"; | |
| 120 | const one = `/alice/priv/commit/${oids["priv/red"]}`; | |
| 121 | for (const cookie of [undefined, bobCookie]) { | |
| 122 | expect(await get(list, cookie)).not.toContain(`/runs/${runs.priv}`); | |
| 123 | const seen = await get(one, cookie); | |
| 124 | expect(seen).not.toContain(`/runs/${runs.priv}`); | |
| 125 | expect(seen).not.toContain("step secret failed"); | |
| 126 | } | |
| 127 | const mine = await get(list, ownerCookie); | |
| 128 | expect(mine).toContain(`href="/alice/priv/runs/${runs.priv}"`); | |
| 129 | expect(mine).toContain("Failed"); | |
| 130 | expect(await get(one, ownerCookie)).toContain("first failing step secret failed"); | |
| 131 | }); | |
| 132 | ||
| 133 | const sentence = (over: Partial<CommitCheck>): string => | |
| 134 | checkSentence({ base: "/alice/pub" } as never, { | |
| 135 | commit: "a".repeat(40), | |
| 136 | checkedCommit: "a".repeat(40), | |
| 137 | state: "ok", | |
| 138 | runId: "run1", | |
| 139 | summary: "", | |
| 140 | finishedAt: null, | |
| 141 | ...over, | |
| 142 | }).toString(); | |
| 143 | ||
| 144 | test("the sentence names whose check answered, a running check and a superseded one", () => { | |
| 145 | const via = sentence({ checkedCommit: "b".repeat(40) }); | |
| 146 | expect(via).toContain("the check of bbbbbbb, which contains it"); | |
| 147 | expect(via).toContain("Passed"); | |
| 148 | const running = sentence({ state: "pending" }); | |
| 149 | expect(running).toContain("The check is running"); | |
| 150 | expect(running).toContain("Running"); | |
| 151 | const gone = sentence({ state: "superseded", runId: null }); | |
| 152 | expect(gone).toContain("superseded"); | |
| 153 | expect(gone).not.toContain("/runs/"); | |
| 154 | }); | |
| 155 | ||
| 156 | test("the badge link names the commit and the failing step", async () => { | |
| 157 | const red = rowOf(await get("/alice/pub/commits"), "red change"); | |
| 158 | expect(red).toContain(`aria-label="Check failed for ${oids["pub/red"]!.slice(0, 7)}: step typecheck failed"`); | |
| 159 | }); |
This row lost
data-navandtabindex="-1", so j/k skips it. Put both on the div:openFocusedclicks the firstainside a non-anchor[data-nav].