Public: anyone can read this; the crew can change it.
GET /api/v1/repos/:owner/:name/forks gives each fork's ahead, behind and last push, and the parent, as the forks page and the MCP forks tool do #9124
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9099-get-api-v1-repos-owner-name-forks-gives → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +142
−15
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts +1 −1
| @@ -5,7 +5,7 @@ export const route: Route = { | ||
| 5 | 5 | method: "get", |
| 6 | 6 | path: `${REPO}/forks`, |
| 7 | 7 | summary: |
| 8 | "List the forks of a repository you can read; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor", | |
| 8 | "List the forks of a repository you can read; answers { parent: \"owner/name\" | null, items: [...], next_cursor }, each item the repository plus ahead and behind (commits against the parent's default branch; null when git cannot say) and updatedAt (the last push, null when unknown), read for the page only; limit 1 to 200, cursor from next_cursor", | |
| 9 | 9 | auth: false, |
| 10 | 10 | query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" }, |
| 11 | 11 | refusals: { |
src/http/api.ts +16 −7
| @@ -933,22 +933,31 @@ export function pageParams( | ||
| 933 | 933 | } |
| 934 | 934 | |
| 935 | 935 | /** |
| 936 | * One page of `rows` (already in order) after the row whose id is the cursor, | |
| 937 | * as a response. A cursor no row answers to (deleted between two requests, or | |
| 938 | * invented) is a 422 `invalid_cursor`, as for branches: an empty last page | |
| 939 | * would tell the client it had seen everything. | |
| 936 | * One page of `rows` (already in order) after the row whose id is the cursor: | |
| 937 | * `{ items, next_cursor }`. A cursor no row answers to (deleted between two | |
| 938 | * requests, or invented) is a 422 `invalid_cursor`, as for branches: an empty | |
| 939 | * last page would tell the client it had seen everything. | |
| 940 | 940 | */ |
| 941 | export function pageOf<T extends { id: string }>( | |
| 941 | export function slicePage<T extends { id: string }>( | |
| 942 | 942 | rows: T[], |
| 943 | 943 | page: { limit: number; cursor: string | null }, |
| 944 | ): Response { | |
| 944 | ): { items: T[]; next_cursor: string | null } | Response { | |
| 945 | 945 | const at = page.cursor === null ? -1 : rows.findIndex((r) => r.id === page.cursor); |
| 946 | 946 | if (page.cursor !== null && at === -1) |
| 947 | 947 | return apiError("invalid_cursor", 422, "that cursor names a row that is gone: start over"); |
| 948 | 948 | const start = at + 1; |
| 949 | 949 | const items = rows.slice(start, start + page.limit); |
| 950 | 950 | const more = start + page.limit < rows.length; |
| 951 | return json({ items, next_cursor: more ? items[items.length - 1]!.id : null }); | |
| 951 | return { items, next_cursor: more ? items[items.length - 1]!.id : null }; | |
| 952 | } | |
| 953 | ||
| 954 | /** `slicePage` as a response. */ | |
| 955 | export function pageOf<T extends { id: string }>( | |
| 956 | rows: T[], | |
| 957 | page: { limit: number; cursor: string | null }, | |
| 958 | ): Response { | |
| 959 | const sliced = slicePage(rows, page); | |
| 960 | return sliced instanceof Response ? sliced : json(sliced); | |
| 952 | 961 | } |
| 953 | 962 | |
| 954 | 963 | /** The page of `rows` (sorted by name) after the cursor: `{ items, next_cursor }`. */ |
src/http/api/upstream.ts +28 −5
| @@ -14,11 +14,12 @@ import { | ||
| 14 | 14 | setFollowUpstream, |
| 15 | 15 | } from "../../actions/upstream_follow.ts"; |
| 16 | 16 | import { ENABLED_WORDS } from "../../core/arg_words.ts"; |
| 17 | import { canAdmin, visibleForks } from "../../core/repos.ts"; | |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 17 | import { driftOfForks, MAX_RANKED_FORKS, type RankedFork } from "../../actions/forks.ts"; | |
| 18 | import { canAdmin, forkParentFor, visibleForks } from "../../core/repos.ts"; | |
| 19 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 19 | 20 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 20 | 21 | import { getFollow } from "../../core/upstream_follow.ts"; |
| 21 | import { loadRepo, pageOf, pageParams } from "../api.ts"; | |
| 22 | import { loadRepo, pageParams, slicePage } from "../api.ts"; | |
| 22 | 23 | import type { Handler } from "../context.ts"; |
| 23 | 24 | import { apiError, body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| 24 | 25 | import type { Router } from "../router.ts"; |
| @@ -71,14 +72,36 @@ export function upstreamRoutes(router: Router<Handler>): void { | ||
| 71 | 72 | return json(checked.value); |
| 72 | 73 | }); |
| 73 | 74 | |
| 74 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { | |
| 75 | router.add("GET", "/api/v1/repos/:owner/:name/forks", async (ctx) => { | |
| 75 | 76 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 76 | 77 | const repo = loadRepo(ctx); |
| 77 | 78 | if (!repo) return notFound(); |
| 78 | 79 | const page = pageParams(ctx); |
| 79 | 80 | if (page instanceof Response) return page; |
| 80 | 81 | const rows = timePhase(ctx.request, "forks", () => visibleForks(ctx.app.db, repo.id, ctx.auth.user)); |
| 81 | return timePhase(ctx.request, "render", () => pageOf(rows, page)); | |
| 82 | const sliced = slicePage(rows, page); | |
| 83 | if (sliced instanceof Response) return sliced; | |
| 84 | const { items, next_cursor } = sliced; | |
| 85 | // Drift is read for this page only, in runs of what one call ranks. | |
| 86 | const drifts = await timePhaseAsync(ctx.request, "drift", async () => { | |
| 87 | const ranked: RankedFork[] = []; | |
| 88 | for (let i = 0; i < items.length; i += MAX_RANKED_FORKS) | |
| 89 | ranked.push(...(await driftOfForks(ctx.app, repo, items.slice(i, i + MAX_RANKED_FORKS)))); | |
| 90 | return ranked; | |
| 91 | }); | |
| 92 | const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user); | |
| 93 | return timePhase(ctx.request, "render", () => | |
| 94 | json({ | |
| 95 | parent: parent === undefined ? null : `${parent.ownerName}/${parent.name}`, | |
| 96 | items: drifts.map(({ fork, drift, pushedAt }) => ({ | |
| 97 | ...fork, | |
| 98 | ahead: drift.ahead, | |
| 99 | behind: drift.behind, | |
| 100 | updatedAt: pushedAt, | |
| 101 | })), | |
| 102 | next_cursor, | |
| 103 | }), | |
| 104 | ); | |
| 82 | 105 | }); |
| 83 | 106 | |
| 84 | 107 | router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => { |
test/fork-counts.test.ts +1 −1
| @@ -112,7 +112,7 @@ describe("fork counts hold only the forks the viewer may read", () => { | ||
| 112 | 112 | const api = await fetch(`${base}/api/v1/repos/alice/lib/forks`, { |
| 113 | 113 | headers: { cookie: cookies[who]! }, |
| 114 | 114 | }); |
| 115 | expect(await api.json()).toEqual({ items: [], next_cursor: null }); | |
| 115 | expect(await api.json()).toEqual({ parent: null, items: [], next_cursor: null }); | |
| 116 | 116 | } |
| 117 | 117 | const owners = await page("/alice/lib/forks", "bob"); |
| 118 | 118 | expect(owners).toContain("1 fork"); |
test/forks-api-drift.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** The forks API list gives each fork's drift and the parent, as the forks page does. */ | |
| 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 { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { promote } from "./helpers/members.ts"; | |
| 11 | ||
| 12 | let dataDir = ""; | |
| 13 | let app: App, servers: Servers; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dataDir = await mkdtemp(join(tmpdir(), "forge-fkdrift-")); | |
| 17 | app = createApp({ dataDir, port: 0 }); | |
| 18 | servers = await serve(app); | |
| 19 | }); | |
| 20 | afterAll(async () => { | |
| 21 | servers.stop(); | |
| 22 | app.close(); | |
| 23 | await rm(dataDir, { recursive: true, force: true }); | |
| 24 | }); | |
| 25 | ||
| 26 | test("a fork one commit ahead reads ahead 1, behind 0, with the parent named", async () => { | |
| 27 | const mk = async (username: string) => { | |
| 28 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: username === "alice" }); | |
| 29 | if (!r.ok) throw new Error("user"); | |
| 30 | promote(app.db, r.value); | |
| 31 | return r.value; | |
| 32 | }; | |
| 33 | const alice = await mk("alice"); | |
| 34 | const bob = await mk("bob"); | |
| 35 | const made = await createRepo(app, alice, { name: "app", visibility: "public" }); | |
| 36 | if (!made.ok) throw new Error("repo"); | |
| 37 | await commitToBranch(app, alice, made.value, { message: "one", files: [{ path: "a.txt", content: "x\n" }] }); | |
| 38 | const fork = await forkRepo(app, bob, made.value); | |
| 39 | if (!fork.ok) throw new Error("fork"); | |
| 40 | await commitToBranch(app, bob, fork.value, { message: "two", files: [{ path: "b.txt", content: "y\n" }] }); | |
| 41 | const res = await fetch(`${servers.url}/api/v1/repos/alice/app/forks`); | |
| 42 | const page = (await res.json()) as { parent: string | null; items: { id: string; name: string; ahead: number | null; behind: number | null; updatedAt: string | null }[]; next_cursor: string | null }; | |
| 43 | expect(res.status).toBe(200); | |
| 44 | expect(page.items.length).toBe(1); | |
| 45 | expect(page.items[0]!.name).toBe("app"); | |
| 46 | expect(page.items[0]!.ahead).toBe(1); | |
| 47 | expect(page.items[0]!.behind).toBe(0); | |
| 48 | expect(page.items[0]!.updatedAt).not.toBeNull(); | |
| 49 | expect(page.next_cursor).toBeNull(); | |
| 50 | expect(page.parent).toBeNull(); // the source itself has no parent | |
| 51 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!).filter((n) => n !== "app"); | |
Box 1 says drift is read for the requested page only, but nothing tests it. Add a case with 2 or more forks, |
||
| 52 | expect(names).toEqual(["session", "forks", "drift", "render"]); | |
| 53 | const down = await fetch(`${servers.url}/api/v1/repos/bob/app/forks`); | |
| 54 | expect(((await down.json()) as { parent: string | null }).parent).toBe("alice/app"); | |
| 55 | }); | |
| 56 | ||
| 57 | test("drift is read for the page being answered, not for every fork", async () => { | |
| 58 | const { spyOn } = await import("bun:test"); | |
| 59 | const forksMod = await import("../src/actions/forks.ts"); | |
| 60 | const mk = async (username: string) => { | |
| 61 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase" }); | |
| 62 | if (!r.ok) throw new Error("user"); | |
| 63 | promote(app.db, r.value); | |
| 64 | return r.value; | |
| 65 | }; | |
| 66 | const carol = await mk("carol"); | |
| 67 | const dan = await mk("dan"); | |
| 68 | const erin = await mk("erin"); | |
| 69 | const made = await createRepo(app, carol, { name: "lib", visibility: "public" }); | |
| 70 | if (!made.ok) throw new Error("repo"); | |
| 71 | await commitToBranch(app, carol, made.value, { message: "one", files: [{ path: "a.txt", content: "x\n" }] }); | |
| 72 | for (const [who, n] of [[dan, 1], [erin, 2]] as const) { | |
| 73 | const f = await forkRepo(app, who, made.value); | |
| 74 | if (!f.ok) throw new Error("fork"); | |
| 75 | for (let i = 0; i < n; i++) | |
| 76 | await commitToBranch(app, who, f.value, { message: `m${i}`, files: [{ path: `f${i}.txt`, content: "y\n" }] }); | |
| 77 | } | |
| 78 | const spy = spyOn(forksMod, "driftOfForks"); | |
| 79 | type Page = { items: { id: string; ownerName: string; ahead: number | null }[]; next_cursor: string | null }; | |
| 80 | const get = async (q: string) => (await (await fetch(`${servers.url}/api/v1/repos/carol/lib/forks?${q}`)).json()) as Page; | |
| 81 | const first = await get("limit=1"); | |
| 82 | const readFirst = spy.mock.calls.flatMap((c) => c[2].map((f) => f.id)); | |
| 83 | expect(first.items.length).toBe(1); | |
| 84 | expect(first.next_cursor).toBe(first.items[0]!.id); | |
| 85 | expect(readFirst).toEqual([first.items[0]!.id]); | |
| 86 | spy.mockClear(); | |
| 87 | const second = await get(`limit=1&cursor=${first.next_cursor}`); | |
| 88 | const readSecond = spy.mock.calls.flatMap((c) => c[2].map((f) => f.id)); | |
| 89 | spy.mockRestore(); | |
| 90 | expect(second.items.length).toBe(1); | |
| 91 | expect(second.next_cursor).toBeNull(); | |
| 92 | expect(readSecond).toEqual([second.items[0]!.id]); | |
| 93 | expect(second.items[0]!.id).not.toBe(first.items[0]!.id); | |
| 94 | expect([first.items[0]!.ahead, second.items[0]!.ahead].sort()).toEqual([1, 2]); | |
| 95 | }); |
test/forks-api-timing.test.ts +1 −1
| @@ -42,7 +42,7 @@ test("the forks list names session, forks, render in order", async () => { | ||
| 42 | 42 | const res = await fetch(`${servers.url}/api/v1/repos/alice/yard/forks`, { headers: { authorization: `Bearer ${bearer}` } }); |
| 43 | 43 | await res.arrayBuffer(); |
| 44 | 44 | expect(res.status).toBe(200); |
| 45 | expect(names(res)).toEqual(["session", "forks", "render"]); | |
| 45 | expect(names(res)).toEqual(["session", "forks", "drift", "render"]); | |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | 48 | test("a refused forks read still names session", async () => { |
new above
offline
This copies the cursor lookup, slice and
morecomputation frompageOf(src/http/api.ts:942) and drops the shared helper. Any later change to paging now has to be made twice. SplitpageOfinto a function that returns{ items, next_cursor } | Responsefor the slice and havepageOfcall it, then use that here.