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

Merged opened this

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

Merged

scout/forge:scout/7507-the-commits-page-shows-each-commit-s-che → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/7507-the-commits-page-shows-each-commit-s-che was merged into main as cca8a9a.

Review

Sign in to review this change.
3 files changed +253 −26
README.md +2
@@ -77,6 +77,8 @@ Reports from outside (sent with no account) are decided in the terminal too: ope
7777
7878 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`).
7979
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
8082 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`).
8183
8284 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 @@
1010 * list can do neither, so the live browse pages by cursor and says so.
1111 */
1212
13 import { checkOfCommit, verdictsOfCommits } from "../../actions/runs.ts";
14 import type { CommitCheck } from "../../checks/commit_check.ts";
1315 import { diffCommit, type Diff } from "../../git/diff.ts";
1416 import { isSafeOid, isSafeRef, isSafeTreePath } from "../../git/guard.ts";
1517 import { commitLog } from "../../git/read.ts";
@@ -34,6 +36,7 @@ import {
3436 messageText,
3537 originTag,
3638 pager,
39 runStateBadge,
3740 sha,
3841 time,
3942 } from "../ui.ts";
@@ -62,51 +65,107 @@ function pageNumber(raw: string | null): number {
6265 * it wears that forge's mark instead of a local avatar — the same rule every
6366 * imported author follows.
6467 */
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 {
6673 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 >`;
6789 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>
7197 <a
7298 class="row"
7399 data-nav
74100 tabindex="-1"
75101 href="${scope.base}/commit/${commit.oid}"
76102 >
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>
94104 <span class="row-trail">${sha(commit.oid)}</span>
95105 </a>
96 </li>`,
97 ),
106 </li>`;
107 return html`<li>
outdated

This row lost data-nav and tabindex="-1", so j/k skips it. Put both on the div: openFocused clicks the first a inside a non-anchor [data-nav].

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 runStateBadge(verdict.state, "check") from ui.ts, the single named badge for a check's state. As written, a check reads "green" here and "Passed" on its run page.

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 }),
98124 );
99125 }
100126
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 runStateBadge here and at line 142 too. The pending branch at line 147 should wear the running badge.

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
101155 /** One commit's header card and its diff. */
102156 export function renderCommit(
103157 scope: RepoScope,
104158 commit: Commit,
105159 diff: Diff,
106160 syntax: SyntaxMap,
161 check: CommitCheck | null = null,
107162 ): Html {
163 const verdict: Html =
164 check !== null && check.state !== "none"
165 ? html`<div class="card-body small">${checkSentence(scope, check)}</div>`
166 : html``;
108167 const head: Html = card(
109 html`${when(
168 html`${verdict}${when(
110169 commit.body.trim() !== "",
111170 () => html`<div class="card-body">${messageText(commit.body)}</div>`,
112171 )}
@@ -230,7 +289,11 @@ export async function commitsPage(ctx: Ctx): Promise<Response> {
230289 const hasMore = log.value.length > PER_PAGE;
231290
232291 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 )}
234297 ${pager({
235298 ...(num > 1 ? { prev: query(num - 1) } : {}),
236299 ...(hasMore ? { next: query(num + 1) } : {}),
@@ -264,6 +327,8 @@ export async function commitPage(ctx: Ctx): Promise<Response> {
264327 if (!log.ok || log.value.length === 0) return notFoundPage(ctx);
265328 const commit = log.value[0]!;
266329
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);
267332 const diffResult = await diffCommit(dir, commit.oid);
268333 notePhase(ctx.request, "git", performance.now() - reading);
269334 const diff = diffResult.ok
@@ -273,9 +338,10 @@ export async function commitPage(ctx: Ctx): Promise<Response> {
273338 const syntax = hunkSyntax(diff);
274339 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 diffCommit and await both together.

275340
341 const checked = await checking;
276342 const rendering = performance.now();
277343 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)}`;
279345 const response = page({
280346 ctx,
281347 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 via, pending and superseded wordings of checkSentence.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs