diff --git a/src/backend/pullrequests/index.test.ts b/src/backend/pullrequests/index.test.ts index e30b5f4..d8e3a33 100644 --- a/src/backend/pullrequests/index.test.ts +++ b/src/backend/pullrequests/index.test.ts @@ -5,6 +5,7 @@ import { approvePullRequest, createPullRequest, createPullRequestComment, + declinePullRequest, findOpenPullRequestForBranch, getPullRequest, getPullRequestDiff, @@ -921,6 +922,64 @@ describe("review action endpoints (approve / unapprove / request-changes / unreq }); }); +describe("declinePullRequest", () => { + const DECLINE_PATH = (id: number) => + `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}/decline`; + + test("POSTs with no body and returns the declined PR detail", async () => { + let seenMethod: string | null = null; + let seenBody: string | null = null; + server.use( + http.post(DECLINE_PATH(42), async ({ request }) => { + seenMethod = request.method; + seenBody = await request.text(); + return HttpResponse.json(makePrDetail({ id: 42, state: "DECLINED" }), { + status: 200, + }); + }), + ); + + const result = await declinePullRequest(creds, ref, 42); + + expect(seenMethod!).toBe("POST"); + expect(seenBody!).toBe(""); + expect(result.id).toBe(42); + expect(result.state).toBe("DECLINED"); + expect(result.url).toBe("https://bitbucket.org/ws/repo/pull-requests/42"); + }); + + test("propagates HTTP 4xx when the PR is already in a terminal state", async () => { + // Bitbucket returns a 4xx (typically 400) when /decline is called on + // a PR that's already merged, declined, or superseded. The command + // layer's pre-flight GET handles these cases cleanly; this path + // exists for TOCTOU races. + server.use( + http.post(DECLINE_PATH(42), () => + HttpResponse.json( + { type: "error", error: { message: "PR is not open" } }, + { status: 400 }, + ), + ), + ); + + const err = await declinePullRequest(creds, ref, 42).catch((e) => e); + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(400); + }); + + test("surfaces 404 when the PR does not exist", async () => { + server.use( + http.post(DECLINE_PATH(99), () => + HttpResponse.json({ type: "error" }, { status: 404 }), + ), + ); + + const err = await declinePullRequest(creds, ref, 99).catch((e) => e); + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(404); + }); +}); + describe("getPullRequestDiff", () => { const DIFF_PATH = (id: number) => `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}/diff`; diff --git a/src/backend/pullrequests/index.ts b/src/backend/pullrequests/index.ts index 0d7236f..66b7a21 100644 --- a/src/backend/pullrequests/index.ts +++ b/src/backend/pullrequests/index.ts @@ -607,6 +607,45 @@ export async function withdrawRequestChanges( ); } +/** + * Declines an open pull request. Bitbucket returns the updated PR (now + * in state DECLINED) on success. No request body. + * + * Expected failure modes: 4xx when the PR is in a terminal state already + * (merged, declined, superseded). We let the status propagate — the command + * layer does a pre-flight `getPullRequest` to surface those cases with a + * clean user-facing message, so this path is only hit on races or unexpected + * server errors. + */ +export async function declinePullRequest( + credentials: Credentials, + ref: { workspace: string; slug: string }, + pullRequestId: number, +): Promise { + const client = createBitbucketClient(credentials); + const { data, response } = await client.POST( + "/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/decline", + { + params: { + path: { + workspace: ref.workspace, + repo_slug: ref.slug, + pull_request_id: pullRequestId, + }, + }, + }, + ); + + if (!response.ok || !data) { + throw new PullRequestError( + `Failed to decline pull request #${pullRequestId}: HTTP ${response.status}.`, + response.status, + ); + } + + return toPullRequestDetail(data as RawPullRequest); +} + function toPullRequestDetail(pr: RawPullRequest): PullRequestDetail { const base = toPullRequest(pr); const raw = pr as Record; diff --git a/src/commands/pullrequest/decline.ts b/src/commands/pullrequest/decline.ts new file mode 100644 index 0000000..74c1fb4 --- /dev/null +++ b/src/commands/pullrequest/decline.ts @@ -0,0 +1,72 @@ +import { + declinePullRequest, + getPullRequest, + PullRequestError, + type PullRequestState, +} from "../../backend/pullrequests/index.ts"; +import { loadConfigOrExit } from "../../shared/config/index.ts"; +import type { Renderer } from "../../shared/renderer/index.ts"; +import { + RepositoryResolutionError, + resolveRepository, +} from "../../shared/repository/index.ts"; +import { resolveCurrentPullRequestId } from "./current.ts"; + +export type PullRequestDeclineOptions = { + repository?: string; +}; + +const TERMINAL_STATES: ReadonlySet = new Set([ + "MERGED", + "DECLINED", + "SUPERSEDED", +]); + +/** + * Declines an open PR. Pre-flights with a GET so we can refuse cleanly on + * already-terminal states (merged / declined / superseded) without relying + * on Bitbucket's error body shape. The extra request is cheap vs. the + * alternative of parsing 4xx bodies to tell them apart. + */ +export async function runPullRequestDecline( + renderer: Renderer, + idArg: string | undefined, + options: PullRequestDeclineOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + try { + const ref = await resolveRepository({ override: options.repository }); + const id = await resolveCurrentPullRequestId(idArg, { + renderer, + config, + ref, + commandName: "decline", + }); + + const current = await getPullRequest(config, ref, id); + if (TERMINAL_STATES.has(current.state)) { + renderer.error( + `Pull request #${id} is ${current.state.toLowerCase()}; cannot decline.`, + ); + process.exit(1); + } + + const updated = await declinePullRequest(config, ref, id); + + if (renderer.json) { + renderer.detail(updated, []); + return; + } + renderer.message(`Declined pull request #${id}: ${updated.url}`); + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +} diff --git a/src/commands/pullrequest/index.ts b/src/commands/pullrequest/index.ts index eb4197f..8fa2423 100644 --- a/src/commands/pullrequest/index.ts +++ b/src/commands/pullrequest/index.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { withRenderer } from "../../shared/renderer/commander.ts"; import { runPullRequestComment } from "./comment.ts"; import { runPullRequestCreate } from "./create.ts"; +import { runPullRequestDecline } from "./decline.ts"; import { runPullRequestDiff } from "./diff.ts"; import { runPullRequestList } from "./list.ts"; import { runPullRequestReview } from "./review.ts"; @@ -86,6 +87,17 @@ export function registerPullRequestCommands(program: Command): void { ) .action(withRenderer(runPullRequestDiff)); + pr.command("decline") + .description( + "Decline an open pull request (defaults to the PR for the current branch)", + ) + .argument("[id]", "Pull request number") + .option( + "-R, --repository ", + "Override repository detection", + ) + .action(withRenderer(runPullRequestDecline)); + pr.command("review") .description( "Submit a review on a pull request (defaults to the PR for the current branch)",