Public: anyone can read this; the crew can change it.
A repository's labels, webhooks, events and an issue's teams name their Server-Timing phases #4995
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4984-a-repository-s-labels-webhooks-events-an → main 1 commit fork
2 files
changed +81
−4
src/http/api.ts +14 −4
| @@ -2473,6 +2473,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2473 | 2473 | }); |
| 2474 | 2474 | |
| 2475 | 2475 | router.add("GET", "/api/v1/repos/:owner/:name/events", (ctx) => { |
| 2476 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2476 | 2477 | const repo = loadRepo(ctx); |
| 2477 | 2478 | if (!repo) return notFound(); |
| 2478 | 2479 | // The outbox, as this reader is meant to see it: a webhook's URL and an |
| @@ -2484,7 +2485,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2484 | 2485 | const cursor = q.get("cursor"); |
| 2485 | 2486 | if (cursor !== null && !validEventsCursor(cursor)) |
| 2486 | 2487 | return apiError("invalid_cursor", 422, "cursor is the next_cursor of a page before"); |
| 2487 | return json(eventsPage(ctx.app.db, repo.id, ctx.auth.user, limit, cursor ?? undefined)); | |
| 2488 | const page = timePhase(ctx.request, "events", () => | |
| 2489 | eventsPage(ctx.app.db, repo.id, ctx.auth.user, limit, cursor ?? undefined), | |
| 2490 | ); | |
| 2491 | return timePhase(ctx.request, "render", () => json(page)); | |
| 2488 | 2492 | }); |
| 2489 | 2493 | |
| 2490 | 2494 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { |
| @@ -3005,9 +3009,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3005 | 3009 | // ── labels ─────────────────────────────────────────────────────────── |
| 3006 | 3010 | |
| 3007 | 3011 | router.add("GET", "/api/v1/repos/:owner/:name/labels", (ctx) => { |
| 3012 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3008 | 3013 | const repo = loadRepo(ctx); |
| 3009 | 3014 | if (!repo) return notFound(); |
| 3010 | return json(listLabels(ctx.app.db, repo.id)); | |
| 3015 | const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id)); | |
| 3016 | return timePhase(ctx.request, "render", () => json(labels)); | |
| 3011 | 3017 | }); |
| 3012 | 3018 | |
| 3013 | 3019 | router.add("POST", "/api/v1/repos/:owner/:name/labels", async (ctx) => { |
| @@ -3072,9 +3078,11 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3072 | 3078 | // Name up to two crew teams on an issue: the pull that closes it is |
| 3073 | 3079 | // reviewed by each (`core/issue_teams.ts`). |
| 3074 | 3080 | router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/teams", (ctx) => { |
| 3081 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3075 | 3082 | const found = loadIssue(ctx); |
| 3076 | 3083 | if ("error" in found) return found.error; |
| 3077 | return json({ teams: teamsOfIssue(ctx.app.db, found.issue.id) }); | |
| 3084 | const teams = timePhase(ctx.request, "teams", () => teamsOfIssue(ctx.app.db, found.issue.id)); | |
| 3085 | return timePhase(ctx.request, "render", () => json({ teams })); | |
| 3078 | 3086 | }); |
| 3079 | 3087 | router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/teams", async (ctx) => { |
| 3080 | 3088 | const denied = requireScope(ctx, "rw"); |
| @@ -4446,11 +4454,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4446 | 4454 | // ── webhooks — admin-scoped, since a webhook exfiltrates repo events ── |
| 4447 | 4455 | |
| 4448 | 4456 | router.add("GET", "/api/v1/repos/:owner/:name/hooks", (ctx) => { |
| 4457 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 4449 | 4458 | const denied = requireScope(ctx, "admin"); |
| 4450 | 4459 | if (denied) return denied; |
| 4451 | 4460 | const repo = loadRepo(ctx); |
| 4452 | 4461 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound(); |
| 4453 | return json(listWebhooks(ctx.app.db, repo.id)); | |
| 4462 | const hooks = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id)); | |
| 4463 | return timePhase(ctx.request, "render", () => json(hooks)); | |
| 4454 | 4464 | }); |
| 4455 | 4465 | |
| 4456 | 4466 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { |
test/repo-reads-timing.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** A repository's labels, hooks, events and an issue's teams name their Server-Timing phases. */ | |
| 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 9 | ||
| 10 | let dataDir = ""; | |
| 11 | let app: App, servers: Servers, bearer = ""; | |
| 12 | ||
| 13 | const call = (path: string, init: RequestInit = {}) => | |
| 14 | fetch(`${servers.url}${path}`, { | |
| 15 | ...init, | |
| 16 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" }, | |
| 17 | }); | |
| 18 | ||
| 19 | const phases = async (path: string): Promise<{ status: number; names: string[] }> => { | |
| 20 | const res = await call(path); | |
| 21 | await res.arrayBuffer(); | |
| 22 | const names = (res.headers.get("server-timing") ?? "").split(", ").filter(Boolean).map((p) => p.split(";")[0]!).filter((n) => n !== "db" && n !== "app"); | |
| 23 | return { status: res.status, names }; | |
| 24 | }; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-rtime-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 31 | const minted = await fetch(`${servers.url}/api/v1/tokens`, { | |
| 32 | method: "POST", | |
| 33 | headers: { "content-type": "application/json" }, | |
| 34 | body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }), | |
| 35 | }); | |
| 36 | bearer = ((await minted.json()) as { token: string }).token; | |
| 37 | const made = await call("/api/v1/repos", { method: "POST", body: JSON.stringify({ name: "yard", visibility: "private" }) }); | |
| 38 | expect(made.status).toBe(201); | |
| 39 | const issue = await call("/api/v1/repos/alice/yard/issues", { method: "POST", body: JSON.stringify({ title: "one" }) }); | |
| 40 | expect(issue.status).toBe(201); | |
| 41 | }); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dataDir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | const cases: [string, string, string][] = [ | |
| 50 | ["labels", "/api/v1/repos/alice/yard/labels", "labels"], | |
| 51 | ["hooks", "/api/v1/repos/alice/yard/hooks", "hooks"], | |
| 52 | ["events", "/api/v1/repos/alice/yard/events", "events"], | |
| 53 | ["teams", "/api/v1/repos/alice/yard/issues/1/teams", "teams"], | |
| 54 | ]; | |
| 55 | ||
| 56 | for (const [name, path, read] of cases) { | |
| 57 | test(`${name} names session, ${read}, render in order`, async () => { | |
| 58 | const got = await phases(path); | |
| 59 | expect(got.status).toBe(200); | |
| 60 | expect(got.names).toEqual(["session", read, "render"]); | |
| 61 | }); | |
| 62 | test(`${name} on a missing repository still names session`, async () => { | |
| 63 | const got = await phases(path.replace("/yard", "/nope")); | |
| 64 | expect(got.status).toBe(404); | |
| 65 | expect(got.names[0]).toBe("session"); | |
| 66 | }); | |
| 67 | } |
new above
offline