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

The terminal's one-commit view says the commit's check: green, red or not run, and why, as MCP `commits` with `oid` does #7310

Merged opened this

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

Merged

scout/forge:scout/7300-the-terminal-s-one-commit-view-says-the → main 2 commits fork

  • Merged. scout/7300-the-terminal-s-one-commit-view-says-the was merged into main as 350eac2.

Review

Sign in to review this change.
5 files changed +391 −6
README.md +2
@@ -56,6 +56,8 @@ On a sealed issue, a writer gets the private repository to fix it in: `POST /api
5656
5757 **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet.
5858
59 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`).
60
5961 Under the agents, each team that has a pull has one pooled row (its members'
6062 counts added, the ratios taken from the sums; an agent on two teams counts in
6163 both): `data-team` rows on the page, "team <name>" rows in that terminal screen.
src/actions/runs.ts +12 −1
@@ -8,7 +8,7 @@
88 * the system's own voice and takes no actor.
99 */
1010
11 import { isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts";
11 import { commitVerdicts, isCommitId, releaseCheck, type CommitCheck } from "../checks/commit_check.ts";
1212 import { hasCommit, isAncestor } from "../git/local.ts";
1313 import { repoDir } from "../git/repos.ts";
1414 import { mayViewId } from "../core/sealed.ts";
@@ -191,6 +191,17 @@ export async function checkOfCommit(
191191 );
192192 }
193193
194 /** The own green or red verdicts of a page of commits, for whoever may read the runs; empty otherwise. */
195 export function verdictsOfCommits(
196 app: App,
197 actor: User | undefined,
198 repo: Repo,
199 oids: readonly string[],
200 ): Map<string, CommitCheck> {
201 if (!mayReadRuns(app, actor, repo)) return new Map();
202 return commitVerdicts(app.db, repo.id, repo.defaultBranch, oids);
203 }
204
194205 /** A run by id, only if it belongs to a repository the actor can see. */
195206 export function getRepoRun(
196207 app: App,
src/checks/commit_check.ts +40
@@ -87,6 +87,46 @@ export function commitCheck(
8787 };
8888 }
8989
90 /**
91 * The own verdicts, `ok` or `failed`, of a page of commits in one query: what
92 * a log list marks. A commit with no verdict yet is absent from the map.
93 */
94 export function commitVerdicts(
95 db: Database,
96 repoId: string,
97 branch: string,
98 oids: readonly string[],
99 ): Map<string, CommitCheck> {
100 const out = new Map<string, CommitCheck>();
101 const wanted = new Set(oids.filter(isCommitId));
102 if (wanted.size === 0) return out;
103 const rows = db
104 .query<
105 { id: string; commit_oid: string; state: string; summary: string; finished_at: string | null },
106 [string, string, number]
107 >(
108 `SELECT id, commit_oid, state, summary, finished_at
109 FROM (SELECT id, kind, issue_id, env, commit_oid, state, summary, finished_at
110 FROM run WHERE repo_id = ?1 ORDER BY id DESC LIMIT ?3)
111 WHERE kind = 'check' AND issue_id IS NULL AND env = ?2
112 AND state IN ('ok', 'failed')
113 ORDER BY id DESC`,
114 )
115 .all(repoId, branch, WINDOW);
116 for (const row of rows) {
117 if (!wanted.has(row.commit_oid) || out.has(row.commit_oid)) continue;
118 out.set(row.commit_oid, {
119 commit: row.commit_oid,
120 checkedCommit: row.commit_oid,
121 state: row.state as "ok" | "failed",
122 runId: row.id,
123 summary: row.summary,
124 finishedAt: row.finished_at,
125 });
126 }
127 return out;
128 }
129
90130 /** How many later commits main checked are asked whether they contain the tag. */
91131 export const DESCENDANTS = 10;
92132
src/tui/screens/commits.ts +73 −5
@@ -6,6 +6,8 @@
66 * about having reached the end.
77 */
88
9 import { checkOfCommit, verdictsOfCommits } from "../../actions/runs.ts";
10 import type { CommitCheck } from "../../checks/commit_check.ts";
911 import type { FileDiff } from "../../git/diff.ts";
1012 import type { Commit, RepoSource } from "../../view/source.ts";
1113 import type { Ctx, Screen } from "../app/app.ts";
@@ -31,7 +33,34 @@ import {
3133 runAsync,
3234 } from "./common.ts";
3335 import { refPickerScreen } from "./refs.ts";
34 import { sourceName, whereRows, whereStrip } from "./where.ts";
36 import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
37
38 /** A log row's mark: ✓ green, ✗ red, blank where the commit's own check has no verdict. */
39 const markOf = (check: CommitCheck | undefined): string =>
40 check === undefined ? " " : check.state === "ok" ? "✓" : "✗";
41
42 /**
43 * One line for a commit's check: green, red with the failing step, or not
44 * run. When a later main commit's check answers (`checkedCommit`), it says
45 * whose, so a row the log left unmarked never opens to a bare "green".
46 */
47 export function checkLine(check: CommitCheck | null): string {
48 if (check === null) return "";
49 const step = check.summary.split("\n")[0]?.trim() ?? "";
50 const via = check.checkedCommit === check.commit ? "" : ` · via ${check.checkedCommit.slice(0, 8)}`;
51 switch (check.state) {
52 case "ok":
53 return `check: green${via}`;
54 case "failed":
55 return step === "" ? `check: red${via}` : `check: red · ${step}${via}`;
56 case "pending":
57 return `check: running${via}`;
58 case "superseded":
59 return "check: superseded";
60 default:
61 return "check: not run";
62 }
63 }
3564
3665 export function commitsScreen(
3766 f: Session,
@@ -45,6 +74,8 @@ export function commitsScreen(
4574 /** "No commits" is a claim, and it waits for the first page to answer. */
4675 let loaded = false;
4776 const state = new ListState();
77 /** Green or red marks for the log, one query per page; empty where the viewer may not read runs. */
78 const marks = new Map<string, CommitCheck>();
4879
4980 const page = async (): Promise<void> => {
5081 const result = await src.commits(ref, cursor);
@@ -53,6 +84,15 @@ export function commitsScreen(
5384 if (commits.length === 0)
5485 ctx.toast(`no commits on ${ref}: ${errorText(result.error)}`, "danger");
5586 } else {
87 const repo = localRepo(src);
88 if (repo !== undefined)
89 for (const [oid, check] of verdictsOfCommits(
90 f.app,
91 f.user,
92 repo,
93 result.value.items.map((commit) => commit.oid),
94 ))
95 marks.set(oid, check);
5696 commits = [...commits, ...result.value.items];
5797 cursor = result.value.next ?? undefined;
5898 more = result.value.next !== null;
@@ -104,7 +144,7 @@ export function commitsScreen(
104144 const rows = commits.map((commit) => ({
105145 title: commit.subject,
106146 secondary: commit.login ?? commit.author,
107 meta: `${commit.oid.slice(0, 8)} ${relTime(commit.date)}`,
147 meta: `${markOf(marks.get(commit.oid))} ${commit.oid.slice(0, 8)} ${relTime(commit.date)}`,
108148 }));
109149 if (more) rows.push({ title: "Load more…", secondary: "", meta: "" });
110150 drawList(s, body!, c, state, rows);
@@ -127,6 +167,7 @@ export function commitsScreen(
127167 }
128168 if (e.key === "r") {
129169 commits = [];
170 marks.clear();
130171 cursor = undefined;
131172 more = true;
132173 loaded = false;
@@ -146,12 +187,22 @@ export function commitScreen(
146187 commit: Commit,
147188 ): Screen {
148189 const view = new DiffView();
190 let check: CommitCheck | null = null;
149191 return {
150192 title: commit.oid.slice(0, 8),
151193 keys: view.keys,
152194 async load() {
153195 const result = await src.commit(commit.oid);
154196 view.setFiles(result.ok ? result.value.diff.files : []);
197 const repo = localRepo(src);
198 if (repo !== undefined) {
199 try {
200 const answer = await checkOfCommit(f.app, f.user, repo, commit.oid);
201 check = answer.ok ? answer.value : null;
202 } catch {
203 check = null; // the diff still draws without its check line
204 }
205 }
155206 ctx.invalidate();
156207 },
157208 render(area, s) {
@@ -171,10 +222,27 @@ export function commitScreen(
171222 style(c.fg, c.card, 1),
172223 inner.x + inner.w,
173224 );
174 if (inner.h > 1)
225 const verdict = checkLine(check);
226 const at = verdict === "" ? 0 : 1;
227 if (verdict !== "" && inner.h > 1)
175228 s.text(
176229 inner.x,
177230 inner.y + 1,
231 truncate(verdict, inner.w),
232 style(
233 check?.state === "ok"
234 ? c.success
235 : check?.state === "failed"
236 ? c.danger
237 : c.mutedFg,
238 c.card,
239 ),
240 inner.x + inner.w,
241 );
242 if (inner.h > 1 + at)
243 s.text(
244 inner.x,
245 inner.y + 1 + at,
178246 truncate(
179247 `${commit.author} <${commit.email}> · ${relTime(commit.date)} · ${view.summary()}`,
180248 inner.w,
@@ -182,10 +250,10 @@ export function commitScreen(
182250 style(c.mutedFg, c.card),
183251 inner.x + inner.w,
184252 );
185 if (inner.h > 2 && commit.body !== "")
253 if (inner.h > 2 + at && commit.body !== "")
186254 s.text(
187255 inner.x,
188 inner.y + 2,
256 inner.y + 2 + at,
189257 truncate(commit.body.split("\n")[0] ?? "", inner.w),
190258 style(c.faint, c.card),
191259 inner.x + inner.w,
test/tui-commit-check.test.ts added +264
@@ -0,0 +1,264 @@
1 /**
2 * The terminal's history says each commit's check, as MCP `commits` with
3 * `oid` does: a mark in the log for green or red, a line under the title of
4 * one commit, nothing where no check ran or the viewer may not read runs.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createApp as createAppRecord } from "../src/core/apps.ts";
14 import { getRepo, type Repo } from "../src/core/repos.ts";
15 import { createRun, finishRun } from "../src/core/runs.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import type { CommitCheck } from "../src/checks/commit_check.ts";
18 import type { Ctx, Screen } from "../src/tui/app/app.ts";
19 import { checkLine, commitScreen, commitsScreen } from "../src/tui/screens/commits.ts";
20 import { local } from "../src/tui/screens/where.ts";
21 import { Session } from "../src/tui/session.ts";
22 import { Screen as Surface } from "../src/tui/term/screen.ts";
23 import { theme } from "../src/tui/ui/theme.ts";
24 import { seedAdmin } from "./helpers/admin.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 let dir: string;
30 let app: App;
31 let alice: User;
32 let repo: Repo;
33 let priv: Repo;
34 let bob: User;
35 let appRepo: Repo;
36 let viaRepo: Repo;
37 let viaOids: string[] = [];
38 const oids: string[] = [];
39
40 const ctx = {
41 theme: theme("dark"),
42 push: () => {},
43 pop: () => {},
44 replace: () => {},
45 invalidate: () => {},
46 toast: () => {},
47 hit: () => {},
48 track: <T>(p: Promise<T>) => p,
49 } as unknown as Ctx;
50
51 const draw = (screen: Screen): string => {
52 const s = new Surface(110, 30);
53 screen.render({ x: 0, y: 0, w: 110, h: 30 }, s);
54 return s.toText();
55 };
56
57 const session = (user: User | undefined) =>
58 new Session(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user });
59
60 async function check(r: Repo, oid: string, state: "ok" | "failed", summary: string) {
61 const run = createRun(app.db, {
62 repoId: r.id,
63 kind: "check",
64 env: r.defaultBranch,
65 commitOid: oid,
66 });
67 app.db.query("UPDATE run SET state = 'running' WHERE id = ?1").run(run.id);
68 finishRun(app.db, run.id, { state, summary });
69 }
70
71 async function seed(name: string, visibility: "public" | "private") {
72 const made = await createRepo(app, alice, { name, visibility });
73 if (!made.ok) throw new Error("repo creation failed");
74 const r = getRepo(app.db, "alice", name)!;
75 const list: string[] = [];
76 for (const n of ["green", "red", "none"]) {
77 const c = await commitToBranch(app, alice, r, {
78 message: `${n} change`,
79 files: [{ path: `${n}.txt`, content: `${n}\n` }],
80 });
81 if (!c.ok) throw new Error("commit failed");
82 const log = await local(session(alice), r).commits(r.defaultBranch);
83 if (!log.ok) throw new Error("log failed");
84 list.push(log.value.items[0]!.oid);
85 }
86 return { r, list };
87 }
88
89 beforeAll(async () => {
90 dir = await mkdtemp(join(tmpdir(), "forge-tui-commit-check-"));
91 app = createApp({ dataDir: dir, port: 0 });
92 const a = await seedAdmin(app, {
93 username: "alice",
94 email: "[email protected]",
95 password: "a-long-passphrase",
96 });
97 if (!a.ok) throw new Error("signup failed");
98 alice = a.value;
99 const pub = await seed("pub", "public");
100 repo = pub.r;
101 oids.push(...pub.list);
102 await check(repo, oids[0]!, "ok", "");
103 await check(repo, oids[1]!, "failed", "step typecheck failed");
104 const hidden = await seed("hidden", "private");
105 priv = hidden.r;
106 await check(priv, hidden.list[1]!, "failed", "step typecheck failed");
107 oids.push(...hidden.list);
108 const b = await createUser(app.db, {
109 username: "bob",
110 email: "[email protected]",
111 password: "a-long-passphrase",
112 origin: "seed",
113 trusted: true,
114 });
115 if (!b.ok) throw new Error("bob failed");
116 bob = b.value;
117 // Public to read, but its runs answer only to those who may enter the private app.
118 const walled = await seed("walled", "public");
119 appRepo = walled.r;
120 await check(appRepo, walled.list[0]!, "ok", "");
121 oids.push(...walled.list);
122 const madeApp = createAppRecord(app.db, { repoId: appRepo.id, slug: "walled", access: "private" });
123 if (!madeApp.ok) throw new Error("app failed");
124 // main moved on: the head's check covers the commits before it.
125 const via = await seed("via", "public");
126 viaRepo = via.r;
127 viaOids = via.list;
128 await check(viaRepo, viaOids[2]!, "ok", "");
129 }, 120_000);
130
131 afterAll(async () => {
132 app.close();
133 await rm(dir, { recursive: true, force: true });
134 });
135
136 const rowOf = (text: string, subject: string): string =>
137 text.split("\n").find((l) => l.includes(subject)) ?? "";
138
139 describe("the log marks green and red", () => {
140 test("a green and a red row carry a mark, a row with none does not", async () => {
141 const f = session(alice);
142 const screen = commitsScreen(f, ctx, local(f, repo), repo.defaultBranch);
143 await screen.load!(new AbortController().signal);
144 const text = draw(screen);
145 expect(rowOf(text, "green change")).toContain("✓");
146 expect(rowOf(text, "red change")).toContain("✗");
147 expect(rowOf(text, "none change")).not.toMatch(/[✓✗]/);
148 });
149
150 test("a viewer who cannot read the repository's runs sees no mark", async () => {
151 const f = session(undefined);
152 const screen = commitsScreen(f, ctx, local(f, priv), priv.defaultBranch);
153 await screen.load!(new AbortController().signal);
154 expect(draw(screen)).not.toMatch(/[✓✗]/);
155 });
156 });
157
158 describe("a viewer who sees the repository but may not read its runs", () => {
159 test("the log has no mark for them, and does for a writer", async () => {
160 const mark = async (user: User) => {
161 const f = session(user);
162 const screen = commitsScreen(f, ctx, local(f, appRepo), appRepo.defaultBranch);
163 await screen.load!(new AbortController().signal);
164 return draw(screen);
165 };
166 expect(rowOf(await mark(alice), "green change")).toContain("✓");
167 const seen = await mark(bob);
168 expect(seen).toContain("green change");
169 expect(seen).not.toMatch(/[✓✗]/);
170 });
171
172 test("the one-commit view has no check line for them, and does for a writer", async () => {
173 const open = async (user: User) => {
174 const f = session(user);
175 const src = local(f, appRepo);
176 const found = await src.commit(oids[6]!);
177 if (!found.ok) throw new Error("commit not found");
178 const screen = commitScreen(f, ctx, src, found.value.commit);
179 await screen.load!(new AbortController().signal);
180 return draw(screen);
181 };
182 expect(await open(alice)).toContain("check: green");
183 expect(await open(bob)).not.toContain("check:");
184 });
185 });
186
187 describe("a check that comes from a later commit says so", () => {
188 test("the log leaves the row unmarked and the commit line names the commit that answered", async () => {
189 const f = session(alice);
190 const src = local(f, viaRepo);
191 const log = commitsScreen(f, ctx, src, viaRepo.defaultBranch);
192 await log.load!(new AbortController().signal);
193 expect(rowOf(draw(log), "green change")).not.toMatch(/[✓✗]/);
194 const found = await src.commit(viaOids[0]!);
195 if (!found.ok) throw new Error("commit not found");
196 const screen = commitScreen(f, ctx, src, found.value.commit);
197 await screen.load!(new AbortController().signal);
198 expect(draw(screen)).toContain(`check: green · via ${viaOids[2]!.slice(0, 8)}`);
199 });
200 });
201
202 describe("a failing look-up does not stop the diff", () => {
203 test("the screen is still invalidated for a redraw", async () => {
204 const broken = Object.create(app, {
205 db: {
206 get() {
207 throw new Error("db gone");
208 },
209 },
210 }) as App;
211 const f = new Session(broken, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice });
212 const src = local(session(alice), repo);
213 const found = await src.commit(oids[0]!);
214 if (!found.ok) throw new Error("commit not found");
215 let drawn = 0;
216 const screen = commitScreen(f, { ...ctx, invalidate: () => drawn++ } as Ctx, src, found.value.commit);
217 await screen.load!(new AbortController().signal);
218 expect(drawn).toBe(1);
219 });
220 });
221
222 describe("one commit says its check under its title", () => {
223 const open = async (user: User | undefined, r: Repo, oid: string) => {
224 const f = session(user);
225 const src = local(f, r);
226 const found = await src.commit(oid);
227 if (!found.ok) throw new Error("commit not found");
228 const screen = commitScreen(f, ctx, src, found.value.commit);
229 await screen.load!(new AbortController().signal);
230 return draw(screen);
231 };
232
233 test("green, red with the failing step, and not run", async () => {
234 expect(await open(alice, repo, oids[0]!)).toContain("check: green");
235 expect(await open(alice, repo, oids[1]!)).toContain("check: red · step typecheck failed");
236 expect(await open(alice, repo, oids[2]!)).toContain("check: not run");
237 });
238
239 test("an unreadable repository shows no check", async () => {
240 expect(await open(undefined, priv, oids[4]!)).not.toContain("check:");
241 });
242
243 test("checkLine is empty without an answer", () => {
244 expect(checkLine(null)).toBe("");
245 });
246
247 test("checkLine words running, superseded, and a red with no summary", () => {
248 const base: CommitCheck = {
249 commit: "a".repeat(40),
250 checkedCommit: "a".repeat(40),
251 state: "pending",
252 runId: "r",
253 summary: "",
254 finishedAt: null,
255 };
256 expect(checkLine(base)).toBe("check: running");
257 expect(checkLine({ ...base, state: "superseded" })).toBe("check: superseded");
258 expect(checkLine({ ...base, state: "failed" })).toBe("check: red");
259 expect(checkLine({ ...base, state: "none" })).toBe("check: not run");
260 expect(checkLine({ ...base, state: "ok", checkedCommit: "b".repeat(40) })).toBe(
261 "check: green · via bbbbbbbb",
262 );
263 });
264 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs