Public: anyone can read this; the crew can change it.
The API's milestones and upstream proposals reads name their Server-Timing phases: session, read, render #4025
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3939-the-api-s-milestones-and-upstream-propos → main 2 commits fork 1 thread · 1 unresolved
2 files
changed +116
−4
src/http/api.ts +16 −4
| @@ -2590,9 +2590,13 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2590 | 2590 | ); |
| 2591 | 2591 | |
| 2592 | 2592 | router.add("GET", "/api/v1/repos/:owner/:name/following/proposals", async (ctx) => { |
| 2593 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2593 | 2594 | const repo = loadRepo(ctx); |
| 2594 | 2595 | if (!repo) return notFound(); |
| 2595 | return json({ items: await forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined) }); | |
| 2596 | const items = await timePhaseAsync(ctx.request, "read", () => | |
| 2597 | forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined), | |
| 2598 | ); | |
| 2599 | return timePhase(ctx.request, "render", () => json({ items })); | |
| 2596 | 2600 | }); |
| 2597 | 2601 | |
| 2598 | 2602 | router.add( |
| @@ -3312,22 +3316,30 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3312 | 3316 | // Milestones as data: the plan's limbs with their weights, and one |
| 3313 | 3317 | // milestone opened. The same numbers as the pages. |
| 3314 | 3318 | router.add("GET", "/api/v1/repos/:owner/:name/milestones", async (ctx) => { |
| 3319 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3315 | 3320 | const repo = loadRepo(ctx); |
| 3316 | 3321 | if (!repo) return notFound(); |
| 3317 | return json(await milestonesJson(ctx, repo)); | |
| 3322 | const data = await timePhaseAsync(ctx.request, "read", () => | |
| 3323 | milestonesJson(ctx, repo), | |
| 3324 | ); | |
| 3325 | return timePhase(ctx.request, "render", () => json(data)); | |
| 3318 | 3326 | }); |
| 3319 | 3327 | router.add( |
| 3320 | 3328 | "GET", |
| 3321 | 3329 | "/api/v1/repos/:owner/:name/milestones/:number", |
| 3322 | 3330 | async (ctx) => { |
| 3331 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 3323 | 3332 | const repo = loadRepo(ctx); |
| 3324 | 3333 | if (!repo) return notFound(); |
| 3325 | 3334 | const number = positiveInt(ctx.params.number); |
| 3326 | 3335 | const data = |
| 3327 | 3336 | number === undefined |
| 3328 | 3337 | ? undefined |
| 3329 | : await milestoneData(ctx, repo, number); | |
| 3330 | return data === undefined ? notFound() : json(data); | |
| 3338 | : await timePhaseAsync(ctx.request, "read", () => | |
| 3339 | milestoneData(ctx, repo, number), | |
| 3340 | ); | |
| 3341 | if (data === undefined) return notFound(); | |
| 3342 | return timePhase(ctx.request, "render", () => json(data)); | |
| 3331 | 3343 | }, |
| 3332 | 3344 | ); |
| 3333 | 3345 | // Keep a thought exactly as typed (#1658): append-only, never edited. |
test/api-milestones-timing.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | /** The API's milestones and upstream proposals reads name their Server-Timing phases (#3939). */ | |
| 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 { addDependency } from "../src/core/dependencies.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { createSession } from "../src/core/sessions.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 13 | import { quietLogs } from "./helpers/globals.ts"; | |
| 14 | ||
| 15 | quietLogs(); | |
| 16 | ||
| 17 | let dataDir: string; | |
| 18 | let app: App; | |
| 19 | let servers: Servers; | |
| 20 | let cookie = ""; | |
| 21 | let umbrella = 0; | |
| 22 | const cells = process.env["FORGE_CELLS"]; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | process.env["FORGE_CELLS"] = "off"; | |
| 26 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-milestones-timing-")); | |
| 27 | app = createApp({ dataDir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | const made = await createUser(app.db, { | |
| 30 | origin: "seed", | |
| 31 | trusted: true, | |
| 32 | username: "alice", | |
| 33 | email: "[email protected]", | |
| 34 | password: "a-good-passphrase", | |
| 35 | adminIfFirst: true, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(String(made.error)); | |
| 38 | const alice = made.value; | |
| 39 | cookie = `forge_session=${createSession(app.db, alice.id).token}`; | |
| 40 | const repo = await createRepo(app, alice, { name: "r", visibility: "public" }); | |
| 41 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 42 | // A milestone is an issue that waits for another issue. | |
| 43 | const open = (title: string) => { | |
| 44 | const made = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, title }); | |
| 45 | if (!made.ok) throw new Error(String(made.error)); | |
| 46 | return made.value; | |
| 47 | }; | |
| 48 | const parent = open("umbrella"); | |
| 49 | const child = open("step"); | |
| 50 | const linked = addDependency(app.db, parent, child, alice.id); | |
| 51 | if (!linked.ok) throw new Error(String(linked.error)); | |
| 52 | umbrella = parent.number; | |
| 53 | }); | |
| 54 | ||
| 55 | afterAll(async () => { | |
| 56 | await servers.stop(); | |
| 57 | app.close(); | |
| 58 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 59 | else process.env["FORGE_CELLS"] = cells; | |
| 60 | await rm(dataDir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | async function phases(path: string): Promise<{ status: number; names: string[]; body: string }> { | |
| 64 | const res = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 65 | const body = await res.text(); | |
| 66 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); | |
| 67 | return { status: res.status, names: names.slice(names.indexOf("app") + 1), body }; | |
| 68 | } | |
| 69 | ||
| 70 | test("the milestones list names session, read, render", async () => { | |
| 71 | const got = await phases("/api/v1/repos/alice/r/milestones"); | |
| 72 | expect(got.status).toBe(200); | |
| 73 | expect(got.names).toEqual(["session", "read", "render"]); | |
| 74 | }); | |
| 75 | ||
| 76 | test("the proposals read names session, read, render", async () => { | |
| 77 | const got = await phases("/api/v1/repos/alice/r/following/proposals"); | |
| 78 | expect(got.status).toBe(200); | |
| 79 | expect(got.names).toEqual(["session", "read", "render"]); | |
| 80 | expect(JSON.parse(got.body).items).toBeArray(); | |
| 81 | }); | |
| 82 | ||
| 83 | test("one milestone names session, read, render; a miss names session and read", async () => { | |
| 84 | const list = JSON.parse((await phases("/api/v1/repos/alice/r/milestones")).body); | |
| 85 | expect(list.limbs.length).toBeGreaterThan(0); | |
| 86 | const got = await phases(`/api/v1/repos/alice/r/milestones/${umbrella}`); | |
| 87 | expect(got.status).toBe(200); | |
| 88 | expect(got.names).toEqual(["session", "read", "render"]); | |
| 89 | const miss = await phases("/api/v1/repos/alice/r/milestones/9999"); | |
| 90 | expect(miss.status).toBe(404); | |
| 91 | expect(miss.names).toEqual(["session", "read"]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a missing repository still names session", async () => { | |
| 95 | for (const path of ["milestones", "milestones/1", "following/proposals"]) { | |
| 96 | const got = await phases(`/api/v1/repos/alice/nope/${path}`); | |
| 97 | expect(got.status).toBe(404); | |
| 98 | expect(got.names).toEqual(["session"]); | |
| 99 | } | |
| 100 | }); |
new above
offline
This line always gives
undefined: the list's shape is{ repo, limbs }, and the fixture repo has no milestones anyway. So the success assertion below never runs. Seed an issue that waits for another one inbeforeAll, then assert the 200 path for that milestone without theif.