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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3771-the-api-s-issue-list-names-its-server-ti → main 5 commits fork
2 files
changed +65
−6
src/http/api.ts +9 −6
| @@ -3016,24 +3016,27 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3016 | 3016 | // ── issues — the label-as-queue surface automation drives ──────────── |
| 3017 | 3017 | |
| 3018 | 3018 | router.add("GET", "/api/v1/repos/:owner/:name/issues", (ctx) => { |
| 3019 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3019 | 3020 | const repo = loadRepo(ctx); |
| 3020 | 3021 | if (!repo) return notFound(); |
| 3021 | 3022 | const fields = ctx.url.searchParams.get("fields"); |
| 3022 | 3023 | if (fields !== null && fields !== "brief") |
| 3023 | 3024 | return apiError("invalid_fields", 422, "fields must be brief, or left out"); |
| 3024 | 3025 | const query = listQuery(ctx); |
| 3025 | const items = withLabelsMany( | |
| 3026 | ctx, | |
| 3026 | const listed = timePhase(ctx.request, "list", () => | |
| 3027 | 3027 | listIssues(ctx.app.db, repo.id, { |
| 3028 | 3028 | ...query, |
| 3029 | 3029 | kind: "issue", |
| 3030 | 3030 | ...sealedScope(ctx.app.db, repo, ctx.auth.user), |
| 3031 | 3031 | }), |
| 3032 | 3032 | ); |
| 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 | }); | |
| 3037 | 3040 | }); |
| 3038 | 3041 | |
| 3039 | 3042 | 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 | }); |
new above
offline