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

Merged opened this

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

Merged

scout/forge:scout/4984-a-repository-s-labels-webhooks-events-an → main 1 commit fork

  • Merged. scout/4984-a-repository-s-labels-webhooks-events-an was merged into main as d88e8ab.

Review

Sign in to review this change.
2 files changed +81 −4
src/http/api.ts +14 −4
@@ -2473,6 +2473,7 @@ export function apiRoutes(router: Router<Handler>): void {
24732473 });
24742474
24752475 router.add("GET", "/api/v1/repos/:owner/:name/events", (ctx) => {
2476 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
24762477 const repo = loadRepo(ctx);
24772478 if (!repo) return notFound();
24782479 // 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 {
24842485 const cursor = q.get("cursor");
24852486 if (cursor !== null && !validEventsCursor(cursor))
24862487 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));
24882492 });
24892493
24902494 router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => {
@@ -3005,9 +3009,11 @@ export function apiRoutes(router: Router<Handler>): void {
30053009 // ── labels ───────────────────────────────────────────────────────────
30063010
30073011 router.add("GET", "/api/v1/repos/:owner/:name/labels", (ctx) => {
3012 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
30083013 const repo = loadRepo(ctx);
30093014 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));
30113017 });
30123018
30133019 router.add("POST", "/api/v1/repos/:owner/:name/labels", async (ctx) => {
@@ -3072,9 +3078,11 @@ export function apiRoutes(router: Router<Handler>): void {
30723078 // Name up to two crew teams on an issue: the pull that closes it is
30733079 // reviewed by each (`core/issue_teams.ts`).
30743080 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/teams", (ctx) => {
3081 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
30753082 const found = loadIssue(ctx);
30763083 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 }));
30783086 });
30793087 router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/teams", async (ctx) => {
30803088 const denied = requireScope(ctx, "rw");
@@ -4446,11 +4454,13 @@ export function apiRoutes(router: Router<Handler>): void {
44464454 // ── webhooks — admin-scoped, since a webhook exfiltrates repo events ──
44474455
44484456 router.add("GET", "/api/v1/repos/:owner/:name/hooks", (ctx) => {
4457 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
44494458 const denied = requireScope(ctx, "admin");
44504459 if (denied) return denied;
44514460 const repo = loadRepo(ctx);
44524461 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));
44544464 });
44554465
44564466 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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs