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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7442-the-terminal-s-about-card-opens-the-repo → main 3 commits fork 3 threads · 3 unresolved
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 | ||
| 407 | 407 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 408 | 408 | - 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. |
| 409 | 409 | - 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'). | |
| 410 | 411 | - [](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. |
| 411 | 412 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
| 412 | 413 | - 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"; | ||
| 60 | 60 | import type { Session } from "../session.ts"; |
| 61 | 61 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts"; |
| 62 | 62 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 63 | import { eventsScreen } from "./events.ts"; | |
| 63 | 64 | import { newPullScreen } from "./newpull.ts"; |
| 64 | 65 | import { pullScreen } from "./pull.ts"; |
| 65 | 66 | import { forkOf, hostOf, liveOrigin, local, localRepo, sourceName } from "./where.ts"; |
| @@ -154,6 +155,7 @@ export function aboutScreen( | ||
| 154 | 155 | get keys() { |
| 155 | 156 | return [ |
| 156 | 157 | binding(["y"], "copy the clone URL", { disabled: cloneUrl === "" }), |
| 158 | ...(repo === undefined ? [] : [binding(["v"], "events")]), | |
| 157 | 159 | // Only an admin is offered the key, and only an admin's press counts. |
| 158 | 160 | ...(may() |
| 159 | 161 | ? [ |
| @@ -408,6 +410,10 @@ export function aboutScreen( | ||
| 408 | 410 | ctx.invalidate(); |
| 409 | 411 | return true; |
| 410 | 412 | } |
| 413 | if (e.key === "v" && repo !== undefined) { | |
| 414 | ctx.push(eventsScreen(f, ctx, repo)); | |
| 415 | return true; | |
| 416 | } | |
| 411 | 417 | if (e.key === "e" && repo !== undefined && f.user?.isAdmin === true) { |
| 412 | 418 | ctx.push(egressScreen(f, ctx, repo)); |
| 413 | 419 | 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
|
||
| 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 }; | |
|
||
| 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 | }); |
new above
offline
The README needs a line for this key next to the other About-card keys (README.md ~417-418): what
vshows, thatnpages, and that the audience matchesGET …/eventsand MCPevents.