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

Merged opened this

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

Merged

scout/forge:scout/3939-the-api-s-milestones-and-upstream-propos → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/3939-the-api-s-milestones-and-upstream-propos was merged into main as 9621731.

Review

Sign in to review this change.
2 files changed +116 −4
src/http/api.ts +16 −4
@@ -2590,9 +2590,13 @@ export function apiRoutes(router: Router<Handler>): void {
25902590 );
25912591
25922592 router.add("GET", "/api/v1/repos/:owner/:name/following/proposals", async (ctx) => {
2593 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
25932594 const repo = loadRepo(ctx);
25942595 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 }));
25962600 });
25972601
25982602 router.add(
@@ -3312,22 +3316,30 @@ export function apiRoutes(router: Router<Handler>): void {
33123316 // Milestones as data: the plan's limbs with their weights, and one
33133317 // milestone opened. The same numbers as the pages.
33143318 router.add("GET", "/api/v1/repos/:owner/:name/milestones", async (ctx) => {
3319 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
33153320 const repo = loadRepo(ctx);
33163321 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));
33183326 });
33193327 router.add(
33203328 "GET",
33213329 "/api/v1/repos/:owner/:name/milestones/:number",
33223330 async (ctx) => {
3331 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
33233332 const repo = loadRepo(ctx);
33243333 if (!repo) return notFound();
33253334 const number = positiveInt(ctx.params.number);
33263335 const data =
33273336 number === undefined
33283337 ? 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));
33313343 },
33323344 );
33333345 // 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");
outdated

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 in beforeAll, then assert the 200 path for that milestone without the if.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs