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

GET /api/v1/repos/:owner/:name/iterations answers the fork family's runs and pins as JSON, the same the Iterations page draws #9145

Merged opened this

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

Merged

mason/forge:mason/9125-get-api-v1-repos-owner-name-iterations-a → main 1 commit fork

  • Merged. mason/9125-get-api-v1-repos-owner-name-iterations-a was merged into main as a4606c6.

Review

Sign in to review this change.
11 files changed +78 −11
docs/design/areas/iterations.md +1 −1
@@ -1,6 +1,6 @@
11 # Iterations
22
3 A fork family's build pins (`/:owner/:name/iterations`) as a downward tree of runs, reached from the Builds page. `iterationsPage` in `src/web/pages/iterations.ts` draws it; `iterations_family.ts` holds the data (`iterationsOf`, `lanesOf`) and `iterations_script.ts` the rules (`ITERATIONS_CSS`) and the script. Without the script the runs stand side by side; with it each run hangs under the build it grew from.
3 A fork family's build pins (`/:owner/:name/iterations`) as a downward tree of runs, reached from the Builds page. `iterationsPage` in `src/web/pages/iterations.ts` draws it; `src/core/iterations.ts` holds the data (`iterationsOf`, `lanesOf`) and `iterations_script.ts` the rules (`ITERATIONS_CSS`) and the script. Without the script the runs stand side by side; with it each run hangs under the build it grew from.
44
55 - `.it-page` — the page below the repo header; carries `data-here` and `data-lanes` (the runs, as JSON) for the script.
66 - `.it-key` — the one-line key: solid line for fork of, thin for a branch (`.it-thin`), dashed (`.it-dash`) for built on a pin of; `.it-sp` the Collapse/Expand forks and zoom buttons (`.it-zc`), shown only once the script adds `.it-on` to `.it-page`.
docs/features/iterations.md +4
@@ -15,3 +15,7 @@ Open `/:owner/:name/iterations`, or the **Iterations** button on a repository's
1515 ## In the terminal
1616
1717 Open a repository, then the palette (`ctrl+k`) and **Iterations**. The family's runs are an indented list, a run under the build it grew from, its builds beneath; `j`/`k` move, `esc` goes back. It shows exactly what the page shows for the same viewer.
18
19 ## Over the API
20
21 `GET /api/v1/repos/:owner/:name/iterations` answers `{ forks, lanes }` for the viewer: the same forks (each with its pins: label, sha as `oid`, date as `at`, first line of `note`, `parent`), and the same runs the page draws, at most 40 repositories, sharing the 5-second pins cache. A private repository you may not read is a 404; a private fork you may not read is absent; a family with no pin answers 200 with empty lists.
src/contract/openapi/routes/getApiV1ReposOwnerNameIterations.ts added +10
@@ -0,0 +1,10 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/iterations`,
6 summary: "A fork family's build pins as JSON, the same the Iterations page draws, for the viewer; answers { forks, lanes }: forks are { repo, name, forkOf, builds: [{ key, label, oid, at, note, parent, edge }] }, lanes are the tree's runs in drawing order; at most 40 repositories, a private fork the viewer may not read is absent, and a family with no pin answers empty lists",
7 auth: false,
8 reply: { forks: "array", lanes: "array" },
9 refusals: { not_found: "404: no such repository, or it is not readable" },
10 };
src/contract/openapi/routes/index.ts +1
@@ -98,6 +98,7 @@ export { route as getApiV1ReposOwnerNameAppNotes } from "./getApiV1ReposOwnerNam
9898 export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts";
9999 export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts";
100100 export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts";
101 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
101102 export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts";
102103 export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts";
103104 export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts";
src/web/pages/iterations_family.ts → src/core/iterations.ts renamed +5 −5
@@ -8,11 +8,11 @@
88 * carries its parent's pins; the family draws each once, in the parent.
99 */
1010
11 import { canRead, getRepoById, visibleForks, type Repo } from "../../core/repos.ts";
12 import { listBuildPins, type BuildPin } from "../../git/builds.ts";
13 import { repoDir } from "../../git/repos.ts";
14 import type { App } from "../../app.ts";
15 import type { User } from "../../core/users.ts";
11 import { canRead, getRepoById, visibleForks, type Repo } from "./repos.ts";
12 import { listBuildPins, type BuildPin } from "../git/builds.ts";
13 import { repoDir } from "../git/repos.ts";
14 import type { App } from "../app.ts";
15 import type { User } from "./users.ts";
1616
1717 /** How many repositories a family draws. */
1818 export const FAMILY_LIMIT = 40;
src/http/api.ts +2
@@ -345,6 +345,7 @@ import { forgeOriginRoutes } from "./api/forge_origin.ts";
345345 import { userRoutes } from "./api/user.ts";
346346 import { appRoutes } from "./api/app.ts";
347347 import { buildRoutes } from "./api/builds.ts";
348 import { iterationRoutes } from "./api/iterations.ts";
348349 import { appNoteRoutes } from "./api/app_notes.ts";
349350 import { reportRoutes } from "./api/reports.ts";
350351 import { rollbackRoutes } from "./api/rollback.ts";
@@ -3537,6 +3538,7 @@ export function apiRoutes(router: Router<Handler>): void {
35373538
35383539 appRoutes(router);
35393540 buildRoutes(router);
3541 iterationRoutes(router);
35403542
35413543 egressGrantRoutes(router);
35423544
src/http/api/iterations.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * A fork family's build pins and runs as JSON (`core/iterations.ts`), the
3 * same the Iterations page draws, for the viewer: private forks they may not
4 * read are absent. A family with no pin answers empty lists.
5 */
6
7 import { iterationsOf, lanesOf } from "../../core/iterations.ts";
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import { loadRepo } from "../api.ts";
10 import type { Handler } from "../context.ts";
11 import { json, notFound } from "../respond.ts";
12 import type { Router } from "../router.ts";
13
14 export function iterationRoutes(router: Router<Handler>): void {
15 router.add("GET", "/api/v1/repos/:owner/:name/iterations", async (ctx) => {
16 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
17 const repo = loadRepo(ctx);
18 if (!repo) return notFound();
19 const forks = (await iterationsOf(ctx.app, ctx.auth.user, repo)) ?? [];
20 const res = json({ forks, lanes: lanesOf(forks) });
21 res.headers.set("cache-control", "private, no-cache");
22 res.headers.set("vary", "authorization, cookie");
23 return res;
24 });
25 }
src/tui/screens/iterations.ts +1 −1
@@ -8,7 +8,7 @@
88 */
99
1010 import { canRead, type Repo } from "../../core/repos.ts";
11 import { iterationsOf, lanesOf, type IterationLane } from "../../web/pages/iterations_family.ts";
11 import { iterationsOf, lanesOf, type IterationLane } from "../../core/iterations.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
1313 import { binding } from "../app/keys.ts";
1414 import type { Command } from "../app/palette.ts";
src/web/pages/iterations.ts +2 −2
@@ -3,7 +3,7 @@
33 * cards in a downward tree, a card per run, with fork and concept lines,
44 * zoom, and folds. Reached from Builds; it is not found where the family
55 * (what the viewer may read of it) holds no pin. Read-only: pinning stays on
6 * Builds. The data is `iterations_family.ts`, the browser half
6 * Builds. The data is `core/iterations.ts`, the browser half
77 * `iterations_script.ts`.
88 */
99
@@ -12,7 +12,7 @@ import type { Ctx } from "../../http/context.ts";
1212 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts";
1313 import { html, when, type Html } from "../html.ts";
1414 import { page } from "../layout.ts";
15 import { iterationsOf, lanesOf, type IterationBuild, type IterationLane } from "./iterations_family.ts";
15 import { iterationsOf, lanesOf, type IterationBuild, type IterationLane } from "../../core/iterations.ts";
1616 import { btn } from "../ui.ts";
1717 import { ITERATIONS_CSS, ITERATIONS_JS } from "./iterations_script.ts";
1818
test/iterations.test.ts +26 −1
@@ -16,7 +16,7 @@ import { getRepo, type Repo } from "../src/core/repos.ts";
1616 import { createUser, type User } from "../src/core/users.ts";
1717 import { promote } from "./helpers/members.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
19 import { FAMILY_LIMIT, familyRepos, iterationsOf, lanesOf, type IterationBuild, type IterationFork } from "../src/web/pages/iterations_family.ts";
19 import { FAMILY_LIMIT, familyRepos, iterationsOf, lanesOf, type IterationBuild, type IterationFork } from "../src/core/iterations.ts";
2020 import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations_script.ts";
2121
2222 const build = (repo: string, label: string, parent: string | null = null, edge: IterationBuild["edge"] = parent === null ? null : "git"): IterationBuild => ({
@@ -230,4 +230,29 @@ describe("iterations page", () => {
230230 expect(await (await get("/alice/game/builds")).text()).toContain('href="/alice/game/iterations"');
231231 expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations");
232232 });
233
234 test("the API lists the same pins as the page for a reader, a stranger and a guest", async () => {
235 for (const who of ["carol", "bob", ""]) {
236 const cookie = who === "" ? "" : await signIn(who);
237 const page = await (await get("/alice/game/iterations", cookie)).text();
238 const shown = [...page.matchAll(/data-key="([^"]+)"/g)].map((m) => m[1]!);
239 const res = await get("/api/v1/repos/alice/game/iterations", cookie);
240 expect(res.status).toBe(200);
241 const data = (await res.json()) as { forks: IterationFork[]; lanes: { builds: IterationBuild[] }[] };
242 const keys = data.forks.flatMap((f) => f.builds.map((b) => b.key));
243 expect([...new Set(shown)].sort()).toEqual([...keys].sort());
244 expect(data.lanes.flatMap((l) => l.builds.map((b) => b.key)).sort()).toEqual([...keys].sort());
245 expect(keys.some((k) => k.includes("hush-pin"))).toBe(who === "carol");
246 expect(data.forks.flatMap((f) => f.builds).every((b) => b.label !== "" && /^[0-9a-f]{40}$/.test(b.oid) && b.at !== "")).toBe(true);
247 }
248 });
249
250 test("the API: a private repository is a 404 to a stranger, an empty family answers empty lists", async () => {
251 expect((await get("/api/v1/repos/alice/secret/iterations")).status).toBe(404);
252 expect((await get("/api/v1/repos/alice/secret/iterations", await signIn("bob"))).status).toBe(404);
253 expect((await get("/api/v1/repos/alice/secret/iterations", await signIn("alice"))).status).toBe(200);
254 const res = await get("/api/v1/repos/alice/empty/iterations");
255 expect(res.status).toBe(200);
256 expect(await res.json()).toEqual({ forks: [], lanes: [] });
257 });
233258 });
test/tui-iterations.test.ts +1 −1
@@ -12,7 +12,7 @@ import { promote } from "./helpers/members.ts";
1212 import { createForgeApp } from "../src/tui/forge.ts";
1313 import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts";
1414 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { lanesOf } from "../src/web/pages/iterations_family.ts";
15 import { lanesOf } from "../src/core/iterations.ts";
1616 import { quietLogs } from "./helpers/globals.ts";
1717 import { settleApp } from "./helpers/tui.ts";
1818

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs