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

A repository's forks, labels and webhooks answer `{ items, next_cursor }` #6972

Merged opened this

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

Merged

scout/forge:scout/4843-a-repository-s-forks-labels-and-webhooks → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/4843-a-repository-s-forks-labels-and-webhooks was merged into main as 0ed05a5.

Review

Sign in to review this change.
13 files changed +250 −22
README.md +1 −1
@@ -243,7 +243,7 @@ runs no check and no preview until a writer approves its head or lets it run
243243 (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull
244244 opened with it says "proposed by child … from …" on every tab of the pull's
245245 page, in the terminal's pull view, and as `proposal` in the pull's JSON
246 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. `GET …/repos/:owner/:name/events` answers `{ items, next_cursor }`, newest first, `limit` 1 to 200 (default 50) and `cursor` from `next_cursor` (anything else is a 422); it used to answer a bare array of every event, so a client that read the array reads `items` now. A request reads at most 1,000 events, so when most of them are hidden from the reader a page can come back short with a `next_cursor`: keep following it until it is null. MCP's `events` tool answers the same. `GET …/repos/:owner/:name/branches` answers `{ items, next_cursor }` in the order it always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the name of the last branch on it); anything else is a 422, and a cursor naming a branch deleted between two requests is `invalid_cursor`, so start over. It used to answer a bare array, so a client that read the array reads `items` now.
246 (`GET …/pulls/:number`, and MCP's `pull` tool). `GET …/pulls/:number?diff=0` reads the pull without its diff (and skips computing it); `diff` is `0` or `1`, default `1`, and anything else answers 422. `GET …/issues?fields=brief` lists issues with only number, kind, title, state, labels, assignee, author, originAuthor, authorIsAgent, createdAt and updatedAt (and `untrusted` where it applies); no bodies or comments. `fields` is `brief` or left out, and anything else answers 422 `invalid_fields`. `GET …/repos/:owner/:name/events` answers `{ items, next_cursor }`, newest first, `limit` 1 to 200 (default 50) and `cursor` from `next_cursor` (anything else is a 422); it used to answer a bare array of every event, so a client that read the array reads `items` now. A request reads at most 1,000 events, so when most of them are hidden from the reader a page can come back short with a `next_cursor`: keep following it until it is null. MCP's `events` tool answers the same. `GET …/repos/:owner/:name/branches` answers `{ items, next_cursor }` in the order it always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the name of the last branch on it); anything else is a 422, and a cursor naming a branch deleted between two requests is `invalid_cursor`, so start over. It used to answer a bare array, so a client that read the array reads `items` now. `GET …/repos/:owner/:name/forks`, `…/labels` and `…/hooks` answer `{ items, next_cursor }` too, in the order they always had, `limit` 1 to 200 (default 50) and `cursor` the `next_cursor` of the page before (the id of the last row on it); anything else is a 422, and a cursor naming a row deleted between two requests is `invalid_cursor`, so start over. They used to answer a bare array, so a client that read the array reads `items` now.

The README now describes the new shape, but src/contract/openapi/routes/getApiV1ReposOwnerName{Labels,Forks,Hooks}.ts still describe a bare array with no limit or cursor. The preview's /api/v1/openapi.json serves that old text. Update all three the way getApiV1ReposOwnerNameBranches.ts is written: summary, query, and refusals.

247247
248248 ### Proposing to a parent forge
249249
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts +12 −1
@@ -1,3 +1,14 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/forks`, summary: "List the forks of a repository you can read", auth: false };
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/forks`,
6 summary:
7 "List the forks of a repository you can read; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
8 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 refusals: {
11 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
12 invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)",
13 },
14 };
src/contract/openapi/routes/getApiV1ReposOwnerNameHooks.ts +12 −1
@@ -1,3 +1,14 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/hooks`, summary: "List the repository's webhooks (admin scope); answers an array of webhooks", auth: true };
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/hooks`,
6 summary:
7 "List the repository's webhooks (admin scope), each without its secret; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
8 auth: true,
9 query: { limit: "integer", cursor: "string" },
10 refusals: {
11 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
12 invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)",
13 },
14 };
src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts +12 −1
@@ -1,3 +1,14 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/labels`, summary: "List a repository's labels by name as a bare array of { id, name, color }; a private repository needs a token", auth: false };
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/labels`,
6 summary:
7 "List a repository's labels by name, each { id, name, color }; a private repository needs a token; answers { items: [...], next_cursor }; limit 1 to 200, cursor from next_cursor",
8 auth: false,
9 query: { limit: "integer", cursor: "string" },
10 refusals: {
11 invalid_limit: "limit is not a whole number from 1 to 200 (422)",
12 invalid_cursor: "cursor is not the next_cursor of a page before, or names a row deleted since (422)",
13 },
14 };
src/http/api.ts +43 −2
@@ -1033,6 +1033,45 @@ export function keyedWindow(ctx: Ctx): { limit: number; cursor: string | null }
10331033 return { limit, cursor };
10341034 }
10351035
1036 /**
1037 * `?limit=&cursor=` for a list read whole and cut here: limit is a whole
1038 * number from 1 to 200 (default 50), the cursor an id from a previous page.
1039 * Anything else is the 422 to answer.
1040 */
1041 export function pageParams(
1042 ctx: Ctx,
1043 ): { limit: number; cursor: string | null } | Response {
1044 const q = ctx.url.searchParams;
1045 const rawLimit = q.get("limit");
1046 const limit =

Low: pageParams repeats the limit parsing in keyedWindow just above, with the literals 50 and 200 instead of LIST_LIMIT/LIST_LIMIT_MAX. If the default or maximum changes, these three routes will drift from branches. Use the constants (or share one limit parser) in a follow-up.

1047 rawLimit === null ? 50 : /^\d{1,4}$/.test(rawLimit) ? Number(rawLimit) : 0;
1048 if (limit < 1 || limit > 200)
1049 return apiError("invalid_limit", 422, "limit is a whole number from 1 to 200");
1050 const cursor = q.get("cursor");
1051 if (cursor !== null && !/^[A-Za-z0-9_-]{1,64}$/.test(cursor))
1052 return apiError("invalid_cursor", 422, "cursor is the next_cursor of a previous page");
1053 return { limit, cursor };
1054 }
1055
1056 /**
1057 * One page of `rows` (already in order) after the row whose id is the cursor,
1058 * as a response. A cursor no row answers to (deleted between two requests, or
1059 * invented) is a 422 `invalid_cursor`, as for branches: an empty last page
1060 * would tell the client it had seen everything.
1061 */
1062 export function pageOf<T extends { id: string }>(
1063 rows: T[],
outdated

A cursor whose row was deleted between two requests lands here and returns an empty last page, so the client stops early without knowing it missed rows. Use a keyset cursor (labels name > cursor, hooks id > cursor), or answer 422 invalid_cursor as …/branches does.

1064 page: { limit: number; cursor: string | null },
1065 ): Response {
1066 const at = page.cursor === null ? -1 : rows.findIndex((r) => r.id === page.cursor);
1067 if (page.cursor !== null && at === -1)
1068 return apiError("invalid_cursor", 422, "that cursor names a row that is gone: start over");
1069 const start = at + 1;
1070 const items = rows.slice(start, start + page.limit);
1071 const more = start + page.limit < rows.length;
1072 return json({ items, next_cursor: more ? items[items.length - 1]!.id : null });
1073 }
1074
10361075 /** The page of `rows` (sorted by name) after the cursor: `{ items, next_cursor }`. */
10371076 export function keyedPage<T>(
10381077 rows: T[],
@@ -4281,8 +4320,10 @@ export function apiRoutes(router: Router<Handler>): void {
42814320 if (denied) return denied;
42824321 const repo = loadRepo(ctx);
42834322 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
4284 const hooks = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id));
4285 return timePhase(ctx.request, "render", () => json(hooks));
4323 const page = pageParams(ctx);
4324 if (page instanceof Response) return page;
4325 const rows = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id));
4326 return timePhase(ctx.request, "render", () => pageOf(rows, page));
42864327 });
42874328
42884329 router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => {
src/http/api/labels.ts +5 −3
@@ -7,7 +7,7 @@
77 import { createLabel, deleteLabel } from "../../actions/repos.ts";
88 import { listLabels } from "../../core/issues.ts";
99 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
10 import { loadRepo } from "../api.ts";
10 import { loadRepo, pageOf, pageParams } from "../api.ts";
1111 import type { Handler } from "../context.ts";
1212 import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
1313 import type { Router } from "../router.ts";
@@ -17,8 +17,10 @@ export function labelsRoutes(router: Router<Handler>): void {
1717 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
1818 const repo = loadRepo(ctx);
1919 if (!repo) return notFound();
20 const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id));
21 return timePhase(ctx.request, "render", () => json(labels));
20 const page = pageParams(ctx);
21 if (page instanceof Response) return page;
22 const rows = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id));
23 return timePhase(ctx.request, "render", () => pageOf(rows, page));
2224 });
2325
2426 router.add("POST", "/api/v1/repos/:owner/:name/labels", async (ctx) => {
src/http/api/upstream.ts +5 −3
@@ -18,7 +18,7 @@ import { canAdmin, visibleForks } from "../../core/repos.ts";
1818 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1919 import { storageLimitMessage } from "../../core/storage.ts";
2020 import { getFollow } from "../../core/upstream_follow.ts";
21 import { loadRepo } from "../api.ts";
21 import { loadRepo, pageOf, pageParams } from "../api.ts";
2222 import type { Handler } from "../context.ts";
2323 import { apiError, body, json, notFound, requireScope, respondAccess, str } from "../respond.ts";
2424 import type { Router } from "../router.ts";
@@ -75,8 +75,10 @@ export function upstreamRoutes(router: Router<Handler>): void {
7575 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
7676 const repo = loadRepo(ctx);
7777 if (!repo) return notFound();
78 const forks = timePhase(ctx.request, "forks", () => visibleForks(ctx.app.db, repo.id, ctx.auth.user));
79 return timePhase(ctx.request, "render", () => json(forks));
78 const page = pageParams(ctx);
79 if (page instanceof Response) return page;
80 const rows = timePhase(ctx.request, "forks", () => visibleForks(ctx.app.db, repo.id, ctx.auth.user));
81 return timePhase(ctx.request, "render", () => pageOf(rows, page));
8082 });
8183
8284 router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => {
test/api-list-shape.test.ts −3
@@ -17,9 +17,6 @@ import { seedAdmin } from "./helpers/admin.ts";
1717
1818 /** Routes that answer a bare array today → the open issue that changes them. */
1919 const BARE: Record<string, string> = {
20 "/api/v1/repos/:owner/:name/forks": "#4843",
21 "/api/v1/repos/:owner/:name/labels": "#4843",
22 "/api/v1/repos/:owner/:name/hooks": "#4843",
2320 "/api/v1/repos/:owner/:name/commits": "#5083",
2421 "/api/v1/user/origins": "#4841",
2522 "/api/v1/user/origin-apps": "#4841",
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([]);
115 expect(await api.json()).toEqual({ items: [], next_cursor: null });
116116 }
117117 const owners = await page("/alice/lib/forks", "bob");
118118 expect(owners).toContain("1 fork");
test/list-pages.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * A repository's forks, labels and webhooks answer `{ items, next_cursor }`
3 * (#4843): paged by `limit` and `cursor`, a bad limit a 422, and each route
4 * keeps its own permission and filter.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { commitToBranch, createRepo, forkRepo, updateRepoSettings } from "../src/actions/repos.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createLabel } from "../src/core/issues.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { createWebhook } from "../src/core/webhooks.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { promote } from "./helpers/members.ts";
19
20 describe("forks, labels and webhooks are pages", () => {
21 let dataDir: string;
22 let app: App;
23 let servers: Servers;
24 let lib: Repo;
25 const tokens: Record<string, string> = {};
26
27 beforeAll(async () => {
28 dataDir = await mkdtemp(join(tmpdir(), "forge-list-pages-"));
29 app = createApp({ dataDir, port: 0 });
30 servers = await serve(app);
31 const users: Record<string, User> = {};
32 for (const [name, first] of [["alice", true], ["bob", false], ["carol", false], ["dave", false]] as const) {
33 const made = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: name,
37 email: `${name}@forge.local`,
38 password: "a-good-passphrase",
39 adminIfFirst: first,
40 });
41 if (!made.ok) throw new Error(made.error);
42 users[name] = made.value;
43 tokens[name] = createToken(app.db, made.value.id, "t", "admin").token;
44 }
45 promote(app.db, users["bob"]!, users["carol"]!);
46 const made = await createRepo(app, users["alice"]!, { name: "lib", visibility: "public" });
47 if (!made.ok) throw new Error(String(made.error));
48 lib = made.value;
49 const seeded = await commitToBranch(app, users["alice"]!, lib, {
50 message: "Start",
51 files: [{ path: "README.md", content: "# lib\n" }],
52 });
53 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
54 const bobs = await forkRepo(app, users["bob"]!, lib);
55 if (!bobs.ok) throw new Error(String(bobs.error));
56 const carols = await forkRepo(app, users["carol"]!, lib);
57 if (!carols.ok) throw new Error(String(carols.error));
58 const closed = await updateRepoSettings(app, users["carol"]!, carols.value, { visibility: "private" });
59 if (!closed.ok) throw new Error(String(closed.error));
60 for (const name of ["bug", "chore", "docs"]) createLabel(app.db, lib.id, name, "aabbcc");
61 for (const n of [1, 2, 3])
62 if (!createWebhook(app.db, { repoId: lib.id, url: `https://example.com/h${n}` }).ok)
63 throw new Error("hook");
64 }, 60_000);
65
66 afterAll(async () => {
67 servers.stop();
68 app.close();
69 await rm(dataDir, { recursive: true, force: true });
70 });
71
72 const get = async (path: string, who?: string) => {
73 const res = await fetch(`${servers.url}/api/v1/repos/alice/lib${path}`, {
74 headers: who ? { authorization: `Bearer ${tokens[who]}` } : {},
75 });
76 return { status: res.status, body: (await res.json()) as any };
77 };
78
79 test("forks: the shape, and a private fork is not shown to a stranger", async () => {
80 const stranger = await get("/forks", "dave");
81 expect(stranger.status).toBe(200);
82 expect(stranger.body.next_cursor).toBeNull();
83 expect(stranger.body.items.map((f: any) => f.ownerName)).toEqual(["bob"]);
84 const owner = await get("/forks", "carol");
85 expect(owner.body.items.map((f: any) => f.ownerName).sort()).toEqual(["bob", "carol"]);
86 });
87
88 test("forks: paging walks every fork once", async () => {
89 const first = await get("/forks?limit=1", "carol");
90 expect(first.body.items).toHaveLength(1);
91 expect(first.body.next_cursor).not.toBeNull();
92 const second = await get(`/forks?limit=1&cursor=${first.body.next_cursor}`, "carol");
93 expect(second.body.items).toHaveLength(1);
94 expect(second.body.next_cursor).toBeNull();
95 expect(second.body.items[0].id).not.toBe(first.body.items[0].id);
96 });
97
98 test("labels: the shape and paging", async () => {
99 const all = await get("/labels");
100 expect(all.body.items.map((l: any) => l.name)).toEqual(["bug", "chore", "docs"]);
101 expect(all.body.next_cursor).toBeNull();
102 const first = await get("/labels?limit=2");
103 expect(first.body.items.map((l: any) => l.name)).toEqual(["bug", "chore"]);
104 const rest = await get(`/labels?limit=2&cursor=${first.body.next_cursor}`);
105 expect(rest.body.items.map((l: any) => l.name)).toEqual(["docs"]);
106 expect(rest.body.next_cursor).toBeNull();
107 });
108
109 test("hooks: the shape, paging, admin only, and no secret", async () => {
110 const all = await get("/hooks", "alice");
111 expect(all.status).toBe(200);
112 expect(all.body.items).toHaveLength(3);
113 expect(JSON.stringify(all.body)).not.toContain("whsec");
114 expect(all.body.items[0]).not.toHaveProperty("secret");
115 const first = await get("/hooks?limit=2", "alice");
116 expect(first.body.items).toHaveLength(2);
117 const rest = await get(`/hooks?limit=2&cursor=${first.body.next_cursor}`, "alice");
118 expect(rest.body.items).toHaveLength(1);
119 expect(rest.body.next_cursor).toBeNull();
120 expect((await get("/hooks", "dave")).status).toBe(404);
121 expect((await get("/hooks")).status).toBeGreaterThanOrEqual(401);
122 });
123
124 test("a cursor whose row is gone is a 422, not a short end", async () => {
125 for (const [path, who] of [["/forks", "carol"], ["/labels", "alice"], ["/hooks", "alice"]] as const) {
126 const res = await get(`${path}?cursor=01notarealid`, who);
127 expect(res.status).toBe(422);
128 expect(res.body.error).toBe("invalid_cursor");
129 }
130 });
131
132 test("a bad limit or cursor is a 422 on all three", async () => {
133 for (const path of ["/forks", "/labels", "/hooks"])
134 for (const bad of ["limit=0", "limit=201", "limit=abc", "limit=-1", "limit=1.5", "cursor=a%20b"]) {
135 const res = await get(`${path}?${bad}`, "alice");
136 expect(res.status).toBe(422);
137 expect(res.body.error).toBe(bad.startsWith("cursor") ? "invalid_cursor" : "invalid_limit");
138 }
139 });
140
141 test("deleting the row a page ended on makes the next read start over", async () => {
142 const first = await get("/labels?limit=1", "alice");
143 expect(first.body.next_cursor).toBeTruthy();
144 const del = await fetch(`${servers.url}/api/v1/repos/alice/lib/labels/${first.body.next_cursor}`, {
145 method: "DELETE",
146 headers: { authorization: `Bearer ${tokens["alice"]}` },
147 });
148 expect(del.status).toBe(204);
149 const next = await get(`/labels?limit=1&cursor=${first.body.next_cursor}`, "alice");
150 expect(next.status).toBe(422);
151 expect(next.body.error).toBe("invalid_cursor");
152 });
153 });
test/origins-sync.test.ts +2 −2
@@ -323,10 +323,10 @@ describe("origin sync: incremental, event-shaped, and polite", () => {
323323 // The second sweep sent If-None-Match and got a 304 back — and the labels
324324 // survived, because a 304 must serve the cached body, not an empty one.
325325 expect(fake.state.conditionalHits).toBeGreaterThan(hitsBefore);
326 const labels = await read<{ name: string }[]>(
326 const labels = await read<{ items: { name: string }[] }>(
327327 await api("/api/v1/repos/alice/sprockets/labels"),
328328 );
329 expect(labels.map((l) => l.name)).toEqual(["bug"]);
329 expect(labels.items.map((l) => l.name)).toEqual(["bug"]);
330330 });
331331
332332 test("the worker's due sweep is time-driven and bounded", async () => {
test/origins.test.ts +2 −2
@@ -418,8 +418,8 @@ describe("origins: connecting, mirroring, writing through", () => {
418418 // Labels, issues and pulls carry the origin's numbers and its authors.
419419 const labels = (await (
420420 await api("/api/v1/repos/alice/widgets/labels")
421 ).json()) as { name: string }[];
422 expect(labels.map((l) => l.name).sort()).toEqual(["bug", "chore"]);
421 ).json()) as { items: { name: string }[] };
422 expect(labels.items.map((l) => l.name).sort()).toEqual(["bug", "chore"]);
423423
424424 const issues = (await (
425425 await api("/api/v1/repos/alice/widgets/issues?state=all")
test/parity.test.ts +2 −2
@@ -396,7 +396,7 @@ describe("parity: full control through actions and the API", () => {
396396 });
397397 expect(queue.status).toBe(201);
398398 expect(
399 (await call("GET", "/api/v1/repos/alice/pub/labels")).list.map(
399 ((await call("GET", "/api/v1/repos/alice/pub/labels")).json.items as Json[]).map(
400400 (l) => l.name,
401401 ),
402402 ).toEqual(["bug", "queue"]);
@@ -714,7 +714,7 @@ describe("parity: full control through actions and the API", () => {
714714 );
715715 expect((detail.json.diff as Json).files).toHaveLength(1);
716716 const forks = await call("GET", "/api/v1/repos/alice/pub/forks");
717 expect(forks.list.map((f) => f.ownerName)).toEqual(["bob"]);
717 expect((forks.json.items as Json[]).map((f) => f.ownerName)).toEqual(["bob"]);
718718 });
719719
720720 test("numbers from another repo are not_found here", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs