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

The terminal's About card opens the repository's events with `v`: newest first, paged, as MCP `events` reads them #7469

Merged opened this

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

Merged

scout/forge:scout/7442-the-terminal-s-about-card-opens-the-repo → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/7442-the-terminal-s-about-card-opens-the-repo was merged into main as f073377.

Review

Sign in to review this change.
4 files changed +191 −0
README.md +1
@@ -407,6 +407,7 @@ This page is the front door. Everything else is a file of its own under
407407 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
408408 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go.
409409 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
410 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
410411 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
411412 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
412413 - Under it, the panel says the crew's build yield over 24 h (runs finished, pushed, landed; `src/flow/build_yield.ts`) and the median hours to first review (`src/flow/review_latency.ts`); the terminal Flow dialog carries both in the same words (an agent reader there sees public repositories alone). `/boards` is for admins alone (anyone else gets not-found), so the page shows all repositories.
src/tui/screens/about.ts +6
@@ -60,6 +60,7 @@ import { badge, card } from "../ui/widgets.ts";
6060 import type { Session } from "../session.ts";
6161 import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts";
6262 import { connectionsScreen, egressScreen } from "./egress.ts";
63 import { eventsScreen } from "./events.ts";
6364 import { newPullScreen } from "./newpull.ts";
6465 import { pullScreen } from "./pull.ts";
6566 import { forkOf, hostOf, liveOrigin, local, localRepo, sourceName } from "./where.ts";
@@ -154,6 +155,7 @@ export function aboutScreen(
154155 get keys() {
155156 return [
156157 binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }),
158 ...(repo === undefined ? [] : [binding(["v"], "events")]),

The README needs a line for this key next to the other About-card keys (README.md ~417-418): what v shows, that n pages, and that the audience matches GET …/events and MCP events.

157159 // Only an admin is offered the key, and only an admin's press counts.
158160 ...(may()
159161 ? [
@@ -408,6 +410,10 @@ export function aboutScreen(
408410 ctx.invalidate();
409411 return true;
410412 }
413 if (e.key === "v" && repo !== undefined) {
414 ctx.push(eventsScreen(f, ctx, repo));
415 return true;
416 }
411417 if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) {
412418 ctx.push(egressScreen(f, ctx, repo));
413419 return true;
src/tui/screens/events.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * `v` on the About card — what happened on a repository, newest first.
3 *
4 * The same read as MCP `events` and the API (`eventsPage`): the audience
5 * filter runs there, so a webhook's URL and an app's variable names reach
6 * only the admins. One line per event — when, who, what — in the words the
7 * activity feed uses (`eventActor`, `eventVerb`). Fifty at a time; `n` reads
8 * the next page from the cursor the last one returned.
9 */
10
11 import { EVENTS_LIMIT_DEFAULT, eventsPage } from "../../core/events_page.ts";
12 import type { ForgeEvent } from "../../core/events.ts";
13 import type { Repo } from "../../core/repos.ts";
14 import { getUsersByIds } from "../../core/users.ts";
15 import { eventActor, eventVerb } from "../../design/activity.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import type { Session } from "../session.ts";
19 import { emptyState } from "../ui/widgets.ts";
20 import { ListState, ago, drawList, type ListRow } from "./common.ts";
21
22 /** What an event is about, when it says: `#12`, or the branch a push moved. */
23 function subjectOf(event: ForgeEvent): string {
24 const number = event.payload["number"];
25 if (typeof number === "number") return `#${number}`;
26 if (event.kind !== "push") return "";
27 const updates = event.payload["updates"];
28 const ref = Array.isArray(updates) ? (updates[0] as { ref?: unknown } | undefined)?.ref : undefined;
29 return typeof ref === "string" ? ref.replace(/^refs\/heads\//, "") : "";
30 }
31
32 /** One row per event: `who verb: subject`, with its age beside it. */
33 function eventRows(f: Session, events: readonly ForgeEvent[]): ListRow[] {
34 const actors = getUsersByIds(f.db, events.map((e) => e.actorId ?? "").filter(Boolean));
35 return events.map((event) => {
36 const who = eventActor(event, event.actorId === null ? undefined : actors.get(event.actorId)?.username);
37 const subject = subjectOf(event);
38 const title = `${who} ${eventVerb(event)}${subject === "" ? "" : `: ${subject}`}`;
39 return { title: title.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " "), meta: ago(event.createdAt) };
40 });
41 }
42
43 export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
44 const state = new ListState();
45 let count = 0;
46 const rows: ListRow[] = [];
47 let next: string | null = null;
48 const read = (cursor?: string): void => {
49 const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor);
50 count += page.items.length;
51 rows.push(...eventRows(f, page.items));
52 next = page.next_cursor;
53 ctx.invalidate();
54 };
55 read();
56 return {
57 title: "Events",
58 get keys() {
59 return [
60 binding(["j", "k"], "move"),
61 ...(next === null ? [] : [binding(["n"], "next page")]),
62 binding(["esc", "q"], "back"),
63 ];
64 },
65 render(area, s) {
66 if (count === 0) {
67 emptyState(s, area, ctx.theme.c, {
68 title: "No events yet",
69 body: "Pushes, merges and deploys land here.",
70 });
71 return;
outdated

eventRows runs on every render: one getUsersByIds query plus a regex per event, over a list that grows by 50 with each n. Build the rows once in read() (rows.push(...eventRows(f, page.items))) and pass rows to drawList.

72 }
73 drawList(s, area, ctx.theme.c, state, rows);
74 },
75 key(e) {
76 if (e.key === "esc" || e.key === "q") ctx.pop();
77 else if (e.key === "n") {
78 if (next !== null) read(next);
79 } else state.key(e, count);
80 return true;
81 },
82 };
83 }
test/tui-events.test.ts added +101
@@ -0,0 +1,101 @@
1 /** `v` on the About card reads the repository's events as MCP `events` does. */
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 { createRepo } from "../src/actions/repos.ts";
7 import { signUp } from "../src/actions/users.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import type { User } from "../src/core/users.ts";
11 import { createForgeApp } from "../src/tui/forge.ts";
12 import { aboutScreen } from "../src/tui/screens/about.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { localSource } from "../src/view/local.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17 import { fixtureSlot } from "./helpers/slot.ts";
18 import { seedAdmin } from "./helpers/admin.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: Forge;
24 let alice: User;
25 let bob: User;
26 let repo: Repo;
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-tui-events-"));
30 forge = createApp({ dataDir: dir, port: 0 });
31 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
32 if (!a.ok) throw new Error("signup failed");
33 alice = a.value;
34 const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) });
35 if (!b.ok) throw new Error("signup failed");
36 bob = b.value;
37 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
38 if (!r.ok) throw new Error("repo failed");
39 repo = r.value;
40 forge.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: alice.id, payload: { number: 1 } });
41 forge.bus.append({
42 kind: "webhook.created",
43 repoId: repo.id,
44 actorId: alice.id,
45 payload: { url: "https://hooks.example/secret-path" },
46 });
47 for (let i = 0; i < 60; i++)
48 forge.bus.append({ kind: "push", repoId: repo.id, actorId: alice.id, payload: { updates: [{ ref: `refs/heads/b${i}` }] } });
49 });
50 afterAll(async () => {
51 forge.close?.();
52 await rm(dir, { recursive: true, force: true });
53 });
54
55 /** Open the About card as `who`, press `v`, then the given keys; return the card's and the list's text. */
56 async function open(who: User, keys: string[] = []): Promise<{ about: string; events: string }> {
57 const { app, session } = createForgeApp(
58 forge,
59 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
60 { terminal: headlessTerminal(120, 40), exitHooks: false },
61 );
62 app.start();
63 await settleApp(app);
64 const src = localSource(forge, repo, who);
65 const about = aboutScreen(session, app.ctx, { render() {} } as never, src, undefined, "main");
66 app.push(about);
67 await settleApp(app);
68 const card = app.screen.toText();
69 for (const key of ["v", ...keys]) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
70 await settleApp(app);
71 const events = app.screen.toText();
72 app.stop();
73 return { about: card, events };

toContain("events") matches any occurrence of the word. Assert the hint (v followed by events) so the test fails if the binding is removed.

74 }
75
76 test("the About card offers v, and v lists the events newest first", async () => {
77 const { about, events } = await open(alice);
78 expect(about).toMatch(/\bv\b[^\n]*events/);
79 expect(events).toContain("alice pushed: b59");
80 expect(events.indexOf("alice pushed: b59")).toBeLessThan(events.indexOf("alice pushed: b58"));
81 expect(events).toContain("next page");
82 });
83
84 test("n loads the next page past 50, ending at the oldest event", async () => {
85 const first = await open(alice, ["G"]);
86 expect(first.events).toContain("alice pushed: b10");
87 expect(first.events).not.toContain("alice opened an issue: #1");
88 const all = await open(alice, ["n", "G"]);
89 expect(all.events).toContain("alice opened an issue: #1");
90 expect(all.events).not.toContain("next page");
91 });
92
93 test("an admin sees the webhook event; a reader does not, nor its URL", async () => {
94 const admin = await open(alice, ["n", "G"]);
95 expect(admin.events).toContain("alice added a webhook");
96 expect(admin.events).not.toContain("hooks.example");
97 const reader = await open(bob, ["n", "G"]);
98 expect(reader.events).not.toContain("added a webhook");
99 expect(reader.events).not.toContain("hooks.example");
100 expect(reader.events).toContain("alice opened an issue: #1");
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs