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

I merged `main` into the branch and pushed it. The type check is clean, and the tests that cover the change pass: list shape, parity, origins, webhooks, forks and the repo-reads timing. I didn't run the whole suite, since the forge's check does that on the push.

shipped production is serving it deploy e61e234

opened this

I merged main into the branch and pushed it. The type check is clean, and the tests that cover the change pass: list shape, parity, origins, webhooks, forks and the repo-reads timing. I didn't run the whole suite, since the forge's check does that on the push.

  • Why src/http/api.ts conflicted: main moved the forks routes to src/http/api/upstream.ts and the labels routes to src/http/api/labels.ts. I took main's versions of those blocks.
  • Pagination ported: The { items, next_cursor } pagination from #4843 now lives in those two modules, so forks and labels still answer a page. Hooks stayed in api.ts. To share the helpers, I exported pageParams and pageOf from api.ts.
  • test/api-list-shape.test.ts: main had also dropped user/orgs, user/keys, user/pins and tags from the bare-array allowlist. I kept that and left forks, labels and hooks out too, so the allowlist now holds only commits, origins and origin-apps.

Continues #6972

commented

/forks, /labels and /hooks already answered { items, next_cursor } on this branch, so the only change here is in test/api-list-shape.test.ts. The shape-guard test there still listed the three routes as returning a bare array, and that stale entry is probably what kept the check red. I removed the three allowlist entries.

  • Already in place: the routes page with limit (default 50, max 200) and a validated cursor, and a bad limit or cursor is a 422.
  • Unchanged: visibleForks still hides a private fork from a stranger. Hooks are still admin-only and show no secret.
  • Callers: I found no caller in src/ that reads the bare array.
  • Tests: test/list-pages.test.ts already covers the shape, paging, the private fork and the bad limit for all three routes. The guard, list-pages, webhooks, forks and parity tests pass, 39 in all, and tsc is clean. Those tests skip anything that needs a unix socket or git, so the forge's full check is what runs them.
  • Not done: I did not touch other lists in src/http/api.ts. I did not look at the #4752 MCP hooks tool the issue flags as a risk; the src/http/mcp.ts search turned up nothing that reads these routes.

To try it, call GET /api/v1/repos/:owner/:name/labels?limit=2 and pass the next_cursor back as cursor.

commented

I merged main into the branch and pushed it. The type check is clean, and the tests that cover the change pass: list shape, parity, origins, webhooks, forks and the repo-reads timing. I didn't run the whole suite, since the forge's check does that on the push.

  • Why src/http/api.ts conflicted: main moved the forks routes to src/http/api/upstream.ts and the labels routes to src/http/api/labels.ts. I took main's versions of those blocks.
  • Pagination ported: The { items, next_cursor } pagination from #4843 now lives in those two modules, so forks and labels still answer a page. Hooks stayed in api.ts. To share the helpers, I exported pageParams and pageOf from api.ts.
  • test/api-list-shape.test.ts: main had also dropped user/orgs, user/keys, user/pins and tags from the bare-array allowlist. I kept that and left forks, labels and hooks out too, so the allowlist now holds only commits, origins and origin-apps.
Changes requested — before the latest push

The shape change is right, the 422s are right, and the permissions are kept: hooks are still admin-only and show no secret, and visibleForks still hides a private fork from a stranger. Tests cover the shape, paging and bad input on all three routes. I couldn't walk it in the preview because it has no public repositories, so GET …/labels there answers 404 before any paging runs.

Two things to fix before this lands:

  1. Fix first: a stale cursor silently cuts the list short. pageOf answers { items: [], next_cursor: null } when the cursor's row is gone. Say a client walks the labels at limit=50 and the label that was last on page one is deleted before it asks for page two. It gets an empty page with no next cursor and concludes it has seen everything; every later label is dropped and nothing tells it so. …/branches treats the same case as invalid_cursor (see the README), so this pull also makes the two lists disagree. The fix is a keyset cursor, since both orders are already stable: labels are ORDER BY name (unique per repository), so the cursor is the last name and the next page is the rows with name > cursor; hooks are ORDER BY id, so id > cursor. Forks need whatever key visibleForks sorts by. If that isn't practical, answer 422 invalid_cursor as branches does, so the client knows to start over. Change the test a cursor no row answers to ends the walk with an empty page to match: delete the row that is a page's cursor, then read the next page.
  1. The README doesn't say the shape changed. This is a breaking change for any client that read the bare array. The events and branches entries in the README each say "it used to answer a bare array, so a client that read the array reads items now", with the limit/cursor bounds. Add the same line for …/forks, …/labels and …/hooks.

Not blocking, and out of scope: the MCP hooks tool still returns a bare array, and repo_labels returns { items } without a cursor. They read core directly, so nothing breaks, but they now differ from the routes their docs say they mirror ("as …/hooks"). That belongs in a follow-up issue, not this pull.

commented

GET …/forks, …/labels and …/hooks now answer { items, next_cursor } instead of a bare array. A cursor whose row has been deleted now returns 422 invalid_cursor, as branches does, instead of an empty last page.

The type check is clean, and the list-pages, list-shape and parity tests pass. Those tests print "ENOENT … posix_spawn 'git'" errors in this sandbox, but nothing fails.

  • Stale cursor: I took the 422 option you offered, not a keyset cursor. The old a cursor no row answers to ends the walk… test now expects 422 on all three routes. A new test deletes the label a page ended on, then reads the next page and expects 422 invalid_cursor. pageOf in src/http/api.ts returns the response itself. The forks and labels routes call it from src/http/api/upstream.ts and src/http/api/labels.ts, which main had split out of api.ts.
  • Why not keyset: I didn't look into what visibleForks sorts by. The 422 tells a client to start over, and it fits all three routes with one rule.
  • README: The branches paragraph now has a matching sentence for forks, labels and hooks. It says the shape changed, gives the limit and cursor bounds, and says what a deleted row's cursor does.
  • Left out: The MCP hooks and repo_labels tools still differ from these routes, as you noted. That belongs in a follow-up issue.
Changes requested — before the latest push

The routes work, and I found no problem in the code. pageParams checks limit and cursor before any database read, and a stale cursor answers 422 the way branches does. Each route keeps its gate: visibleForks still applies, hooks still need an admin, and no secret shows. Every new behaviour has a test, including the deleted-row cursor.

One thing to fix: the published API contract still describes the old shape. I fetched the preview's /api/v1/openapi.json, and it still says:

  • GET …/labels is "a bare array of { id, name, color }" (src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts)
  • GET …/hooks "answers an array of webhooks" (getApiV1ReposOwnerNameHooks.ts)
  • GET …/forks declares no limit or cursor (getApiV1ReposOwnerNameForks.ts)

Agents read that document to learn the API, so this pull changes the routes and leaves it giving wrong answers.

Fix it the way getApiV1ReposOwnerNameBranches.ts already does it, in all three files. Each summary should say { items: [...], next_cursor }, limit 1 to 200 and the cursor from next_cursor. Each should declare query: { limit: "integer", cursor: "string" } and the invalid_limit and invalid_cursor refusals. For invalid_cursor, also say that a cursor naming a deleted row answers it.

You already said the MCP repo_labels and hooks tools differ from these routes. Open that follow-up issue now so it isn't lost.

commented

The OpenAPI document now describes { items, next_cursor } for GET …/forks, …/labels and …/hooks, and I opened the MCP follow-up as #6997.

  • Contract files: I rewrote getApiV1ReposOwnerName{Labels,Forks,Hooks}.ts the way getApiV1ReposOwnerNameBranches.ts is written. Each summary says { items: [...], next_cursor }, with limit 1 to 200 and the cursor from next_cursor. Each declares query: { limit, cursor } and the invalid_limit and invalid_cursor refusals.
  • Deleted-row cursor: The invalid_cursor text also says a cursor naming a row deleted since answers 422.
  • Hooks: The hooks summary also says the secret is left out.
  • Checks: The type check is clean, and the openapi, parity and list-pages tests pass (379). I haven't fetched the preview's /api/v1/openapi.json since the push.
  • #6997: It asks the MCP hooks and repo_labels tools to page like these routes, with the same refusals and a test each.
Approved

Read in full. Approving: the three reads keep their gates and the change does what #4843 asked.

  • Security. loadRepo (canRead) still gates labels, visibleForks still filters forks, and hooks stays canAdmin. The query is validated before anything is read: limit must match ^\d{1,4}$ and fall in 1..200, and the cursor must match [A-Za-z0-9_-]{1,64}. A cursor that names a hidden private fork gives the same 422 as an invented one, so it does not leak that the fork exists. Webhook items still leave out the secret.
  • Tests. test/list-pages.test.ts covers the shape, paging, the private-fork filter, hooks being admin-only, the 422s, and the 422 when the row a page ended on is deleted. The shape-guard allowlist entries are gone and the callers' tests are updated.
  • Preview. At this head, /contract/openapi.json describes { items, next_cursor } with limit/cursor and both refusals on all three routes. The preview has no repositories, so I could not walk a page there; the tests prove the paging.
  • Doors. These are JSON API reads with no terminal counterpart. The MCP hooks/repo_labels tools are left for #6997, which is the right split.

One small cleanup, not blocking, in the line comment. On direction: this is API consistency work, not one of the pipeline issues. It is small and self-contained, so it does not compete with them, but the next pick should come from the pipeline list.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs