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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8430-the-events-page-narrows-to-one-kind-owne → main 4 commits fork
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 | ||
| 512 | 512 | - 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). |
| 513 | 513 | - 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. |
| 514 | 514 | - 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'). | |
| 516 | 516 | - 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`). |
| 517 | 517 | - [](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. |
| 518 | 518 | - 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 @@ | ||
| 2 | 2 | |
| 3 | 3 | `/: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. |
| 4 | 4 | |
| 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 | ||
| 5 | 7 | 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. |
| 6 | 8 | |
| 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 { | ||
| 82 | 82 | return WORDS[kind] ?? "acted on this repository"; |
| 83 | 83 | } |
| 84 | 84 | |
| 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 | ||
| 85 | 104 | /** Who to credit when the row names nobody. */ |
| 86 | 105 | export function eventActor( |
| 87 | 106 | event: ForgeEvent, |
src/tui/screens/events.ts +23 −15
| @@ -6,14 +6,15 @@ | ||
| 6 | 6 | * only the admins. One line per event — when, who, what — in the words the |
| 7 | 7 | * activity feed uses (`eventActor`, `eventVerb`). Fifty at a time; `n` reads |
| 8 | 8 | * 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. | |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import { EVENTS_LIMIT_DEFAULT, eventsPage } from "../../core/events_page.ts"; |
| 13 | 14 | import type { ForgeEvent } from "../../core/events.ts"; |
| 14 | 15 | import type { Repo } from "../../core/repos.ts"; |
| 15 | 16 | import { getUsersByIds } from "../../core/users.ts"; |
| 16 | import { eventActor, eventVerb } from "../../design/activity.ts"; | |
| 17 | import { eventActor, eventKindLabel, eventVerb } from "../../design/activity.ts"; | |
| 17 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 19 | import { binding } from "../app/keys.ts"; |
| 19 | 20 | import type { Session } from "../session.ts"; |
| @@ -77,10 +78,22 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 77 | 78 | const opens: ({ pull: boolean; number: number } | undefined)[] = []; |
| 78 | 79 | let next: string | null = null; |
| 79 | 80 | 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 | }; | |
| 80 | 92 | const read = (cursor?: string): void => { |
| 81 | 93 | const page = eventsPage(f.db, repo.id, f.user, EVENTS_LIMIT_DEFAULT, cursor, kind); |
| 82 | 94 | count += page.items.length; |
| 83 | 95 | rows.push(...eventRows(f, page.items)); |
| 96 | kinds.push(...page.items.map((e) => e.kind)); | |
| 84 | 97 | opens.push(...page.items.map(opensOf)); |
| 85 | 98 | next = page.next_cursor; |
| 86 | 99 | ctx.invalidate(); |
| @@ -88,22 +101,23 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 88 | 101 | read(); |
| 89 | 102 | return { |
| 90 | 103 | get title() { |
| 91 | return kind === undefined ? "Events" : "Events · settings"; | |
| 104 | return kind === undefined ? "Events" : `Events · ${eventKindLabel(kind).toLowerCase()}`; | |
| 92 | 105 | }, |
| 93 | 106 | get keys() { |
| 94 | 107 | return [ |
| 95 | 108 | binding(["j", "k"], "move"), |
| 96 | 109 | ...(opens[state.index] === undefined ? [] : [binding(["enter"], "open")]), |
| 97 | 110 | ...(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"), | |
| 99 | 113 | binding(["esc", "q"], "back"), |
| 100 | 114 | ]; |
| 101 | 115 | }, |
| 102 | 116 | render(area, s) { |
| 103 | 117 | if (count === 0) { |
| 104 | 118 | 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.", | |
| 107 | 121 | }); |
| 108 | 122 | return; |
| 109 | 123 | } |
| @@ -119,15 +133,9 @@ export function eventsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 119 | 133 | const src = local(f, repo); |
| 120 | 134 | ctx.push(target.pull ? pullScreen(f, ctx, src, target.number) : issueScreen(f, ctx, src, target.number)); |
| 121 | 135 | } |
| 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); | |
| 131 | 139 | return true; |
| 132 | 140 | }, |
| 133 | 141 | }; |
src/web/pages/events.ts +20 −9
| @@ -5,13 +5,14 @@ | ||
| 5 | 5 | * filter runs there, so a webhook's URL and an app's variable names reach |
| 6 | 6 | * only the admins. One row per event — who, what, when — in the words the |
| 7 | 7 | * 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. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 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"; | |
| 12 | 13 | import type { ForgeEvent } from "../../core/events.ts"; |
| 13 | 14 | import { getUsersByIds } from "../../core/users.ts"; |
| 14 | import { eventActor, eventVerb } from "../../design/activity.ts"; | |
| 15 | import { eventActor, eventKindLabel, eventVerb } from "../../design/activity.ts"; | |
| 15 | 16 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 16 | 17 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 18 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| @@ -19,7 +20,7 @@ import { pushedBranch } from "../feeds.ts"; | ||
| 19 | 20 | import { html, type Html } from "../html.ts"; |
| 20 | 21 | import { page } from "../layout.ts"; |
| 21 | 22 | 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"; | |
| 23 | 24 | |
| 24 | 25 | /** Where an event has a page: its issue or pull, a push's commits. */ |
| 25 | 26 | function linkOf(base: string, event: ForgeEvent): string | undefined { |
| @@ -29,7 +30,7 @@ function linkOf(base: string, event: ForgeEvent): string | undefined { | ||
| 29 | 30 | return event.kind === "push" ? `${base}/commits` : undefined; |
| 30 | 31 | } |
| 31 | 32 | |
| 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 { | |
| 33 | 34 | const actors = getUsersByIds( |
| 34 | 35 | ctx.app.db, |
| 35 | 36 | events.map((e) => e.actorId ?? "").filter(Boolean), |
| @@ -45,6 +46,7 @@ function eventRows(ctx: Ctx, base: string, events: readonly ForgeEvent[]): Html | ||
| 45 | 46 | lead: avatar(who), |
| 46 | 47 | title: `${who} ${eventVerb(event)}${subject === "" ? "" : `: ${subject}`}`, |
| 47 | 48 | trail: time(event.createdAt), |
| 49 | after: btn(eventKindLabel(event.kind), { href: narrow(event.kind), size: "sm", variant: "outline" }), | |
| 48 | 50 | ...(href === undefined ? {} : { href, nav: true }), |
| 49 | 51 | }); |
| 50 | 52 | }), |
| @@ -57,18 +59,27 @@ export function eventsList(ctx: Ctx): Response { | ||
| 57 | 59 | if (!repo) return notFoundPage(ctx); |
| 58 | 60 | const raw = ctx.url.searchParams.get("cursor"); |
| 59 | 61 | 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); | |
| 60 | 64 | 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" }); | |
| 62 | 68 | const rendering = performance.now(); |
| 63 | 69 | const scope = localScope(ctx, repo); |
| 64 | 70 | 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>`} | |
| 65 | 74 | <div class="mt"> |
| 66 | 75 | ${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)} | |
| 69 | 80 | </div> |
| 70 | 81 | ${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)}` }), | |
| 72 | 83 | })}`; |
| 73 | 84 | const response = page({ |
| 74 | 85 | 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 | ||
| 154 | 154 | expect(opensOf(ev("issue.opened"))).toBeUndefined(); |
| 155 | 155 | expect(opensOf(ev("push"))).toBeUndefined(); |
| 156 | 156 | }); |
| 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 | ||
| 111 | 111 | const page = await (await get("/alice/mix/events", ownerCookie)).text(); |
| 112 | 112 | expect(page).toMatch(/<a href="\/alice\/mix\/commits"[^>]*>[\s\S]*?: trunk/); |
| 113 | 113 | 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"'); | |
| 116 | 119 | }); |
| 117 | 120 | |
| 118 | 121 | 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" | ||
| 132 | 135 | expect(page.split("pub — Events").length - 1).toBe(1); |
| 133 | 136 | } |
| 134 | 137 | }); |
| 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&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 | }); |
new above
offline