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.
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
/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 validatedcursor, and a bad limit or cursor is a 422. - Unchanged:
visibleForksstill 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.tsalready 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, andtscis clean. Those tests skip anything that needs a unix socket orgit, 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 MCPhookstool the issue flags as a risk; thesrc/http/mcp.tssearch 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.
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.tsconflicted:mainmoved the forks routes tosrc/http/api/upstream.tsand the labels routes tosrc/http/api/labels.ts. I tookmain'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 inapi.ts. To share the helpers, I exportedpageParamsandpageOffromapi.ts. test/api-list-shape.test.ts:mainhad also droppeduser/orgs,user/keys,user/pinsandtagsfrom 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.
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:
- Fix first: a stale cursor silently cuts the list short.
pageOfanswers{ items: [], next_cursor: null }when the cursor's row is gone. Say a client walks the labels atlimit=50and 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.…/branchestreats the same case asinvalid_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 areORDER BY name(unique per repository), so the cursor is the last name and the next page is the rows withname > cursor; hooks areORDER BY id, soid > cursor. Forks need whatever keyvisibleForkssorts by. If that isn't practical, answer 422invalid_cursoras branches does, so the client knows to start over. Change the testa cursor no row answers to ends the walk with an empty pageto match: delete the row that is a page's cursor, then read the next page.
- 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
itemsnow", with thelimit/cursorbounds. Add the same line for…/forks,…/labelsand…/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.
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 422invalid_cursor.pageOfinsrc/http/api.tsreturns the response itself. The forks and labels routes call it fromsrc/http/api/upstream.tsandsrc/http/api/labels.ts, whichmainhad split out ofapi.ts. - Why not keyset: I didn't look into what
visibleForkssorts 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
limitandcursorbounds, and says what a deleted row's cursor does. - Left out: The MCP
hooksandrepo_labelstools still differ from these routes, as you noted. That belongs in a follow-up issue.
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 …/labelsis "a bare array of { id, name, color }" (src/contract/openapi/routes/getApiV1ReposOwnerNameLabels.ts)GET …/hooks"answers an array of webhooks" (getApiV1ReposOwnerNameHooks.ts)GET …/forksdeclares nolimitorcursor(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.
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}.tsthe waygetApiV1ReposOwnerNameBranches.tsis written. Each summary says{ items: [...], next_cursor }, withlimit1 to 200 and the cursor fromnext_cursor. Each declaresquery: { limit, cursor }and theinvalid_limitandinvalid_cursorrefusals. - Deleted-row cursor: The
invalid_cursortext 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.jsonsince the push. - #6997: It asks the MCP
hooksandrepo_labelstools to page like these routes, with the same refusals and a test each.
Read in full. Approving: the three reads keep their gates and the change does what #4843 asked.
- Security.
loadRepo(canRead) still gates labels,visibleForksstill 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.tscovers 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.jsondescribes{ items, next_cursor }withlimit/cursorand 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_labelstools 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.
I merged
maininto 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.src/http/api.tsconflicted:mainmoved the forks routes tosrc/http/api/upstream.tsand the labels routes tosrc/http/api/labels.ts. I tookmain's versions of those blocks.{ items, next_cursor }pagination from #4843 now lives in those two modules, so forks and labels still answer a page. Hooks stayed inapi.ts. To share the helpers, I exportedpageParamsandpageOffromapi.ts.test/api-list-shape.test.ts:mainhad also droppeduser/orgs,user/keys,user/pinsandtagsfrom 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