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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7210-post-api-v1-repos-owner-name-pulls-numbe → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/7210-post-api-v1-repos-owner-name-pulls-numbe was merged into main as b12ba14.

Review

Sign in to review this change.
6 files changed +294 −1
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
5454
5555 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`.
5656
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
5759 Under the agents, each team that has a pull has one pooled row (its members'
5860 counts added, the ratios taken from the sums; an agent on two teams counts in
5961 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
236236 export { route as postApiV1ReposOwnerNamePullsNumberMerge } from "./postApiV1ReposOwnerNamePullsNumberMerge.ts";
237237 export { route as postApiV1ReposOwnerNamePullsNumberReviewStates } from "./postApiV1ReposOwnerNamePullsNumberReviewStates.ts";
238238 export { route as postApiV1ReposOwnerNamePullsNumberReviews } from "./postApiV1ReposOwnerNamePullsNumberReviews.ts";
239 export { route as postApiV1ReposOwnerNamePullsNumberTakeBack } from "./postApiV1ReposOwnerNamePullsNumberTakeBack.ts";
239240 export { route as postApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve } from "./postApiV1ReposOwnerNamePullsNumberThreadsAnchorResolve.ts";
240241 export { route as postApiV1ReposOwnerNamePullsNumberVariant } from "./postApiV1ReposOwnerNamePullsNumberVariant.ts";
241242 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";
411411 import { followingRoutes } from "./api/following.ts";
412412 import { labelsRoutes } from "./api/labels.ts";
413413 import { reportRoutes } from "./api/reports.ts";
414 import { takeBackRoutes } from "./api/take_back.ts";
414415 import { orgRoutes } from "./api/orgs.ts";
415416 import { promptRoutes } from "./api/prompts.ts";
416417 import { secretRoutes } from "./api/secrets.ts";
@@ -1197,7 +1198,7 @@ const holdJson = (hold: Hold) => ({
11971198 ...(hold.until ? { until: hold.until } : {}),
11981199 });
11991200
1200 function loadPull(ctx: Ctx): Found<{ pull: Pull }> {
1201 export function loadPull(ctx: Ctx): Found<{ pull: Pull }> {
12011202 const repo = loadRepo(ctx);
12021203 if (!repo) return { error: notFound() };
12031204 const number = positiveInt(ctx.params.number);
@@ -2674,6 +2675,7 @@ export function apiRoutes(router: Router<Handler>): void {
26742675 });
26752676
26762677 reportRoutes(router);
2678 takeBackRoutes(router);
26772679
26782680 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
26792681 // 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";
outdated

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) and conflict (→ 409 with the file named, no branch left) over HTTP, and pin the git→500 change once it is made.

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. kind: "git" is a failure of the forge (no tip on the default branch, commit-tree failed), not a conflict, and error.message here is git's raw stderr, which can name paths on the server. Answer 500 with a code of its own (git_failed) and a fixed message, and log the stderr on the server. Treat branch_failed and pull_failed the same way.

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 rw token is refused with 403 forbidden. That is the landing rule on a new door. Also missing: a conflicting revert answers 409 conflict, the response names the file, and no revert-<n> branch is left.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs