Public: anyone can read this; the crew can change it.
A repository's forks, labels and webhooks answer `{ items, next_cursor }` #6972
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4843-a-repository-s-forks-labels-and-webhooks → main 7 commits fork 3 threads · 3 unresolved
README.md +1 −1
| @@ -243,7 +243,7 @@ runs no check and no preview until a writer approves its head or lets it run | ||
| 243 | 243 | (**Let it run here**, which is not a review; a new push waits again), and a bare push to `child/…` starts none. A pull |
| 244 | 244 | opened with it says "proposed by child … from …" on every tab of the pull's |
| 245 | 245 | 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. | |
| 247 | 247 | |
| 248 | 248 | ### Proposing to a parent forge |
| 249 | 249 |
src/contract/openapi/routes/getApiV1ReposOwnerNameForks.ts +12 −1
| @@ -1,3 +1,14 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 } | ||
| 1033 | 1033 | return { limit, cursor }; |
| 1034 | 1034 | } |
| 1035 | 1035 | |
| 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: |
||
| 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 |
||
| 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 | ||
| 1036 | 1075 | /** The page of `rows` (sorted by name) after the cursor: `{ items, next_cursor }`. */ |
| 1037 | 1076 | export function keyedPage<T>( |
| 1038 | 1077 | rows: T[], |
| @@ -4281,8 +4320,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4281 | 4320 | if (denied) return denied; |
| 4282 | 4321 | const repo = loadRepo(ctx); |
| 4283 | 4322 | 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)); | |
| 4286 | 4327 | }); |
| 4287 | 4328 | |
| 4288 | 4329 | router.add("POST", "/api/v1/repos/:owner/:name/hooks", async (ctx) => { |
src/http/api/labels.ts +5 −3
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | import { createLabel, deleteLabel } from "../../actions/repos.ts"; |
| 8 | 8 | import { listLabels } from "../../core/issues.ts"; |
| 9 | 9 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 10 | import { loadRepo } from "../api.ts"; | |
| 10 | import { loadRepo, pageOf, pageParams } from "../api.ts"; | |
| 11 | 11 | import type { Handler } from "../context.ts"; |
| 12 | 12 | import { body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| 13 | 13 | import type { Router } from "../router.ts"; |
| @@ -17,8 +17,10 @@ export function labelsRoutes(router: Router<Handler>): void { | ||
| 17 | 17 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 18 | 18 | const repo = loadRepo(ctx); |
| 19 | 19 | 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)); | |
| 22 | 24 | }); |
| 23 | 25 | |
| 24 | 26 | 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"; | ||
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 19 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 20 | 20 | import { getFollow } from "../../core/upstream_follow.ts"; |
| 21 | import { loadRepo } from "../api.ts"; | |
| 21 | import { loadRepo, pageOf, pageParams } from "../api.ts"; | |
| 22 | 22 | import type { Handler } from "../context.ts"; |
| 23 | 23 | import { apiError, body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; |
| 24 | 24 | import type { Router } from "../router.ts"; |
| @@ -75,8 +75,10 @@ export function upstreamRoutes(router: Router<Handler>): void { | ||
| 75 | 75 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 76 | 76 | const repo = loadRepo(ctx); |
| 77 | 77 | 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)); | |
| 80 | 82 | }); |
| 81 | 83 | |
| 82 | 84 | 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"; | ||
| 17 | 17 | |
| 18 | 18 | /** Routes that answer a bare array today → the open issue that changes them. */ |
| 19 | 19 | 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", | |
| 23 | 20 | "/api/v1/repos/:owner/:name/commits": "#5083", |
| 24 | 21 | "/api/v1/user/origins": "#4841", |
| 25 | 22 | "/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", () => { | ||
| 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([]); | |
| 115 | expect(await api.json()).toEqual({ items: [], next_cursor: null }); | |
| 116 | 116 | } |
| 117 | 117 | const owners = await page("/alice/lib/forks", "bob"); |
| 118 | 118 | 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", () => { | ||
| 323 | 323 | // The second sweep sent If-None-Match and got a 304 back — and the labels |
| 324 | 324 | // survived, because a 304 must serve the cached body, not an empty one. |
| 325 | 325 | expect(fake.state.conditionalHits).toBeGreaterThan(hitsBefore); |
| 326 | const labels = await read<{ name: string }[]>( | |
| 326 | const labels = await read<{ items: { name: string }[] }>( | |
| 327 | 327 | await api("/api/v1/repos/alice/sprockets/labels"), |
| 328 | 328 | ); |
| 329 | expect(labels.map((l) => l.name)).toEqual(["bug"]); | |
| 329 | expect(labels.items.map((l) => l.name)).toEqual(["bug"]); | |
| 330 | 330 | }); |
| 331 | 331 | |
| 332 | 332 | 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", () => { | ||
| 418 | 418 | // Labels, issues and pulls carry the origin's numbers and its authors. |
| 419 | 419 | const labels = (await ( |
| 420 | 420 | 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"]); | |
| 423 | 423 | |
| 424 | 424 | const issues = (await ( |
| 425 | 425 | 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", () => { | ||
| 396 | 396 | }); |
| 397 | 397 | expect(queue.status).toBe(201); |
| 398 | 398 | 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( | |
| 400 | 400 | (l) => l.name, |
| 401 | 401 | ), |
| 402 | 402 | ).toEqual(["bug", "queue"]); |
| @@ -714,7 +714,7 @@ describe("parity: full control through actions and the API", () => { | ||
| 714 | 714 | ); |
| 715 | 715 | expect((detail.json.diff as Json).files).toHaveLength(1); |
| 716 | 716 | 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"]); | |
| 718 | 718 | }); |
| 719 | 719 | |
| 720 | 720 | test("numbers from another repo are not_found here", async () => { |
The README now describes the new shape, but
src/contract/openapi/routes/getApiV1ReposOwnerName{Labels,Forks,Hooks}.tsstill describe a bare array with nolimitorcursor. The preview's/api/v1/openapi.jsonserves that old text. Update all three the waygetApiV1ReposOwnerNameBranches.tsis written: summary,query, andrefusals.