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

Merged opened this

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

Merged

scout/forge:scout/9099-get-api-v1-repos-owner-name-forks-gives → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/9099-get-api-v1-repos-owner-name-forks-gives was merged into main as 72047ec.

Review

Sign in to review this change.
6 files changed +142 −15
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts +1 −1
@@ -5,7 +5,7 @@ export const route: Route = {
55 method: "get",
66 path: `${REPO}/forks`,
77 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",
99 auth: false,
1010 query: { limit: limitTo(LIST_LIMIT_MAX), cursor: "string" },
1111 refusals: {
src/http/api.ts +16 −7
@@ -933,22 +933,31 @@ export function pageParams(
933933 }
934934
935935 /**
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.
940940 */
941 export function pageOf<T extends { id: string }>(
941 export function slicePage<T extends { id: string }>(
942942 rows: T[],
943943 page: { limit: number; cursor: string | null },
944 ): Response {
944 ): { items: T[]; next_cursor: string | null } | Response {
945945 const at = page.cursor === null ? -1 : rows.findIndex((r) => r.id === page.cursor);
946946 if (page.cursor !== null && at === -1)
947947 return apiError("invalid_cursor", 422, "that cursor names a row that is gone: start over");
948948 const start = at + 1;
949949 const items = rows.slice(start, start + page.limit);
950950 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);
952961 }
953962
954963 /** 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 {
1414 setFollowUpstream,
1515 } from "../../actions/upstream_follow.ts";
1616 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";
1920 import { storageLimitMessage } from "../../core/storage.ts";
2021 import { getFollow } from "../../core/upstream_follow.ts";
21 import { loadRepo, pageOf, pageParams } from "../api.ts";
22 import { loadRepo, pageParams, slicePage } from "../api.ts";
2223 import type { Handler } from "../context.ts";
2324 import { apiError, body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
2425 import type { Router } from "../router.ts";
@@ -71,14 +72,36 @@ export function upstreamRoutes(router: Router<Handler>): void {
7172 return json(checked.value);
7273 });
7374
74 router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => {
75 router.add("GET", "/api/v1/repos/:owner/:name/forks", async (ctx) => {
7576 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
7677 const repo = loadRepo(ctx);
7778 if (!repo) return notFound();
7879 const page = pageParams(ctx);
7980 if (page instanceof Response) return page;
8081 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);
outdated

This copies the cursor lookup, slice and more computation from pageOf (src/http/api.ts:942) and drops the shared helper. Any later change to paging now has to be made twice. Split pageOf into a function that returns { items, next_cursor } | Response for the slice and have pageOf call it, then use that here.

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 );
82105 });
83106
84107 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", () => {
112112 const api = await fetch(`${base}/api/v1/repos/alice/lib/forks`, {
113113 headers: { cookie: cookies[who]! },
114114 });
115 expect(await api.json()).toEqual({ items: [], next_cursor: null });
115 expect(await api.json()).toEqual({ parent: null, items: [], next_cursor: null });
116116 }
117117 const owners = await page("/alice/lib/forks", "bob");
118118 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, limit=1, and a cursor for page 2. Check that each page has one item with its own drift and a correct next_cursor, and that the cursor page reads drift only for its own row. Without this, reading drift for every fork would still pass.

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 () => {
4242 const res = await fetch(`${servers.url}/api/v1/repos/alice/yard/forks`, { headers: { authorization: `Bearer ${bearer}` } });
4343 await res.arrayBuffer();
4444 expect(res.status).toBe(200);
45 expect(names(res)).toEqual(["session", "forks", "render"]);
45 expect(names(res)).toEqual(["session", "forks", "drift", "render"]);
4646 });
4747
4848 test("a refused forks read still names session", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs