Public: anyone can read this; the crew can change it.
/:owner/:name/events lists a repository's events in the browser, newest first and paged, as MCP `events` reads them #8421
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7510-owner-name-events-lists-a-repository-s-e → main 7 commits fork
7 files
changed +239
−1
docs/features/events.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # Events in the browser | |
| 2 | ||
| 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 | ||
| 5 | 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 | ||
| 7 | The terminal's version is `v` on the About card (see the README). |
src/http/server.ts +2
| @@ -183,6 +183,7 @@ import { | ||
| 183 | 183 | } from "../web/pages/edit.ts"; |
| 184 | 184 | import { configPage, configSubmit } from "../web/pages/config.ts"; |
| 185 | 185 | import { commitPage, commitsPage } from "../web/pages/commits.ts"; |
| 186 | import { eventsList } from "../web/pages/events.ts"; | |
| 186 | 187 | import { |
| 187 | 188 | issueCommentSubmit, |
| 188 | 189 | issuePrivateSubmit, |
| @@ -1105,6 +1106,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1105 | 1106 | router.add("POST", "/:owner/:name/app/notes", noteModerate); |
| 1106 | 1107 | router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy); |
| 1107 | 1108 | router.add("POST", "/:owner/:name/app/:env/stop", appStop); |
| 1109 | router.add("GET", "/:owner/:name/events", eventsList); | |
| 1108 | 1110 | router.add("GET", "/:owner/:name/runs", runsList); |
| 1109 | 1111 | router.add("GET", "/:owner/:name/runs/:id", runPage); |
| 1110 | 1112 | router.add("POST", "/:owner/:name/runs/:id/cancel", runCancel); |
src/web/chrome.ts +12
| @@ -125,6 +125,7 @@ export type RepoTab = | ||
| 125 | 125 | | "branches" |
| 126 | 126 | | "app" |
| 127 | 127 | | "runs" |
| 128 | | "events" | |
| 128 | 129 | | "api" |
| 129 | 130 | | "settings" |
| 130 | 131 | /** A page that belongs to no tab (waves): nothing is marked current. */ |
| @@ -293,6 +294,15 @@ export function scopeHeader( | ||
| 293 | 294 | current: active === "runs", |
| 294 | 295 | shortcut: "g r", |
| 295 | 296 | }); |
| 297 | // What happened on the repository, as the reader may see it: the events | |
| 298 | // page, for any repository kept here. | |
| 299 | if (repo !== undefined) | |
| 300 | items.push({ | |
| 301 | label: "Events", | |
| 302 | href: `${base}/events`, | |
| 303 | icon: "activity", | |
| 304 | current: active === "events", | |
| 305 | }); | |
| 296 | 306 | // An app that serves /openapi.json gets its API reference one tab over. |
| 297 | 307 | // The spec is read in the background and remembered, so a header never waits. |
| 298 | 308 | if (repo !== undefined && record !== undefined && canEnterApp(ctx.app.db, repo, record, ctx.auth.user)) { |
| @@ -450,6 +460,7 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | ||
| 450 | 460 | ...(mayReadRuns(ctx.app, ctx.auth.user, repo) |
| 451 | 461 | ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }] |
| 452 | 462 | : []), |
| 463 | { t: `${name} — Events`, h: `${base}/events`, c: "This repo" }, | |
| 453 | 464 | { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" }, |
| 454 | 465 | ]; |
| 455 | 466 | } |
| @@ -463,6 +474,7 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] { | ||
| 463 | 474 | ...(mayReadRuns(ctx.app, ctx.auth.user, repo) |
| 464 | 475 | ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }] |
| 465 | 476 | : []), |
| 477 | { t: `${name} — Events`, h: `${base}/events`, c: "This repo" }, | |
| 466 | 478 | { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" }, |
| 467 | 479 | ]; |
| 468 | 480 | } |
src/web/feeds.ts +1 −1
| @@ -60,7 +60,7 @@ export const stamp = (iso: string): string => | ||
| 60 | 60 | /Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`; |
| 61 | 61 | |
| 62 | 62 | /** The branch a push moved, from the one payload field a push always has. */ |
| 63 | function pushedBranch(event: ForgeEvent): string { | |
| 63 | export function pushedBranch(event: ForgeEvent): string { | |
| 64 | 64 | const updates = event.payload["updates"]; |
| 65 | 65 | const ref = Array.isArray(updates) |
| 66 | 66 | ? (updates[0] as { ref?: unknown } | undefined)?.ref |
src/web/pages/events.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * GET /:owner/:name/events — 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 row per event — who, what, when — in the words the | |
| 7 | * activity feed uses, linked where the event has a page. Fifty a page; | |
| 8 | * `?cursor=` reads the next. Server-rendered, no script. | |
| 9 | */ | |
| 10 | ||
| 11 | import { EVENTS_LIMIT_DEFAULT, eventsPage, validEventsCursor } from "../../core/events_page.ts"; | |
| 12 | import type { ForgeEvent } from "../../core/events.ts"; | |
| 13 | import { getUsersByIds } from "../../core/users.ts"; | |
| 14 | import { eventActor, eventVerb } from "../../design/activity.ts"; | |
| 15 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 16 | import type { Ctx } from "../../http/context.ts"; | |
| 17 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; | |
| 18 | import { pushedBranch } from "../feeds.ts"; | |
| 19 | import { html, type Html } from "../html.ts"; | |
| 20 | import { page } from "../layout.ts"; | |
| 21 | import { localScope } from "../scope.ts"; | |
| 22 | import { avatar, empty, listCard, pager, row, time } from "../ui.ts"; | |
| 23 | ||
| 24 | /** Where an event has a page: its issue or pull, a push's commits. */ | |
| 25 | function linkOf(base: string, event: ForgeEvent): string | undefined { | |
| 26 | const number = event.payload["number"]; | |
| 27 | if (typeof number === "number" && Number.isInteger(number)) | |
| 28 | return `${base}/${event.kind.startsWith("pull.") ? "pulls" : "issues"}/${number}`; | |
| 29 | return event.kind === "push" ? `${base}/commits` : undefined; | |
| 30 | } | |
| 31 | ||
| 32 | function eventRows(ctx: Ctx, base: string, events: readonly ForgeEvent[]): Html { | |
| 33 | const actors = getUsersByIds( | |
| 34 | ctx.app.db, | |
| 35 | events.map((e) => e.actorId ?? "").filter(Boolean), | |
| 36 | ); | |
| 37 | return listCard( | |
| 38 | events.map((event) => { | |
| 39 | const who = eventActor(event, event.actorId === null ? undefined : actors.get(event.actorId)?.username); | |
| 40 | const number = event.payload["number"]; | |
| 41 | const subject = | |
| 42 | typeof number === "number" ? `#${number}` : event.kind === "push" ? pushedBranch(event) : ""; | |
| 43 | const href = linkOf(base, event); | |
| 44 | return row({ | |
| 45 | lead: avatar(who), | |
| 46 | title: `${who} ${eventVerb(event)}${subject === "" ? "" : `: ${subject}`}`, | |
| 47 | trail: time(event.createdAt), | |
| 48 | ...(href === undefined ? {} : { href, nav: true }), | |
| 49 | }); | |
| 50 | }), | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | export function eventsList(ctx: Ctx): Response { | |
| 55 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 56 | const repo = loadRepo(ctx); | |
| 57 | if (!repo) return notFoundPage(ctx); | |
| 58 | const raw = ctx.url.searchParams.get("cursor"); | |
| 59 | if (raw !== null && !validEventsCursor(raw)) return notFoundPage(ctx); | |
| 60 | const base = repoBase(repo); | |
| 61 | const read = eventsPage(ctx.app.db, repo.id, ctx.auth.user, EVENTS_LIMIT_DEFAULT, raw ?? undefined); | |
| 62 | const rendering = performance.now(); | |
| 63 | const scope = localScope(ctx, repo); | |
| 64 | const body = html`${scopeHeader(ctx, scope, "events")} | |
| 65 | <div class="mt"> | |
| 66 | ${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)} | |
| 69 | </div> | |
| 70 | ${pager({ | |
| 71 | ...(read.next_cursor === null ? {} : { next: `${base}/events?cursor=${encodeURIComponent(read.next_cursor)}` }), | |
| 72 | })}`; | |
| 73 | const response = page({ | |
| 74 | ctx, | |
| 75 | title: `Events · ${repo.name}`, | |
| 76 | crumbs: scope.crumbs, | |
| 77 | palette: scope.palette, | |
| 78 | body, | |
| 79 | }); | |
| 80 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 81 | return response; | |
| 82 | } |
src/web/scope.ts +1
| @@ -302,6 +302,7 @@ function repoPalette( | ||
| 302 | 302 | if (local) { |
| 303 | 303 | items.push( |
| 304 | 304 | { t: `${name} — New issue`, h: `${base}/issues/new`, c: here }, |
| 305 | { t: `${name} — Events`, h: `${base}/events`, c: here }, | |
| 305 | 306 | { t: `${name} — Settings`, h: `${base}/settings`, c: here }, |
| 306 | 307 | ); |
| 307 | 308 | } |
test/web-events.test.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | /** The events page reads `eventsPage` as MCP `events` does: 50 a page, newest first, as the reader may see. */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let alice: User; | |
| 21 | let pub: Repo; | |
| 22 | let priv: Repo; | |
| 23 | let ownerCookie = ""; | |
| 24 | let bobCookie = ""; | |
| 25 | ||
| 26 | const get = (path: string, cookie?: string) => | |
| 27 | fetch(`${servers.url}${path}`, { headers: cookie ? { cookie } : {} }); | |
| 28 | ||
| 29 | const titles = (page: string): string[] => | |
| 30 | [...page.matchAll(/class="row-title"\s*>([^<]*)<\/span/g)].map((m) => m[1]!); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dir = await mkdtemp(join(tmpdir(), "forge-web-events-")); | |
| 34 | app = createApp({ dataDir: dir, port: 0 }); | |
| 35 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 36 | if (!a.ok) throw new Error("signup failed"); | |
| 37 | alice = a.value; | |
| 38 | servers = await serve(app); | |
| 39 | for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) | |
| 40 | if (!(await createRepo(app, alice, { name, visibility })).ok) throw new Error("repo failed"); | |
| 41 | pub = getRepo(app.db, "alice", "pub")!; | |
| 42 | priv = getRepo(app.db, "alice", "priv")!; | |
| 43 | const bob = await createUser(app.db, { username: "bob", email: "[email protected]", password: "a-long-passphrase", origin: "seed", trusted: true }); | |
| 44 | if (!bob.ok) throw new Error("bob failed"); | |
| 45 | ownerCookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 46 | bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`; | |
| 47 | app.db.query("DELETE FROM event WHERE repo_id IN (?1, ?2)").run(pub.id, priv.id); | |
| 48 | for (let n = 1; n <= 60; n++) | |
| 49 | app.bus.append({ kind: "issue.opened", repoId: pub.id, actorId: alice.id, payload: { number: n } }); | |
| 50 | app.bus.append({ kind: "issue.opened", repoId: priv.id, actorId: alice.id, payload: { number: 1 } }); | |
| 51 | app.bus.append({ | |
| 52 | kind: "webhook.created", | |
| 53 | repoId: pub.id, | |
| 54 | actorId: alice.id, | |
| 55 | payload: { url: "https://hooks.example/secret-token" }, | |
| 56 | }); | |
| 57 | }, 120_000); | |
| 58 | ||
| 59 | afterAll(async () => { | |
| 60 | servers.stop(); | |
| 61 | app.close(); | |
| 62 | await rm(dir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the first page holds 50 events newest first and the cursor reaches the rest", async () => { | |
| 66 | const first = await (await get("/alice/pub/events", bobCookie)).text(); | |
| 67 | const rows = titles(first); | |
| 68 | expect(rows.length).toBe(50); | |
| 69 | // bob may not see the webhook, so the newest he sees is issue #60. | |
| 70 | expect(rows[0]).toBe("alice opened an issue: #60"); | |
| 71 | expect(rows[49]).toBe("alice opened an issue: #11"); | |
| 72 | expect(first).toContain('href="/alice/pub/issues/60"'); | |
| 73 | const next = first.match(/href="(\/alice\/pub\/events\?cursor=[^"]+)"/)?.[1]; | |
| 74 | expect(next).toBeDefined(); | |
| 75 | const second = titles(await (await get(next!.replaceAll("&", "&"), bobCookie)).text()); | |
| 76 | expect(second.length).toBe(10); | |
| 77 | expect(second[0]).toBe("alice opened an issue: #10"); | |
| 78 | expect(second[9]).toBe("alice opened an issue: #1"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a reader does not see the webhook; the admin does", async () => { | |
| 82 | const reader = await (await get("/alice/pub/events", bobCookie)).text(); | |
| 83 | expect(reader).not.toContain("secret-token"); | |
| 84 | expect(reader).not.toContain("added a webhook"); | |
| 85 | const admin = await (await get("/alice/pub/events", ownerCookie)).text(); | |
| 86 | expect(admin).toContain("added a webhook"); | |
| 87 | expect(titles(admin).length).toBe(50); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a stranger to a private repository gets 404, the owner the page", async () => { | |
| 91 | expect((await get("/alice/priv/events")).status).toBe(404); | |
| 92 | expect((await get("/alice/priv/events", bobCookie)).status).toBe(404); | |
| 93 | expect((await get("/alice/priv/events", ownerCookie)).status).toBe(200); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a malformed cursor is refused", async () => { | |
| 97 | expect((await get("/alice/pub/events?cursor=a%20b", bobCookie)).status).toBe(404); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a push row links to the commits, an event with no page is a row without a link", async () => { | |
| 101 | expect((await createRepo(app, alice, { name: "mix", visibility: "public" })).ok).toBe(true); | |
| 102 | const mix = getRepo(app.db, "alice", "mix")!; | |
| 103 | app.db.query("DELETE FROM event WHERE repo_id = ?1").run(mix.id); | |
| 104 | app.bus.append({ kind: "webhook.created", repoId: mix.id, actorId: alice.id, payload: { url: "https://hooks.example/x" } }); | |
| 105 | app.bus.append({ | |
| 106 | kind: "push", | |
| 107 | repoId: mix.id, | |
| 108 | actorId: alice.id, | |
| 109 | payload: { updates: [{ ref: "refs/heads/trunk" }] }, | |
| 110 | }); | |
| 111 | const page = await (await get("/alice/mix/events", ownerCookie)).text(); | |
| 112 | expect(page).toMatch(/<a href="\/alice\/mix\/commits"[^>]*>[\s\S]*?: trunk/); | |
| 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"'); | |
| 116 | }); | |
| 117 | ||
| 118 | test("the repository header has an Events tab, the palette an Events entry", async () => { | |
| 119 | const page = await (await get("/alice/pub/events", bobCookie)).text(); | |
| 120 | expect(page).toMatch(/<a[^>]*href="\/alice\/pub\/events"[^>]*>[\s\S]*?Events/); | |
| 121 | expect(page).toContain("pub — Events"); | |
| 122 | const code = await (await get("/alice/pub", bobCookie)).text(); | |
| 123 | expect(code).toMatch(/<a[^>]*href="\/alice\/pub\/events"/); | |
| 124 | }); | |
| 125 | ||
| 126 | test("a guest reads a public repository's events; the palette lists Events once", async () => { | |
| 127 | const res = await get("/alice/pub/events"); | |
| 128 | expect(res.status).toBe(200); | |
| 129 | expect(await res.text()).toContain("alice opened an issue: #60"); | |
| 130 | for (const path of ["/alice/pub", "/alice/pub/events"]) { | |
| 131 | const page = await (await get(path, bobCookie)).text(); | |
| 132 | expect(page.split("pub — Events").length - 1).toBe(1); | |
| 133 | } | |
| 134 | }); |
new above
offline