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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9125-get-api-v1-repos-owner-name-iterations-a → main 1 commit fork
11 files
changed +78
−11
docs/design/areas/iterations.md +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Iterations |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 5 | 5 | - `.it-page` — the page below the repo header; carries `data-here` and `data-lanes` (the runs, as JSON) for the script. |
| 6 | 6 | - `.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 | ||
| 15 | 15 | ## In the terminal |
| 16 | 16 | |
| 17 | 17 | 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 | ||
| 98 | 98 | export { route as getApiV1ReposOwnerNameAppProductionOpen } from "./getApiV1ReposOwnerNameAppProductionOpen.ts"; |
| 99 | 99 | export { route as getApiV1ReposOwnerNameBranches } from "./getApiV1ReposOwnerNameBranches.ts"; |
| 100 | 100 | export { route as getApiV1ReposOwnerNameBuilds } from "./getApiV1ReposOwnerNameBuilds.ts"; |
| 101 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; | |
| 101 | 102 | export { route as getApiV1ReposOwnerNameCommits } from "./getApiV1ReposOwnerNameCommits.ts"; |
| 102 | 103 | export { route as getApiV1ReposOwnerNameCommitsOid } from "./getApiV1ReposOwnerNameCommitsOid.ts"; |
| 103 | 104 | export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposOwnerNameCommitsOidCheck.ts"; |
src/web/pages/iterations_family.ts → src/core/iterations.ts renamed +5 −5
| @@ -8,11 +8,11 @@ | ||
| 8 | 8 | * carries its parent's pins; the family draws each once, in the parent. |
| 9 | 9 | */ |
| 10 | 10 | |
| 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"; | |
| 16 | 16 | |
| 17 | 17 | /** How many repositories a family draws. */ |
| 18 | 18 | export const FAMILY_LIMIT = 40; |
src/http/api.ts +2
| @@ -345,6 +345,7 @@ import { forgeOriginRoutes } from "./api/forge_origin.ts"; | ||
| 345 | 345 | import { userRoutes } from "./api/user.ts"; |
| 346 | 346 | import { appRoutes } from "./api/app.ts"; |
| 347 | 347 | import { buildRoutes } from "./api/builds.ts"; |
| 348 | import { iterationRoutes } from "./api/iterations.ts"; | |
| 348 | 349 | import { appNoteRoutes } from "./api/app_notes.ts"; |
| 349 | 350 | import { reportRoutes } from "./api/reports.ts"; |
| 350 | 351 | import { rollbackRoutes } from "./api/rollback.ts"; |
| @@ -3537,6 +3538,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3537 | 3538 | |
| 3538 | 3539 | appRoutes(router); |
| 3539 | 3540 | buildRoutes(router); |
| 3541 | iterationRoutes(router); | |
| 3540 | 3542 | |
| 3541 | 3543 | egressGrantRoutes(router); |
| 3542 | 3544 |
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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 13 | import { binding } from "../app/keys.ts"; |
| 14 | 14 | import type { Command } from "../app/palette.ts"; |
src/web/pages/iterations.ts +2 −2
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | * cards in a downward tree, a card per run, with fork and concept lines, |
| 4 | 4 | * zoom, and folds. Reached from Builds; it is not found where the family |
| 5 | 5 | * (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 | |
| 7 | 7 | * `iterations_script.ts`. |
| 8 | 8 | */ |
| 9 | 9 | |
| @@ -12,7 +12,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 12 | 12 | import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| 13 | 13 | import { html, when, type Html } from "../html.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { btn } from "../ui.ts"; |
| 17 | 17 | import { ITERATIONS_CSS, ITERATIONS_JS } from "./iterations_script.ts"; |
| 18 | 18 |
test/iterations.test.ts +26 −1
| @@ -16,7 +16,7 @@ import { getRepo, type Repo } from "../src/core/repos.ts"; | ||
| 16 | 16 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 17 | import { promote } from "./helpers/members.ts"; |
| 18 | 18 | 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"; | |
| 20 | 20 | import { ITERATIONS_CSS, ITERATIONS_JS } from "../src/web/pages/iterations_script.ts"; |
| 21 | 21 | |
| 22 | 22 | 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", () => { | ||
| 230 | 230 | expect(await (await get("/alice/game/builds")).text()).toContain('href="/alice/game/iterations"'); |
| 231 | 231 | expect(await (await get("/alice/empty/builds")).text()).not.toContain("/iterations"); |
| 232 | 232 | }); |
| 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 | }); | |
| 233 | 258 | }); |
test/tui-iterations.test.ts +1 −1
| @@ -12,7 +12,7 @@ import { promote } from "./helpers/members.ts"; | ||
| 12 | 12 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 13 | 13 | import { iterationLines, iterationsCommands, iterationsScreen, NO_ITERATIONS_LINE, NOT_FOUND_LINE } from "../src/tui/screens/iterations.ts"; |
| 14 | 14 | 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"; | |
| 16 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | 17 | import { settleApp } from "./helpers/tui.ts"; |
| 18 | 18 |
new above
offline