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.
mason/forge:mason/7210-post-api-v1-repos-owner-name-pulls-numbe → main 2 commits fork 3 threads · 3 unresolved
README.md +2
| @@ -54,6 +54,8 @@ A pull the merge queue holds as red alone (red on its own merge at its current h | ||
| 54 | 54 | |
| 55 | 55 | On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`. |
| 56 | 56 | |
| 57 | **Taking a shipped pull back**: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` (a person who can write here, `rw` token scope; a session needs a same-origin request) opens a new pull, `Revert "<title>"` from the branch `revert-<n>` into the default branch, that undoes exactly what the shipped pull merged. Nothing lands until a person merges it. It takes no body and answers 201 with the new pull, as `POST …/pulls` answers one. Refusals: an agent or a reader gets 403 `forbidden`; a read-only token gets 403; a pull that is unknown or that you may not see gets 404 `not_found`; 409 `not_shipped` (it never merged), 409 `already_reverted`, 409 `mirrored` (the origin owns the history); 409 `conflict` when the undo no longer applies cleanly, with the conflicting files in `message`, and nothing is left behind. A failure of the forge's own git is 500 `git_failed` with a fixed message; git's words go to the server log. The pull page's **Take back** is the same verb (`revertPull` in `src/actions/revert.ts`). The terminal's pull screen does not offer it yet. | |
| 58 | ||
| 57 | 59 | Under the agents, each team that has a pull has one pooled row (its members' |
| 58 | 60 | counts added, the ratios taken from the sums; an agent on two teams counts in |
| 59 | 61 | both): `data-team` rows on the page, "team <name>" rows in that terminal screen. |
src/contract/openapi/routes/index.ts +1
| @@ -236,6 +236,7 @@ export { route as postApiV1ReposOwnerNamePullsNumberLooks } from "./postApiV1Rep | ||
| 236 | 236 | export { route as postApiV1ReposOwnerNamePullsNumberMerge } from "./postApiV1ReposOwnerNamePullsNumberMerge.ts"; |
| 237 | 237 | export { route as postApiV1ReposOwnerNamePullsNumberReviewStates } from "./postApiV1ReposOwnerNamePullsNumberReviewStates.ts"; |
| 238 | 238 | export { route as postApiV1ReposOwnerNamePullsNumberReviews } from "./postApiV1ReposOwnerNamePullsNumberReviews.ts"; |
| 239 | export { route as postApiV1ReposOwnerNamePullsNumberTakeBack } from "./postApiV1ReposOwnerNamePullsNumberTakeBack.ts"; | |
| 239 | 240 | export { route as postApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve } from "./postApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve.ts"; |
| 240 | 241 | export { route as postApiV1ReposOwnerNamePullsNumberVariant } from "./postApiV1ReposOwnerNamePullsNumberVariant.ts"; |
| 241 | 242 | export { route as postApiV1ReposOwnerNameReports } from "./postApiV1ReposOwnerNameReports.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNamePullsNumberTakeBack.ts added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { method: "post", path: `${REPO}/pulls/{number}/take-back`, summary: "Open the pull that undoes a shipped one (a person's)", auth: true }; |
src/http/api.ts +3 −1
| @@ -411,6 +411,7 @@ import { boardRoutes } from "./api/boards.ts"; | ||
| 411 | 411 | import { followingRoutes } from "./api/following.ts"; |
| 412 | 412 | import { labelsRoutes } from "./api/labels.ts"; |
| 413 | 413 | import { reportRoutes } from "./api/reports.ts"; |
| 414 | import { takeBackRoutes } from "./api/take_back.ts"; | |
| 414 | 415 | import { orgRoutes } from "./api/orgs.ts"; |
| 415 | 416 | import { promptRoutes } from "./api/prompts.ts"; |
| 416 | 417 | import { secretRoutes } from "./api/secrets.ts"; |
| @@ -1197,7 +1198,7 @@ const holdJson = (hold: Hold) => ({ | ||
| 1197 | 1198 | ...(hold.until ? { until: hold.until } : {}), |
| 1198 | 1199 | }); |
| 1199 | 1200 | |
| 1200 | function loadPull(ctx: Ctx): Found<{ pull: Pull }> { | |
| 1201 | export function loadPull(ctx: Ctx): Found<{ pull: Pull }> { | |
| 1201 | 1202 | const repo = loadRepo(ctx); |
| 1202 | 1203 | if (!repo) return { error: notFound() }; |
| 1203 | 1204 | const number = positiveInt(ctx.params.number); |
| @@ -2674,6 +2675,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2674 | 2675 | }); |
| 2675 | 2676 | |
| 2676 | 2677 | reportRoutes(router); |
| 2678 | takeBackRoutes(router); | |
| 2677 | 2679 | |
| 2678 | 2680 | router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => { |
| 2679 | 2681 | // Numbers are shared with pulls: a pull's number answers as the same |
src/http/api/take_back.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * Take it back over the API: `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` | |
| 3 | * opens the pull that undoes a shipped one, as the page's Take back does | |
| 4 | * (`web/studio/actions.ts` `takeBackSubmit`). This is a door only: the | |
| 5 | * rules live in `actions/revert.ts`; this says each refusal as one stable | |
| 6 | * code and status. A refusal of the request is 403/404/409; a failure of | |
| 7 | * the forge's own git is 500 `git_failed`, with git's words kept in the | |
| 8 | * log — they can name paths on the server's disk — and never sent. | |
| 9 | */ | |
| 10 | ||
| 11 | /** What a client is told when the forge's own git failed: nothing to fix on its side. */ | |
| 12 | const GIT_FAILED = "the forge could not build the revert; nothing was changed"; | |
| 13 | ||
| 14 | import { revertPull, type RevertPullError } from "../../actions/revert.ts"; | |
| 15 | import { loadPull, withLabels } from "../api.ts"; | |
| 16 | import type { Log } from "../../kernel/log.ts"; | |
| 17 | import type { Handler } from "../context.ts"; | |
| 18 | import { apiError, json, requireScope, respondAccess } from "../respond.ts"; | |
| 19 | import type { Router } from "../router.ts"; | |
| 20 | import { variantSiblings } from "../../core/pulls.ts"; | |
| 21 | ||
| 22 | /** A refused take-back as the API says it: one code and status per refusal. */ | |
| 23 | export function takeBackRefusal(error: RevertPullError, log: Log): Response { | |
| 24 | if (typeof error === "string") { | |
| 25 | switch (error) { | |
| 26 | case "not_shipped": | |
| 27 | return apiError("not_shipped", 409, "only a shipped pull can be taken back"); | |
| 28 | case "already_reverted": | |
| 29 | return apiError("already_reverted", 409, "this pull has already been taken back"); | |
| 30 | case "mirrored": | |
| 31 | return apiError( | |
| 32 | "mirrored", | |
| 33 | 409, | |
| 34 | "this repository mirrors an origin; take it back there and the next sync will reflect it", | |
| 35 | ); | |
| 36 | case "forbidden": | |
| 37 | return apiError("forbidden", 403, "only a person who can write here can take a pull back"); | |
| 38 | default: | |
| 39 | return respondAccess(error, 403); | |
| 40 | } | |
| 41 | } | |
| 42 | switch (error.kind) { | |
| 43 | case "conflict": | |
| 44 | return apiError( | |
outdated
Medium. |
||
| 45 | "conflict", | |
| 46 | 409, | |
| 47 | `it conflicts with the current code in ${error.files.join(", ")}`, | |
| 48 | ); | |
| 49 | case "base_moved": | |
| 50 | return apiError("conflict", 409, "the default branch moved under it; try again"); | |
| 51 | case "octopus": | |
| 52 | return apiError("conflict", 409, `it has ${error.parents} parents; take it back by hand`); | |
| 53 | case "unsafe": | |
| 54 | log.error("take-back: an unsafe oid reached git"); | |
| 55 | return apiError("git_failed", 500, GIT_FAILED); | |
| 56 | case "git": | |
| 57 | log.error("take-back: git failed", { message: error.message }); | |
| 58 | return apiError("git_failed", 500, GIT_FAILED); | |
| 59 | case "branch_failed": | |
| 60 | case "pull_failed": | |
| 61 | log.error(`take-back: ${error.kind}`, { reason: error.reason }); | |
| 62 | return apiError("git_failed", 500, GIT_FAILED); | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | export function takeBackRoutes(router: Router<Handler>): void { | |
| 67 | router.add( | |
| 68 | "POST", | |
| 69 | "/api/v1/repos/:owner/:name/pulls/:number/take-back", | |
| 70 | async (ctx) => { | |
| 71 | const denied = requireScope(ctx, "rw"); | |
| 72 | if (denied) return denied; | |
| 73 | const found = loadPull(ctx); | |
| 74 | if ("error" in found) return found.error; | |
| 75 | const reverted = await revertPull(ctx.app, ctx.auth.user, found.repo, found.pull); | |
| 76 | if (!reverted.ok) return takeBackRefusal(reverted.error, ctx.app.log); | |
| 77 | return json( | |
| 78 | { | |
| 79 | ...withLabels(ctx, reverted.value), | |
| 80 | variantSiblings: variantSiblings(ctx.app.db, [reverted.value])[0] ?? [], | |
| 81 | }, | |
| 82 | 201, | |
| 83 | ); | |
| 84 | }, | |
| 85 | ); | |
| 86 | } |
test/api-take-back.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * `POST /api/v1/repos/:owner/:name/pulls/:number/take-back` over HTTP: a | |
| 3 | * shipped pull taken back through the API opens the pull that undoes it, | |
| 4 | * shaped as the pull API answers one; a second take-back is | |
| 5 | * `already_reverted`, an open pull is `not_shipped`, an agent's `rw` token | |
| 6 | * is `forbidden` (landing is a person's verb), a revert that conflicts is | |
outdated
Missing: an agent with an |
||
| 7 | * `conflict` with the file named and no branch left, and a read-only token | |
| 8 | * is refused by scope. A failure of the forge's own git is 500 `git_failed` | |
| 9 | * with git's words logged, never sent. The rules are `revertPull`'s | |
| 10 | * (`test/revert.test.ts`). | |
| 11 | */ | |
| 12 | ||
| 13 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 14 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 19 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 20 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 21 | import type { Pull } from "../src/core/pulls.ts"; | |
| 22 | import type { Repo } from "../src/core/repos.ts"; | |
| 23 | import { createToken } from "../src/core/tokens.ts"; | |
| 24 | import { createUser, type User } from "../src/core/users.ts"; | |
| 25 | import { takeBackRefusal } from "../src/http/api/take_back.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { resolveOid } from "../src/git/merge.ts"; | |
| 28 | import { repoDir } from "../src/git/repos.ts"; | |
| 29 | import { quietLogs } from "./helpers/globals.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | describe("POST /api/v1/repos/:owner/:name/pulls/:number/take-back", () => { | |
| 34 | let dataDir = ""; | |
| 35 | let app: App; | |
| 36 | let servers: Servers; | |
| 37 | let alice: User; | |
| 38 | let repo: Repo; | |
| 39 | let rw = ""; | |
| 40 | let ro = ""; | |
| 41 | let agentRw = ""; | |
| 42 | ||
| 43 | const change = async (branch: string, title: string, content: string): Promise<Pull> => { | |
| 44 | await commitToBranch(app, alice, repo, { | |
| 45 | branch, | |
| 46 | base: "main", | |
| 47 | message: title, | |
| 48 | files: [{ path: "code.txt", content }], | |
| 49 | }); | |
| 50 | const opened = await openPull(app, alice, repo, { title, baseBranch: "main", headBranch: branch }); | |
| 51 | if (!opened.ok) throw new Error(JSON.stringify(opened.error)); | |
| 52 | return opened.value; | |
| 53 | }; | |
| 54 | ||
| 55 | const ship = async (branch: string, title: string, content: string): Promise<Pull> => { | |
| 56 | const pull = await change(branch, title, content); | |
| 57 | const merged = await mergePull(app, alice, repo, pull, "squash"); | |
| 58 | if (!merged.ok) throw new Error(JSON.stringify(merged.error)); | |
| 59 | return pull; | |
| 60 | }; | |
| 61 | ||
| 62 | const branchExists = async (name: string): Promise<boolean> => | |
| 63 | (await resolveOid(repoDir(app.reposRoot, repo.ownerName, repo.name), `refs/heads/${name}`)) !== | |
| 64 | undefined; | |
| 65 | ||
| 66 | const takeBack = (number: number, token: string) => | |
| 67 | fetch(`${servers.url}/api/v1/repos/alice/app/pulls/${number}/take-back`, { | |
| 68 | method: "POST", | |
| 69 | headers: { origin: servers.url, authorization: `Bearer ${token}` }, | |
| 70 | }); | |
| 71 | ||
| 72 | beforeAll(async () => { | |
| 73 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-take-back-")); | |
| 74 | app = createApp({ dataDir, port: 0 }); | |
| 75 | servers = await serve(app); | |
| 76 | const made = await createUser(app.db, { | |
| 77 | origin: "seed", | |
| 78 | trusted: true, | |
| 79 | username: "alice", | |
| 80 | email: "[email protected]", | |
| 81 | password: "a-good-passphrase", | |
| 82 | adminIfFirst: true, | |
| 83 | }); | |
| 84 | if (!made.ok) throw new Error(made.error); | |
| 85 | alice = made.value; | |
| 86 | const madeRepo = await createRepo(app, alice, { name: "app", visibility: "public" }); | |
| 87 | if (!madeRepo.ok) throw new Error(String(madeRepo.error)); | |
| 88 | repo = madeRepo.value; | |
| 89 | await commitToBranch(app, alice, repo, { | |
| 90 | branch: "main", | |
| 91 | message: "base", | |
| 92 | files: [{ path: "code.txt", content: "alpha\n" }], | |
| 93 | }); | |
| 94 | rw = createToken(app.db, alice.id, "rw", "rw").token; | |
| 95 | ro = createToken(app.db, alice.id, "ro", "ro").token; | |
| 96 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 97 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 98 | const crewed = addToCrew(app, alice, repo, "bot"); | |
| 99 | if (!crewed.ok) throw new Error(String(crewed.error)); | |
| 100 | agentRw = createToken(app.db, agent.value.user.id, "rw", "rw").token; | |
| 101 | }, 30_000); | |
| 102 | ||
| 103 | afterAll(async () => { | |
| 104 | await servers.stop(); | |
| 105 | app.close(); | |
| 106 | await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a shipped pull taken back opens the pull that undoes it; a second take-back is already_reverted", async () => { | |
| 110 | const shipped = await change("loud", "Make it loud", "alpha\nBETA\n"); | |
| 111 | const merged = await mergePull(app, alice, repo, shipped, "squash"); | |
| 112 | expect(merged.ok).toBe(true); | |
| 113 | ||
| 114 | const res = await takeBack(shipped.number, rw); | |
| 115 | expect(res.status).toBe(201); | |
| 116 | const opened = (await res.json()) as Record<string, unknown>; | |
| 117 | expect(opened.title).toBe('Revert "Make it loud"'); | |
| 118 | expect(opened.baseBranch).toBe("main"); | |
| 119 | expect(opened.headBranch).toBe(`revert-${shipped.number}`); | |
| 120 | expect(opened.number).not.toBe(shipped.number); | |
| 121 | expect(opened.labels).toEqual([]); | |
| 122 | ||
| 123 | // The new pull is the one the pull API reads back. | |
| 124 | const read = await fetch(`${servers.url}/api/v1/repos/alice/app/pulls/${opened.number}?diff=0`); | |
| 125 | expect(read.status).toBe(200); | |
| 126 | expect(((await read.json()) as Record<string, unknown>).title).toBe('Revert "Make it loud"'); | |
| 127 | ||
| 128 | const again = await takeBack(shipped.number, rw); | |
| 129 | expect(again.status).toBe(409); | |
| 130 | expect(((await again.json()) as { error: string }).error).toBe("already_reverted"); | |
| 131 | }); | |
| 132 | ||
| 133 | test("an open pull is not_shipped", async () => { | |
| 134 | const open = await change("quiet", "Make it quiet", "alpha\ngamma\n"); | |
| 135 | const res = await takeBack(open.number, rw); | |
| 136 | expect(res.status).toBe(409); | |
| 137 | const said = (await res.json()) as { error: string; message: string }; | |
| 138 | expect(said.error).toBe("not_shipped"); | |
| 139 | expect(said.message).toBeTruthy(); | |
| 140 | }); | |
| 141 | ||
| 142 | test("a read-only token is refused by scope, and an unknown pull is not_found", async () => { | |
| 143 | const res = await takeBack(1, ro); | |
| 144 | expect(res.status).toBe(403); | |
| 145 | const unknown = await takeBack(999, rw); | |
| 146 | expect(unknown.status).toBe(404); | |
| 147 | }); | |
| 148 | ||
| 149 | test("an agent's rw token is forbidden: taking a change back is a person's verb", async () => { | |
| 150 | const shipped = await ship("epsilon", "Add epsilon", "alpha\nEPSILON\n"); | |
| 151 | const res = await takeBack(shipped.number, agentRw); | |
| 152 | expect(res.status).toBe(403); | |
| 153 | expect(((await res.json()) as { error: string }).error).toBe("forbidden"); | |
| 154 | expect(await branchExists(`revert-${shipped.number}`)).toBe(false); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a revert that conflicts is conflict, names the file, and leaves no branch", async () => { | |
| 158 | const gamma = await ship("gamma", "Add gamma", "alpha\nGAMMA\n"); | |
| 159 | // The same line changes again, so undoing "Add gamma" cannot apply. | |
| 160 | await ship("delta", "Change it again", "alpha\nDELTA\n"); | |
| 161 | const res = await takeBack(gamma.number, rw); | |
| 162 | expect(res.status).toBe(409); | |
| 163 | const said = (await res.json()) as { error: string; message: string }; | |
| 164 | expect(said.error).toBe("conflict"); | |
| 165 | expect(said.message).toContain("code.txt"); | |
| 166 | expect(await branchExists(`revert-${gamma.number}`)).toBe(false); | |
| 167 | }); | |
| 168 | }); | |
| 169 | ||
| 170 | describe("takeBackRefusal: a failure of the forge's own git", () => { | |
| 171 | const logged: string[] = []; | |
| 172 | const log = { | |
| 173 | debug: () => {}, | |
| 174 | info: () => {}, | |
| 175 | warn: () => {}, | |
| 176 | error: (message: string, fields?: Record<string, unknown>) => { | |
| 177 | logged.push(`${message} ${JSON.stringify(fields ?? {})}`); | |
| 178 | }, | |
| 179 | }; | |
| 180 | ||
| 181 | test("git, branch and pull failures are 500 git_failed, logged and never echoed", async () => { | |
| 182 | const stderr = "fatal: /data/repos/alice/app.git/objects: permission denied"; | |
| 183 | for (const error of [ | |
| 184 | { kind: "git" as const, message: stderr }, | |
| 185 | { kind: "branch_failed" as const, reason: stderr }, | |
| 186 | { kind: "pull_failed" as const, reason: stderr }, | |
| 187 | { kind: "unsafe" as const }, | |
| 188 | ]) { | |
| 189 | logged.length = 0; | |
| 190 | const res = takeBackRefusal(error, log); | |
| 191 | expect(res.status).toBe(500); | |
| 192 | const text = await res.text(); | |
| 193 | expect(JSON.parse(text).error).toBe("git_failed"); | |
| 194 | expect(text).not.toContain("/data/repos"); | |
| 195 | expect(logged.length).toBe(1); | |
| 196 | } | |
| 197 | expect(logged[0]).toContain("take-back"); | |
| 198 | }); | |
| 199 | }); |
This mapping is the whole behaviour of the pull, and most of its branches have no test. Pin at least
forbidden(an agent's rw token → 403) andconflict(→ 409 with the file named, no branch left) over HTTP, and pin the git→500 change once it is made.