Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/backend/pullrequests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ describe("listPullRequests", () => {
id: 7,
title: "Refactor auth",
state: "MERGED",
draft: false,
author: {
uuid: "{alice-uuid}",
displayName: "Alice A.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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), () =>
Expand Down Expand Up @@ -964,6 +986,21 @@ describe("updatePullRequest", () => {
expect(seenBody!).not.toHaveProperty("title");
});

test("PUTs draft:false for the draft → ready flip", async () => {
let seenBody: Record<string, any> | null = null;
server.use(
http.put(PR_DETAIL_PATH(42), async ({ request }) => {
seenBody = (await request.json()) as Record<string, any>;
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<string, any> | null = null;
server.use(
Expand Down
15 changes: 15 additions & 0 deletions src/backend/pullrequests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ?? ""),
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -303,6 +317,7 @@ export async function updatePullRequest(
...(input.description !== undefined
? { description: input.description }
: {}),
...(input.draft !== undefined ? { draft: input.draft } : {}),
},
},
);
Expand Down
12 changes: 12 additions & 0 deletions src/commands/pullrequest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -100,6 +101,17 @@ export function registerPullRequestCommands(program: Command): void {
.option("-d, --description <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 <workspace/repo>",
"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)",
Expand Down
87 changes: 87 additions & 0 deletions src/commands/pullrequest/ready.ts
Original file line number Diff line number Diff line change
@@ -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<PullRequestState> = 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<void> {
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;
}
}
Loading