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

The events page narrows to one kind: /:owner/:name/events?kind=pull.merged, and each row's kind links to it #8466

Merged opened this

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

Merged

mason/forge:mason/8430-the-events-page-narrows-to-one-kind-owne → main 4 commits fork

  • Merged. mason/8430-the-events-page-narrows-to-one-kind-owne was merged into main as 1db7070.

Review

Sign in to review this change.
7 files changed +109 −28
README.md +1 −1
@@ -512,7 +512,7 @@ This page is the front door. Everything else is a file of its own under
512512 - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none).
513513 - Stop running as an app at the terminal: `X` on the Deploys screen, offered only to the repository's admin who is a person while it is an app (a writer who is not an admin, an agent, a reader or a guest sees no `X` and it does nothing). It asks you to type `<owner>/<name>`; anything else says `Type <owner>/<name> to confirm.` and changes nothing. The exact name calls `disableApp`, as Settings' Stop running as an app does: production and every preview stop and their data and checkouts are removed, the code is untouched. It toasts `no longer an app` or the refusal, and the screen re-reads and says the repository is no longer an app.
514514 - Everyday account at the terminal: a forge admin with no choice made sees a muted line at the head of the dashboard asking, as the web home card does. `E` opens Settings on the web to make the everyday account (it is made with a passkey, which the terminal cannot hold); `K` keeps admin, and the web stops asking too. Once linked, the line names the everyday account. Anyone else, and an agent, sees neither key.
515 - 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, `s` keeps only settings changes (the same `kind=repo.settings_updated` filter) and shows each as who changed what, from what to what (`alice changed repository settings: requireReview false → true`), `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').
515 - 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, `t` keeps only the kind of the row under the cursor (and again lifts it), `s` keeps only settings changes (the same `kind=repo.settings_updated` filter) and shows each as who changed what, from what to what (`alice changed repository settings: requireReview false → true`), `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').
516516 - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`).
517517 - [`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.
518518 - A ready issue held on a hot file names the open pulls on that file, oldest first, at most 8: `held.pulls` in `GET …/map` and the MCP `map` tool, links after the sentence in the flow panel's "Ready, and why it waits" card, and `#N` lines in the terminal Flow dialog. Read from the pull files the picker caches (`src/crew/hot_files.ts`), per reader: a sealed pull is named and counted for the owner only.
docs/features/events.md +3 −1
@@ -2,6 +2,8 @@
22
33 `/:owner/:name/events` (the **Events** tab on a repository, and `<owner>/<name> — Events` in the command palette) lists the repository's events newest first, 50 a page. `?cursor=` pages to older ones; a malformed cursor answers 404.
44
5 `?kind=` narrows to one event kind (`?kind=pull.merged`), the same `kind` MCP `events` and `GET …/events` take; one that is not a kind (`validEventsKind`) answers 404. Each row has a button beside it in a person's words ("Merged pulls", from `eventKindLabel`) that links to its kind, 44px tall on a thumb; the raw kind appears only in the URL. A narrowed page says "Showing: Merged pulls" with an **All events** button beside the heading, and the next-page link keeps `kind`. A kind with no events says "No <kind> events" and offers All events.
6
57 The audience is the same as MCP `events` and `GET …/events`, because the page reads the same `eventsPage`: a webhook's URL and an app's variable names are the admins'. A public repository answers a guest with 200; a private one answers 404 to anyone who cannot read it. A row links to its page when the event has one (an issue, a push's commits) and is a plain row otherwise.
68
7 The terminal's version is `v` on the About card (see the README). `enter` on a row opens the issue or pull the event names (a `pull.*` event opens the pull, any other the issue); the issue and pull screens check access themselves, and `esc` returns to the list at the same row. A push or a settings row names no number and opens nothing, and the key bar shows `enter open` only when the selected row can open something.
9 The terminal's version is `v` on the About card (see the README). `enter` on a row opens the issue or pull the event names (a `pull.*` event opens the pull, any other the issue); the issue and pull screens check access themselves, and `esc` returns to the list at the same row. A push or a settings row names no number and opens nothing, and the key bar shows `enter open` only when the selected row can open something. `t` keeps only the kind of the row under the cursor and `t` again lifts it; `s` keeps only settings changes.
src/design/activity.ts +19
@@ -82,6 +82,25 @@ export function eventVerb(event: ForgeEvent): string {
8282 return WORDS[kind] ?? "acted on this repository";
8383 }
8484
85 const KIND_LABELS: Record<string, string> = {
86 push: "Pushes",
87 "issue.opened": "Opened issues",
88 "issue.closed": "Closed issues",
89 "pull.opened": "Opened pulls",
90 "pull.merged": "Merged pulls",
91 "pull.closed": "Closed pulls",
92 "webhook.created": "New webhooks",
93 "repo.settings_updated": "Settings changes",
94 };
95
96 /** An event kind in a person's words, for a button that narrows to it: `Merged pulls`. */
97 export function eventKindLabel(kind: string): string {
98 const known = KIND_LABELS[kind];
99 if (known !== undefined) return known;
100 const words = kind.replace(/[._]/g, " ");
101 return words.charAt(0).toUpperCase() + words.slice(1);
102 }
103
85104 /** Who to credit when the row names nobody. */
86105 export function eventActor(
87106 event: ForgeEvent,
src/tui/screens/events.ts +23 −15
@@ -6,14 +6,15 @@
66 * only the admins. One line per event — when, who, what — in the words the
77 * activity feed uses (`eventActor`, `eventVerb`). Fifty at a time; `n` reads
88 * the next page from the cursor the last one returned; `s` keeps only the
9 * settings changes (the same `kind` filter), each saying from what to what.
9 * settings changes (the same `kind` filter), each saying from what to what;
10 * `t` keeps only the kind of the row under the cursor, and again lifts it.
1011 */
1112
1213 import { EVENTS_LIMIT_DEFAULT, eventsPage } from "../../core/events_page.ts";
1314 import type { ForgeEvent } from "../../core/events.ts";
1415 import type { Repo } from "../../core/repos.ts";
1516 import { getUsersByIds } from "../../core/users.ts";
16 import { eventActor, eventVerb } from "../../design/activity.ts";
17 import { eventActor, eventKindLabel, eventVerb } from "../../design/activity.ts";
1718 import type { Ctx, Screen } from "../app/app.ts";
1819 import { binding } from "../app/keys.ts";
1920 import type { Session } from "../session.ts";
@@ -77,10 +78,22 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
7778 const opens: ({ pull: boolean; number: number } | undefined)[] = [];
7879 let next: string | null = null;
7980 let kind: string | undefined;
81 const kinds: string[] = [];
82 const narrow = (to: string | undefined): void => {
83 kind = to;
84 count = 0;
85 rows.length = 0;
86 kinds.length = 0;
87 opens.length = 0;
88 state.index = 0;
89 state.offset = 0;
90 read();
91 };
8092 const read = (cursor?: string): void => {
8193 const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor, kind);
8294 count += page.items.length;
8395 rows.push(...eventRows(f, page.items));
96 kinds.push(...page.items.map((e) => e.kind));
8497 opens.push(...page.items.map(opensOf));
8598 next = page.next_cursor;
8699 ctx.invalidate();
@@ -88,22 +101,23 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
88101 read();
89102 return {
90103 get title() {
91 return kind === undefined ? "Events" : "Events · settings";
104 return kind === undefined ? "Events" : `Events · ${eventKindLabel(kind).toLowerCase()}`;
92105 },
93106 get keys() {
94107 return [
95108 binding(["j", "k"], "move"),
96109 ...(opens[state.index] === undefined ? [] : [binding(["enter"], "open")]),
97110 ...(next === null ? [] : [binding(["n"], "next page")]),
98 binding(["s"], kind === undefined ? "settings only" : "all events"),
111 binding(["s"], kind === SETTINGS_KIND ? "all events" : "settings only"),
112 binding(["t"], kind === undefined ? "this kind only" : "all events"),
99113 binding(["esc", "q"], "back"),
100114 ];
101115 },
102116 render(area, s) {
103117 if (count === 0) {
104118 emptyState(s, area, ctx.theme.c, {
105 title: kind === undefined ? "No events yet" : "No settings changes yet",
106 body: kind === undefined ? "Pushes, merges and deploys land here." : "Press s for every event.",
119 title: kind === undefined ? "No events yet" : `No ${kind} events`,
120 body: kind === undefined ? "Pushes, merges and deploys land here." : "Press t for every event.",
107121 });
108122 return;
109123 }
@@ -119,15 +133,9 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
119133 const src = local(f, repo);
120134 ctx.push(target.pull ? pullScreen(f, ctx, src, target.number) : issueScreen(f, ctx, src, target.number));
121135 }
122 } else if (e.key === "s") {
123 kind = kind === undefined ? SETTINGS_KIND : undefined;
124 count = 0;
125 rows.length = 0;
126 opens.length = 0;
127 state.index = 0;
128 state.offset = 0;
129 read();
130 } else state.key(e, count);
136 } else if (e.key === "s") narrow(kind === SETTINGS_KIND ? undefined : SETTINGS_KIND);
137 else if (e.key === "t") narrow(kind === undefined ? kinds[state.index] : undefined);
138 else state.key(e, count);
131139 return true;
132140 },
133141 };
src/web/pages/events.ts +20 −9
@@ -5,13 +5,14 @@
55 * filter runs there, so a webhook's URL and an app's variable names reach
66 * only the admins. One row per event — who, what, when — in the words the
77 * activity feed uses, linked where the event has a page. Fifty a page;
8 * `?cursor=` reads the next. Server-rendered, no script.
8 * `?cursor=` reads the next, `?kind=` narrows to one kind (each row's kind
9 * links to it). Server-rendered, no script.
910 */
1011
11 import { EVENTS_LIMIT_DEFAULT, eventsPage, validEventsCursor } from "../../core/events_page.ts";
12 import { EVENTS_LIMIT_DEFAULT, eventsPage, validEventsCursor, validEventsKind } from "../../core/events_page.ts";
1213 import type { ForgeEvent } from "../../core/events.ts";
1314 import { getUsersByIds } from "../../core/users.ts";
14 import { eventActor, eventVerb } from "../../design/activity.ts";
15 import { eventActor, eventKindLabel, eventVerb } from "../../design/activity.ts";
1516 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1617 import type { Ctx } from "../../http/context.ts";
1718 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
@@ -19,7 +20,7 @@ import { pushedBranch } from "../feeds.ts";
1920 import { html, type Html } from "../html.ts";
2021 import { page } from "../layout.ts";
2122 import { localScope } from "../scope.ts";
22 import { avatar, empty, listCard, pager, row, time } from "../ui.ts";
23 import { avatar, btn, empty, listCard, pager, row, time } from "../ui.ts";
2324
2425 /** Where an event has a page: its issue or pull, a push's commits. */
2526 function linkOf(base: string, event: ForgeEvent): string | undefined {
@@ -29,7 +30,7 @@ function linkOf(base: string, event: ForgeEvent): string | undefined {
2930 return event.kind === "push" ? `${base}/commits` : undefined;
3031 }
3132
32 function eventRows(ctx: Ctx, base: string, events: readonly ForgeEvent[]): Html {
33 function eventRows(ctx: Ctx, base: string, narrow: (kind: string) => string, events: readonly ForgeEvent[]): Html {
3334 const actors = getUsersByIds(
3435 ctx.app.db,
3536 events.map((e) => e.actorId ?? "").filter(Boolean),
@@ -45,6 +46,7 @@ function eventRows(ctx: Ctx, base: string, events: readonly ForgeEvent[]): Html
4546 lead: avatar(who),
4647 title: `${who} ${eventVerb(event)}${subject === "" ? "" : `: ${subject}`}`,
4748 trail: time(event.createdAt),
49 after: btn(eventKindLabel(event.kind), { href: narrow(event.kind), size: "sm", variant: "outline" }),
4850 ...(href === undefined ? {} : { href, nav: true }),
4951 });
5052 }),
@@ -57,18 +59,27 @@ export function eventsList(ctx: Ctx): Response {
5759 if (!repo) return notFoundPage(ctx);
5860 const raw = ctx.url.searchParams.get("cursor");
5961 if (raw !== null && !validEventsCursor(raw)) return notFoundPage(ctx);
62 const kind = ctx.url.searchParams.get("kind");
63 if (kind !== null && !validEventsKind(kind)) return notFoundPage(ctx);
6064 const base = repoBase(repo);
61 const read = eventsPage(ctx.app.db, repo.id, ctx.auth.user, EVENTS_LIMIT_DEFAULT, raw ?? undefined);
65 const narrow = (k: string) => `${base}/events?kind=${encodeURIComponent(k)}`;
66 const read = eventsPage(ctx.app.db, repo.id, ctx.auth.user, EVENTS_LIMIT_DEFAULT, raw ?? undefined, kind ?? undefined);
67 const allEvents = btn("All events", { href: `${base}/events`, size: "sm", variant: "outline" });
6268 const rendering = performance.now();
6369 const scope = localScope(ctx, repo);
6470 const body = html`${scopeHeader(ctx, scope, "events")}
71 ${kind === null
72 ? null
73 : html`<div class="bar mt"><h2>Showing: ${eventKindLabel(kind)}</h2>${allEvents}</div>`}
6574 <div class="mt">
6675 ${read.items.length === 0
67 ? empty({ icon: "activity", title: "No events yet", note: "Pushes, merges and deploys land here." })
68 : eventRows(ctx, base, read.items)}
76 ? kind === null
77 ? empty({ icon: "activity", title: "No events yet", note: "Pushes, merges and deploys land here." })
78 : empty({ icon: "activity", title: `No ${kind} events`, action: allEvents })
79 : eventRows(ctx, base, narrow, read.items)}
6980 </div>
7081 ${pager({
71 ...(read.next_cursor === null ? {} : { next: `${base}/events?cursor=${encodeURIComponent(read.next_cursor)}` }),
82 ...(read.next_cursor === null ? {} : { next: `${base}/events?${kind === null ? "" : `kind=${encodeURIComponent(kind)}&`}cursor=${encodeURIComponent(read.next_cursor)}` }),
7283 })}`;
7384 const response = page({
7485 ctx,
test/tui-events.test.ts +10
@@ -154,3 +154,13 @@ test("opensOf names the number and the screen, and nothing for a number that is
154154 expect(opensOf(ev("issue.opened"))).toBeUndefined();
155155 expect(opensOf(ev("push"))).toBeUndefined();
156156 });
157
158 test("t keeps only the kind of the row under the cursor, and again lifts it", async () => {
159 const narrowed = await open(alice, ["G", "G", "t"]);
160 expect(narrowed.events).toContain("alice pushed: b");
161 expect(narrowed.events).toContain("Events · pushes");
162 expect(narrowed.events).not.toContain("alice opened an issue");
163 const lifted = await open(alice, ["t", "t"]);
164 expect(lifted.events).toContain("alice pushed: b59");
165 expect(lifted.events).not.toContain("Events · ");
166 });
test/web-events.test.ts +33 −2
@@ -111,8 +111,11 @@ test("a push row links to the commits, an event with no page is a row without a
111111 const page = await (await get("/alice/mix/events", ownerCookie)).text();
112112 expect(page).toMatch(/<a href="\/alice\/mix\/commits"[^>]*>[\s\S]*?: trunk/);
113113 const hook = page.split("<li").find((li) => li.includes("added a webhook"))!.split("</li>")[0]!;
114 expect(hook).not.toContain("<a ");
115 expect(hook).toContain('<div class="row"');
114 const rowMarkup = hook.match(/<div class="row"[\s\S]*?<\/div>/)![0];
115 expect(rowMarkup).not.toMatch(/<a[\s>]/);
116 // the only link in the item is the kind button, outside the row.
117 expect([...hook.matchAll(/<a[\s>]/g)].length).toBe(1);
118 expect(hook).toContain('href="/alice/mix/events?kind=webhook.created"');
116119 });
117120
118121 test("the repository header has an Events tab, the palette an Events entry", async () => {
@@ -132,3 +135,31 @@ test("a guest reads a public repository's events; the palette lists Events once"
132135 expect(page.split("pub — Events").length - 1).toBe(1);
133136 }
134137 });
138
139 test("?kind= narrows to one kind, keeps it on the cursor link, and refuses a bad one", async () => {
140 expect((await createRepo(app, alice, { name: "kinds", visibility: "public" })).ok).toBe(true);
141 const kinds = getRepo(app.db, "alice", "kinds")!;
142 app.db.query("DELETE FROM event WHERE repo_id = ?1").run(kinds.id);
143 app.bus.append({ kind: "push", repoId: kinds.id, actorId: alice.id, payload: { updates: [{ ref: "refs/heads/trunk" }] } });
144 app.bus.append({ kind: "pull.merged", repoId: kinds.id, actorId: alice.id, payload: { number: 7 } });
145 const all = await (await get("/alice/kinds/events", bobCookie)).text();
146 expect(all).toContain('href="/alice/kinds/events?kind=pull.merged"');
147 const only = await (await get("/alice/kinds/events?kind=push", bobCookie)).text();
148 expect(titles(only).length).toBe(1);
149 expect(only).toContain(": trunk");
150 expect(only).not.toContain("#7");
151 expect(only).toContain("All events");
152 expect(only).toContain("Showing: Pushes");
153 expect(all).toContain("Merged pulls");
154 const paged = await (await get("/alice/pub/events?kind=issue.opened", bobCookie)).text();
155 expect(paged).toMatch(/href="\/alice\/pub\/events\?kind=issue\.opened&amp;cursor=[^"]+"/);
156 for (const bad of ["<script>", "Push", "a%20b"])
157 expect((await get(`/alice/kinds/events?kind=${bad}`, bobCookie)).status).toBe(404);
158 });
159
160 test("a kind with no events says so and offers All events", async () => {
161 const page = await (await get("/alice/kinds/events?kind=deploy.finished", bobCookie)).text();
162 expect(page).toContain("No deploy.finished events");
163 expect(page).not.toContain("No events yet");
164 expect(page).toContain('href="/alice/kinds/events"');
165 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs