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

GET /api/v1/repos/{owner}/{name}/waves lists a repository's waves, and …/waves/{version} reads one #5409

Merged opened this

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

Merged

scout/forge:scout/5363-get-api-v1-repos-owner-name-waves-lists → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/5363-get-api-v1-repos-owner-name-waves-lists was merged into main as a690d32.

Review

Sign in to review this change.
4 files changed +167 −1
scripts/shards.ts +1 −1
@@ -454,7 +454,7 @@ export function parseShardSpec(
454454 | { ok: true; none: true }
455455 | { ok: false; error: string } {
456456 if (spec === undefined || spec.trim() === "") return { ok: true, none: true };
457 const m = /^([1-9][0-9]{0,2})\/([1-9][0-9]{0,2})$/.exec(spec.trim());
457 const m = /^([1-9][0-9]{0,3})\/([1-9][0-9]{0,3})$/.exec(spec.trim());
458458 if (!m)
459459 return {
460460 ok: false,
src/http/api.ts +48
@@ -374,6 +374,7 @@ import { carriedBy } from "../core/carried.ts";
374374 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
375375 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
376376 import { repoDir } from "../git/repos.ts";
377 import { readWave, readWaves } from "../story/waves.ts";
377378 import type { Result } from "../kernel/result.ts";
378379 import { sizesView } from "../spawn/sizes.ts";
379380 import { holds, weakEtag } from "./etag.ts";
@@ -3652,6 +3653,53 @@ export function apiRoutes(router: Router<Handler>): void {
36523653 return timePhase(ctx.request, "render", () => json(data));
36533654 },
36543655 );
3656 // Waves as data (#5363): what shipped, newest first, read through the same
3657 // functions as the page and the feed. A file that is not a wave is left out.
3658 router.add("GET", "/api/v1/repos/:owner/:name/waves", async (ctx) => {
3659 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3660 const repo = loadRepo(ctx);
3661 if (!repo) return notFound();
3662 const entries = await timePhaseAsync(ctx.request, "waves", () =>
3663 readWaves(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch),

Nit, not blocking: dirOf(ctx, repo) (defined near loadRepo) says this in one call. Same for the single-wave route below.

3664 );
3665 return timePhase(ctx.request, "render", () =>
3666 json({
3667 items: entries.flatMap((e) =>
3668 e.ok ? [{ version: e.wave.version, date: e.wave.date, headline: e.wave.headline }] : [],
3669 ),
3670 next_cursor: null,
3671 }),
3672 );
3673 });
3674 router.add("GET", "/api/v1/repos/:owner/:name/waves/:version", async (ctx) => {
3675 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3676 const repo = loadRepo(ctx);
3677 if (!repo) return notFound();
3678 const entry = await timePhaseAsync(ctx.request, "wave", () =>
3679 readWave(
3680 repoDir(ctx.app.reposRoot, repo.ownerName, repo.name),
3681 repo.defaultBranch,
3682 ctx.params.version ?? "",
3683 ),
3684 );
3685 if (!entry?.ok) return notFound();
3686 const w = entry.wave;
3687 // A pull line is `#12 Title` linking to the pull; the number is whichever
3688 // of the two names it, or null.
3689 const number = (p: { title: string; url: string }): number | null => {
3690 const m = /^#(\d+)\b/.exec(p.title) ?? /\/pulls\/(\d+)\/?$/.exec(p.url);
3691 return m ? Number(m[1]) : null;
3692 };
3693 return timePhase(ctx.request, "render", () =>
3694 json({
3695 version: w.version,
3696 date: w.date,
3697 headline: w.headline,
3698 highlights: w.highlights,
3699 pulls: w.pulls.map((p) => ({ number: number(p), title: p.title, url: p.url })),
3700 }),
3701 );
3702 });
36553703 // Keep a thought exactly as typed (#1658): append-only, never edited.
36563704 router.add("POST", "/api/v1/thoughts", async (ctx) => {
36573705 const denied = requireScope(ctx, "rw");
test/api-waves.test.ts added +116
@@ -0,0 +1,116 @@
1 /** The API's waves reads (#5363): newest first, one wave, 404s, and 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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { writeWave, type Wave } from "../src/story/wave.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let cookie = "";
20 const cells = process.env["FORGE_CELLS"];
21
22 const wave = (version: string, date: string, headline: string): Wave => ({
23 version,
24 date,
25 headline,
26 highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: `because ${n}` })),
27 pulls: [{ title: "#12 A pull", url: "https://forge.example/o/r/pulls/12" }],
28 });
29 const text = (w: Wave): string => {
30 const r = writeWave(w);
31 if (!r.ok) throw r.error;
32 return r.value;
33 };
34 const files = [
35 wave("0.9.0", "2026-01-01", "Ninth"),
36 wave("0.10.0", "2026-02-01", "Tenth"),
37 ].map((w) => ({ path: `waves/${w.version}.md`, content: text(w) }));
38
39 beforeAll(async () => {
40 process.env["FORGE_CELLS"] = "off";
41 dataDir = await mkdtemp(join(tmpdir(), "forge-api-waves-"));
42 app = createApp({ dataDir, port: 0 });
43 servers = await serve(app);
44 const made = await createUser(app.db, {
45 origin: "seed",
46 trusted: true,
47 username: "alice",
48 email: "[email protected]",
49 password: "a-good-passphrase",
50 adminIfFirst: true,
51 });
52 if (!made.ok) throw new Error(String(made.error));
53 const alice = made.value;
54 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
55 for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) {
56 const repo = await createRepo(app, alice, { name, visibility });
57 if (!repo.ok) throw new Error(String(repo.error));
58 const c = await commitToBranch(app, alice, repo.value, { message: "waves", files });
59 if (!c.ok) throw new Error(JSON.stringify(c.error));
60 }
61 });
62
63 afterAll(async () => {
64 await servers.stop();
65 app.close();
66 if (cells === undefined) delete process.env["FORGE_CELLS"];
67 else process.env["FORGE_CELLS"] = cells;
68 await rm(dataDir, { recursive: true, force: true });
69 });
70
71 async function get(path: string, withCookie = true) {
72 const res = await fetch(`${servers.url}${path}`, { headers: withCookie ? { cookie } : {} });
73 const body = await res.text();
74 const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
75 return { status: res.status, names: names.slice(names.indexOf("app") + 1), body };
76 }
77
78 test("the list is newest first by version, not by name", async () => {
79 const got = await get("/api/v1/repos/alice/pub/waves");
80 expect(got.status).toBe(200);
81 expect(JSON.parse(got.body)).toEqual({
82 items: [
83 { version: "0.10.0", date: "2026-02-01", headline: "Tenth" },
84 { version: "0.9.0", date: "2026-01-01", headline: "Ninth" },
85 ],
86 next_cursor: null,
87 });
88 });
89
90 test("one wave carries highlights with their why, and its pull numbers", async () => {
91 const got = await get("/api/v1/repos/alice/pub/waves/0.9.0");
92 expect(got.status).toBe(200);
93 const w = JSON.parse(got.body);
94 expect(w.headline).toBe("Ninth");
95 expect(w.highlights[0]).toEqual({ title: "Thing 1", why: "because 1" });
96 expect(w.pulls).toEqual([
97 { number: 12, title: "#12 A pull", url: "https://forge.example/o/r/pulls/12" },
98 ]);
99 });
100
101 test("an unknown or malformed version is 404", async () => {
102 for (const v of ["9.9.9", "nope", "1.2", "..%2Fx"])
103 expect((await get(`/api/v1/repos/alice/pub/waves/${v}`)).status).toBe(404);
104 });
105
106 test("a stranger gets 404 for a private repository, an owner 200", async () => {
107 for (const path of ["waves", "waves/0.9.0"]) {
108 expect((await get(`/api/v1/repos/alice/priv/${path}`, false)).status).toBe(404);
109 expect((await get(`/api/v1/repos/alice/priv/${path}`)).status).toBe(200);
110 }
111 });
112
113 test("the reads name their Server-Timing phases", async () => {
114 expect((await get("/api/v1/repos/alice/pub/waves")).names).toEqual(["session", "waves", "render"]);
115 expect((await get("/api/v1/repos/alice/pub/waves/0.9.0")).names).toEqual(["session", "wave", "render"]);
116 });
test/openapi-coverage.test.ts +2
@@ -51,6 +51,8 @@ const NOT_YET_DESCRIBED: readonly string[] = [
5151 "GET /api/v1/repos/{}/{}/pulls/{}/reviews",
5252 "GET /api/v1/repos/{}/{}/pulls/{}/threads",
5353 "GET /api/v1/repos/{}/{}/reports",
54 "GET /api/v1/repos/{}/{}/waves",
55 "GET /api/v1/repos/{}/{}/waves/{}",
5456 "GET /api/v1/user",
5557 "GET /api/v1/user/assigned",
5658 "GET /api/v1/user/keys",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs