Public: anyone can read this; the crew can change it.

The API's issue list names its Server-Timing phases: session, list, labels, render #4014

Merged opened this

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

Merged

scout/forge:scout/3771-the-api-s-issue-list-names-its-server-ti → main 5 commits fork

  • Merged. scout/3771-the-api-s-issue-list-names-its-server-ti was merged into main as d1b51cf.

Review

Sign in to review this change.
2 files changed +65 −6
src/http/api.ts +9 −6
@@ -3016,24 +3016,27 @@ export function apiRoutes(router: Router<Handler>): void {
30163016 // ── issues — the label-as-queue surface automation drives ────────────
30173017
30183018 router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => {
3019 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
30193020 const repo = loadRepo(ctx);
30203021 if (!repo) return notFound();
30213022 const fields = ctx.url.searchParams.get("fields");
30223023 if (fields !== null && fields !== "brief")
30233024 return apiError("invalid_fields", 422, "fields must be brief, or left out");
30243025 const query = listQuery(ctx);
3025 const items = withLabelsMany(
3026 ctx,
3026 const listed = timePhase(ctx.request, "list", () =>
30273027 listIssues(ctx.app.db, repo.id, {
30283028 ...query,
30293029 kind: "issue",
30303030 ...sealedScope(ctx.app.db, repo, ctx.auth.user),
30313031 }),
30323032 );
3033 const page = paged(items, query.limit, (i) => i.number);
3034 return json(
3035 fields === "brief" ? { ...page, items: page.items.map(briefIssue) } : page,
3036 );
3033 const items = timePhase(ctx.request, "labels", () => withLabelsMany(ctx, listed));
3034 return timePhase(ctx.request, "render", () => {
3035 const page = paged(items, query.limit, (i) => i.number);
3036 return json(
3037 fields === "brief" ? { ...page, items: page.items.map(briefIssue) } : page,
3038 );
3039 });
30373040 });
30383041
30393042 router.add("POST", "/api/v1/repos/:owner/:name/issues", async (ctx) => {
test/api-issues-timing.test.ts added +56
@@ -0,0 +1,56 @@
1 /** The API's issue list names its phases in Server-Timing (#3771). */
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 { createIssue } from "../src/core/issues.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 let dataDir = "";
13 let app: App, servers: Servers, bearer = "";
14
15 beforeAll(async () => {
16 dataDir = await mkdtemp(join(tmpdir(), "forge-apiissues-"));
17 app = createApp({ dataDir, port: 0 });
18 servers = await serve(app);
19 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
20 if (!admin.ok) throw new Error(admin.error);
21 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
22 if (!made.ok) throw new Error(String(made.error));
23 for (const title of ["one", "two"])
24 createIssue(app.db, { repoId: made.value.id, title, body: "b", authorId: admin.value.id });
25 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
26 method: "POST",
27 headers: { "content-type": "application/json" },
28 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
29 });
30 bearer = ((await minted.json()) as { token: string }).token;
31 });
32
33 afterAll(async () => {
34 servers.stop();
35 app.close();
36 await rm(dataDir, { recursive: true, force: true });
37 });
38
39 function names(res: Response): string[] {
40 return (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
41 }
42
43 test("the issue list names session, list, labels and render once each", async () => {
44 const res = await fetch(`${servers.url}/api/v1/repos/alice/notes/issues`, { headers: { authorization: `Bearer ${bearer}` } });
45 expect(res.status).toBe(200);
46 const body = (await res.json()) as { items: unknown[] };
47 expect(body.items.length).toBe(2);
48 for (const n of ["session", "list", "labels", "render"]) expect(names(res).filter((x) => x === n).length).toBe(1);
49 });
50
51 test("an unknown repository's 404 still names session", async () => {
52 const res = await fetch(`${servers.url}/api/v1/repos/alice/nope/issues`, { headers: { authorization: `Bearer ${bearer}` } });
53 expect(res.status).toBe(404);
54 await res.text();
55 expect(names(res)).toContain("session");
56 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs