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

Merged opened this

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

Merged

mason/forge:mason/7510-owner-name-events-lists-a-repository-s-e → main 7 commits fork

  • Merged. mason/7510-owner-name-events-lists-a-repository-s-e was merged into main as 1f3c7a6.

Review

Sign in to review this change.
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 {
183183 } from "../web/pages/edit.ts";
184184 import { configPage, configSubmit } from "../web/pages/config.ts";
185185 import { commitPage, commitsPage } from "../web/pages/commits.ts";
186 import { eventsList } from "../web/pages/events.ts";
186187 import {
187188 issueCommentSubmit,
188189 issuePrivateSubmit,
@@ -1105,6 +1106,7 @@ export function buildRouter(): Router<Handler> {
11051106 router.add("POST", "/:owner/:name/app/notes", noteModerate);
11061107 router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy);
11071108 router.add("POST", "/:owner/:name/app/:env/stop", appStop);
1109 router.add("GET", "/:owner/:name/events", eventsList);
11081110 router.add("GET", "/:owner/:name/runs", runsList);
11091111 router.add("GET", "/:owner/:name/runs/:id", runPage);
11101112 router.add("POST", "/:owner/:name/runs/:id/cancel", runCancel);
src/web/chrome.ts +12
@@ -125,6 +125,7 @@ export type RepoTab =
125125 | "branches"
126126 | "app"
127127 | "runs"
128 | "events"
128129 | "api"
129130 | "settings"
130131 /** A page that belongs to no tab (waves): nothing is marked current. */
@@ -293,6 +294,15 @@ export function scopeHeader(
293294 current: active === "runs",
294295 shortcut: "g r",
295296 });
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 });
296306 // An app that serves /openapi.json gets its API reference one tab over.
297307 // The spec is read in the background and remembered, so a header never waits.
298308 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[] {
450460 ...(mayReadRuns(ctx.app, ctx.auth.user, repo)
451461 ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }]
452462 : []),
463 { t: `${name} — Events`, h: `${base}/events`, c: "This repo" },
453464 { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" },
454465 ];
455466 }
@@ -463,6 +474,7 @@ export function repoPalette(ctx: Ctx, repo: Repo): PaletteItem[] {
463474 ...(mayReadRuns(ctx.app, ctx.auth.user, repo)
464475 ? [{ t: `${name} — Runs`, h: `${base}/runs`, c: "This repo" }]
465476 : []),
477 { t: `${name} — Events`, h: `${base}/events`, c: "This repo" },
466478 { t: `${name} — Settings`, h: `${base}/settings`, c: "This repo" },
467479 ];
468480 }
src/web/feeds.ts +1 −1
@@ -60,7 +60,7 @@ export const stamp = (iso: string): string =>
6060 /Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`;
6161
6262 /** 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 {
6464 const updates = event.payload["updates"];
6565 const ref = Array.isArray(updates)
6666 ? (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(
302302 if (local) {
303303 items.push(
304304 { t: `${name} — New issue`, h: `${base}/issues/new`, c: here },
305 { t: `${name} — Events`, h: `${base}/events`, c: here },
305306 { t: `${name} — Settings`, h: `${base}/settings`, c: here },
306307 );
307308 }
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("&amp;", "&"), 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs