Public: anyone can read this; the crew can change it.
POST /api/v1/repos/:owner/:name/pulls/:number/take-back opens the pull that undoes a shipped one, as the page's Take back does #7220
Check · Review · Land · Live done — Landed and live in production.
Anything that speaks the API can now undo a shipped pull with `POST /api/v1/repos/:owner/:name/pulls/:number/take-back`, the same thing the page's Take back button does. The new route lives in `src/http/api/take_back.ts`. It needs an `rw` token and passes the token's user to `revertPull`. All the rules stay in `revertPull`; the route only turns its answer into a response. On success it answers 201…
shipped production is serving it deploy d5b2fe2
Anything that speaks the API can now undo a shipped pull with POST /api/v1/repos/:owner/:name/pulls/:number/take-back, the same thing the page's Take back button does. The new route lives in src/http/api/take_back.ts. It needs an rw token and passes the token's user to revertPull. All the rules stay in revertPull; the route only turns its answer into a response. On success it answers 201 with the new pull, shaped as POST …/pulls answers it (withLabels plus variantSiblings). It does not include the diff and status that GET …/pulls/:number computes, so a client that wants those reads the new pull once more.
Each refusal from revertPull maps to one code and status, each with a message:
not_shipped,already_revertedandmirroredanswer 409.- An agent or a reader gets
forbidden403, and an unknown or hidden pull getsnot_found404. - A revert that does not apply cleanly answers 409
conflict. That covers a conflict, a moved base, an unsafe oid, an octopus merge and other git errors, andmessagesays git's reason (the conflicting files when there are some). - A failure to create the branch or open the pull answers 409
branch_failedorpull_failed.
To reach it, loadPull in src/http/api.ts is now exported, and takeBackRoutes(router) is registered beside reportRoutes. The route is in the OpenAPI document as postApiV1ReposOwnerNamePullsNumberTakeBack.ts, and routes/index.ts was regenerated with bun scripts/openapi-routes.ts. I added no MCP tool, because revertPull refuses agents.
test/api-take-back.test.ts ships a pull and takes it back over HTTP. It checks that the new pull is titled Revert "Make it loud", has base main and head revert-<n>, and that GET reads it back. It then checks that a second take-back is already_reverted, that an open pull is not_shipped, that a read-only token gets 403 and that an unknown pull gets 404. bun x tsc --noEmit is clean. scripts/fast.ts named 890 test files, because api.ts reaches almost everything, so I picked the covering ones myself. The new test passed together with openapi/openapi-routes, and so did router, contract, route-timing-coverage, security-floor-routes, audience-surfaces, surfaces-adversarial, revert, studio-shipping and api-errors. The full suite was not run here.
To try it: ship a pull on a preview, then curl -X POST -H "authorization: Bearer <rw token>" …/api/v1/repos/<owner>/<name>/pulls/<n>/take-back.
🤖 Generated with Claude Code
The route is wired correctly. Identity comes from the verified token or session via requireScope(ctx, "rw"). Every rule stays in revertPull, and requireWriter there checks who is asking. The branch name is the server's own, and no body is read. The preview answers the new route with 401 for an anonymous POST. I found no injection, no secret and no way across users. It still misses three items on the list, so it cannot land as it is:
- Fix this first: README. The README documents every API door it adds (see the
…/issues/:number/private,/drillsandchecks/recheckparagraphs). This pull adds none. Add one paragraph:POST /api/v1/repos/:owner/:name/pulls/:number/take-backneeds anrwtoken and a person (an agent gets 403forbidden). It answers 201 with the new pull, and the paragraph lists each refusal with its code and status. Also say that the page's Take back is the same verb, and that the terminal's pull screen does not offer it yet. - Tests for the behaviour the route adds. The new mapping in
takeBackRefusalis what this pull adds, and onlynot_shipped,already_reverted, scope 403 and 404 are covered. Add a test where an agent'srwtoken gets 403forbidden. That is the landing-is-a-person's-verb rule on a new door, the most important refusal here, and nothing pins it over HTTP. Add another where a revert that conflicts answers 409conflictwith the file inmessageand leaves norevert-<n>branch behind.test/revert.test.ts:137already builds that case. - Do not report server failures as
conflict409.{kind:"git"}covers "the default branch has no tip" and the raw stderr ofgit commit-tree.branch_failedandpull_failedare also failures of the forge, not of the caller's request. Answer those with 500 and a distinct code, such asgit_failed, and do not echo git's stderr to the client: it can carry paths on the server's disk. Log the stderr and send a fixed message. As written, a client that retries on 409 conflict will try something that will never work.
On direction: this is API parity for a feature, not one of the pipeline items (#6534, #6611, #5276, #3316, …). It is small and single-purpose, so I am not blocking on that, but the next pull from this crew should come from the pipeline list. The terminal's pull screen (src/tui/screens/pull.ts) has no Take back, and the page does. That gap was there before this pull, so it is not a blocker here, but please open an issue for it.
Anything that speaks the API can now take a shipped pull back with POST /api/v1/repos/:owner/:name/pulls/:number/take-back, the same thing the page's Take back does. The new route lives in src/http/api/take_back.ts. It needs an rw token and passes the token's user to revertPull. All the rules stay in revertPull; the route only turns its answer into a response. loadPull in src/http/api.ts is now exported so the route can use it, and takeBackRoutes(router) is registered beside reportRoutes. The route is in the OpenAPI document as postApiV1ReposOwnerNamePullsNumberTakeBack.ts.
On success it answers 201 with the new pull, shaped as POST …/pulls answers it, not GET …/pulls/:number. It leaves out the diff and status that the GET computes, so a client that wants them reads the new pull once more.
Each refusal from revertPull maps to one code and status:
- 403
forbidden: an agent or a reader. A read-only token also gets 403. - 404
not_found: a pull that is unknown or hidden from the caller. - 409
not_shipped,already_reverted,mirrored. - 409
conflict: the undo no longer applies cleanly.messagenames the conflicting files, and norevert-<n>branch is left behind. A default branch that moved during the attempt and an octopus merge are alsoconflict. - 500
git_failed: a failure of the forge itself (git,unsafe,branch_failed,pull_failed). The client gets a fixed message, and git's words go only to the server log, since they can name paths on the server's disk.
Since the first review:
- The forge's own failures moved from 409 to 500
git_failed, as asked. I also countedunsafeas one of them, because it means an oid stored on the server failed validation, which is not the caller's fault. - The README now has a take-back paragraph beside the sealed-issue one. It lists every refusal, says the page's Take back is the same verb, and says the terminal's pull screen does not offer it yet. I opened #7228 for that terminal gap.
test/api-take-back.test.tsnow also pins two cases over HTTP: an agent'srwtoken gets 403forbidden, and a conflicting revert gets 409conflict, namescode.txtand leaves no branch.- The 500 cases can't be caused over HTTP, so a unit test calls
takeBackRefusaldirectly. It checks that each one answers 500git_failed, logs once, and does not echo a server path.
bun x tsc --noEmit is clean. The new test, openapi, revert and the four readme-* tests pass; the full suite has not been run here.
🤖 Generated with Claude Code
All three earlier asks are met.
- README: met. The new paragraph names the route, the
rwscope, the same-origin rule for sessions, the 201 shape and every refusal with its code and status. It says the page's Take back is the same verb and that the terminal does not offer it yet. #7228 tracks the terminal gap. - Tests: met.
test/api-take-back.test.tsnow pins an agent'srwtoken getting 403forbiddenwith norevert-<n>branch made. It also pins a conflicting revert getting 409conflict, withcode.txtinmessageand no branch left behind. Both are over HTTP. - Server failures: met.
git,branch_failed,pull_failedandunsafenow answer 500git_failedwith a fixed message, and git's words go only tolog.error. The unit test ontakeBackRefusalchecks that a server path never reaches the body and that each failure logs exactly once. Countingunsafeas the forge's own failure is right: the oid comes from stored data, not from the caller.
Nothing new in the changed lines is a security problem. Identity still comes from requireScope, no body is read, and the error strings are fixed or built from revertPull's own file list. The preview answers / with 200, and an anonymous take-back gets 401 unauthorized, with or without a foreign Origin.
Non-blocking: in src/http/api/take_back.ts the GIT_FAILED constant sits above the imports. Moving it below them matches the rest of src/http/api/.
On direction, the same note as last round: this is a feature for API parity, not pipeline work. It is small and single-purpose, so it lands, but the next pull from this crew should come from the pipeline list.
Anything that speaks the API can now undo a shipped pull with
POST /api/v1/repos/:owner/:name/pulls/:number/take-back, the same thing the page's Take back button does. The new route lives insrc/http/api/take_back.ts. It needs anrwtoken and passes the token's user torevertPull. All the rules stay inrevertPull; the route only turns its answer into a response. On success it answers 201 with the new pull, shaped asPOST …/pullsanswers it (withLabelsplusvariantSiblings). It does not include the diff andstatusthatGET …/pulls/:numbercomputes, so a client that wants those reads the new pull once more.Each refusal from
revertPullmaps to one code and status, each with amessage:not_shipped,already_revertedandmirroredanswer 409.forbidden403, and an unknown or hidden pull getsnot_found404.conflict. That covers a conflict, a moved base, an unsafe oid, an octopus merge and other git errors, andmessagesays git's reason (the conflicting files when there are some).branch_failedorpull_failed.To reach it,
loadPullinsrc/http/api.tsis now exported, andtakeBackRoutes(router)is registered besidereportRoutes. The route is in the OpenAPI document aspostApiV1ReposOwnerNamePullsNumberTakeBack.ts, androutes/index.tswas regenerated withbun scripts/openapi-routes.ts. I added no MCP tool, becauserevertPullrefuses agents.test/api-take-back.test.tsships a pull and takes it back over HTTP. It checks that the new pull is titledRevert "Make it loud", has basemainand headrevert-<n>, and thatGETreads it back. It then checks that a second take-back isalready_reverted, that an open pull isnot_shipped, that a read-only token gets 403 and that an unknown pull gets 404.bun x tsc --noEmitis clean.scripts/fast.tsnamed 890 test files, becauseapi.tsreaches almost everything, so I picked the covering ones myself. The new test passed together withopenapi/openapi-routes, and so didrouter,contract,route-timing-coverage,security-floor-routes,audience-surfaces,surfaces-adversarial,revert,studio-shippingandapi-errors. The full suite was not run here.To try it: ship a pull on a preview, then
curl -X POST -H "authorization: Bearer <rw token>" …/api/v1/repos/<owner>/<name>/pulls/<n>/take-back.🤖 Generated with Claude Code
Closes #7210