From a625d180f31e17674d90c50feecd4e5baf6317c7 Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Wed, 22 Apr 2026 09:51:04 +0000 Subject: [PATCH] BBC2-25 add bb pr ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks a draft PR as ready for review via PUT /pullrequests/{id} with body { draft: false }. Defaults to the PR for the current branch. Reuses the updatePullRequest helper from BBC2-23. Adds `draft?: boolean` to UpdatePullRequestInput and exposes `PullRequest.draft` so the command can pre-flight a GET and: 1. Refuse cleanly on MERGED / DECLINED / SUPERSEDED (API would 4xx). 2. Short-circuit when the PR is already ready — not relying on the API tolerating a no-op `{draft:false}` PUT (the spec is silent on that). No `--undraft` counterpart: Bitbucket Cloud has no API path from ready back to draft, so it's deliberately out of scope. Stacked on BBC2-23 so updatePullRequest lands together; rebase to main after that PR merges. --- src/backend/pullrequests/index.test.ts | 37 +++++++++++ src/backend/pullrequests/index.ts | 15 +++++ src/commands/pullrequest/index.ts | 12 ++++ src/commands/pullrequest/ready.ts | 87 ++++++++++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 src/commands/pullrequest/ready.ts diff --git a/src/backend/pullrequests/index.test.ts b/src/backend/pullrequests/index.test.ts index 2ff5007..29aa524 100644 --- a/src/backend/pullrequests/index.test.ts +++ b/src/backend/pullrequests/index.test.ts @@ -312,6 +312,7 @@ describe("listPullRequests", () => { id: 7, title: "Refactor auth", state: "MERGED", + draft: false, author: { uuid: "{alice-uuid}", displayName: "Alice A.", @@ -373,6 +374,7 @@ describe("getPullRequest", () => { id: 42, title: "Rework auth", state: "OPEN", + draft: false, author: { uuid: "{alice}", displayName: "Alice", nickname: "alice" }, createdOn: "2026-04-10T00:00:00Z", updatedOn: "2026-04-13T00:00:00Z", @@ -416,6 +418,26 @@ describe("getPullRequest", () => { expect((err as PullRequestError).status).toBe(404); }); + test("maps raw.draft onto the PR detail", async () => { + server.use( + http.get(PR_DETAIL_PATH(42), () => + HttpResponse.json(makePrDetail({ draft: true })), + ), + ); + + const result = await getPullRequest(creds, ref, 42); + expect(result.draft).toBe(true); + }); + + test("draft defaults to false when the field is missing", async () => { + server.use( + http.get(PR_DETAIL_PATH(42), () => HttpResponse.json(makePrDetail())), + ); + + const result = await getPullRequest(creds, ref, 42); + expect(result.draft).toBe(false); + }); + test("handles a PR with no participants", async () => { server.use( http.get(PR_DETAIL_PATH(42), () => @@ -964,6 +986,21 @@ describe("updatePullRequest", () => { expect(seenBody!).not.toHaveProperty("title"); }); + test("PUTs draft:false for the draft → ready flip", async () => { + let seenBody: Record | null = null; + server.use( + http.put(PR_DETAIL_PATH(42), async ({ request }) => { + seenBody = (await request.json()) as Record; + return HttpResponse.json(makePrDetail({ id: 42, draft: false })); + }), + ); + + const result = await updatePullRequest(creds, ref, 42, { draft: false }); + + expect(seenBody!).toEqual({ type: "pullrequest", draft: false }); + expect(result.draft).toBe(false); + }); + test("PUTs title and description together when both provided", async () => { let seenBody: Record | null = null; server.use( diff --git a/src/backend/pullrequests/index.ts b/src/backend/pullrequests/index.ts index 23e1a01..bdd9dd6 100644 --- a/src/backend/pullrequests/index.ts +++ b/src/backend/pullrequests/index.ts @@ -37,6 +37,13 @@ export type PullRequest = { id: number; title: string; state: PullRequestState; + /** + * Bitbucket models draft as a boolean flag alongside the `state` enum — + * a draft PR still reports `state: "OPEN"`. Keep them independent so + * downstream code doesn't have to inspect both fields to know "is this + * a draft". + */ + draft: boolean; author: PullRequestAuthor | null; createdOn: string; updatedOn: string; @@ -145,6 +152,7 @@ function toPullRequest(pr: RawPullRequest): PullRequest { id: Number(raw.id ?? 0), title: String(raw.title ?? ""), state: String(raw.state ?? "") as PullRequestState, + draft: Boolean(raw.draft ?? false), author: toAuthor(raw.author), createdOn: String(raw.created_on ?? ""), updatedOn: String(raw.updated_on ?? ""), @@ -268,6 +276,12 @@ export async function createPullRequest( export type UpdatePullRequestInput = { title?: string; description?: string; + /** + * Set to `false` to flip a draft PR to ready for review. The reverse + * (ready → draft) is not supported by Bitbucket Cloud and intentionally + * not exposed here. + */ + draft?: boolean; }; /** @@ -303,6 +317,7 @@ export async function updatePullRequest( ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.draft !== undefined ? { draft: input.draft } : {}), }, }, ); diff --git a/src/commands/pullrequest/index.ts b/src/commands/pullrequest/index.ts index 9581a45..699e900 100644 --- a/src/commands/pullrequest/index.ts +++ b/src/commands/pullrequest/index.ts @@ -5,6 +5,7 @@ import { runPullRequestCreate } from "./create.ts"; import { runPullRequestDiff } from "./diff.ts"; import { runPullRequestEdit } from "./edit.ts"; import { runPullRequestList } from "./list.ts"; +import { runPullRequestReady } from "./ready.ts"; import { runPullRequestReview } from "./review.ts"; import { runPullRequestView } from "./view.ts"; @@ -100,6 +101,17 @@ export function registerPullRequestCommands(program: Command): void { .option("-d, --description ", "New description") .action(withRenderer(runPullRequestEdit)); + pr.command("ready") + .description( + "Mark a draft pull request as ready for review (defaults to the PR for the current branch)", + ) + .argument("[id]", "Pull request number") + .option( + "-R, --repository ", + "Override repository detection", + ) + .action(withRenderer(runPullRequestReady)); + pr.command("review") .description( "Submit a review on a pull request (defaults to the PR for the current branch)", diff --git a/src/commands/pullrequest/ready.ts b/src/commands/pullrequest/ready.ts new file mode 100644 index 0000000..e604024 --- /dev/null +++ b/src/commands/pullrequest/ready.ts @@ -0,0 +1,87 @@ +import { + getPullRequest, + PullRequestError, + type PullRequestState, + updatePullRequest, +} 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 PullRequestReadyOptions = { + repository?: string; +}; + +const IMMUTABLE_STATES: ReadonlySet = new Set([ + "MERGED", + "DECLINED", + "SUPERSEDED", +]); + +/** + * Marks a draft PR as ready for review via `PUT /pullrequests/{id}` with + * `{draft: false}`. Idempotent at the command level: pre-flights with a + * GET and short-circuits when the PR is already ready, so we don't rely + * on the API tolerating `{draft: false}` on an already-ready PR (the + * spec is silent on that case). + * + * Reverse (ready → draft) isn't supported by Bitbucket Cloud — the API + * has no path for it — so no `--undraft` counterpart. + */ +export async function runPullRequestReady( + renderer: Renderer, + idArg: string | undefined, + options: PullRequestReadyOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + try { + const ref = await resolveRepository({ override: options.repository }); + const id = await resolveCurrentPullRequestId(idArg, { + renderer, + config, + ref, + commandName: "ready", + }); + + const current = await getPullRequest(config, ref, id); + if (IMMUTABLE_STATES.has(current.state)) { + renderer.error( + `Pull request #${id} is ${current.state.toLowerCase()}; cannot mark as ready.`, + ); + process.exit(1); + } + + if (!current.draft) { + if (renderer.json) { + renderer.detail(current, []); + return; + } + renderer.message(`Pull request #${id} is already ready: ${current.url}`); + return; + } + + const updated = await updatePullRequest(config, ref, id, { draft: false }); + + if (renderer.json) { + renderer.detail(updated, []); + } else { + renderer.message( + `Marked pull request #${id} as ready for review: ${updated.url}`, + ); + } + } catch (err) { + if ( + err instanceof RepositoryResolutionError || + err instanceof PullRequestError + ) { + renderer.error(err.message); + process.exit(1); + } + throw err; + } +}