Skip to content
Merged
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
71 changes: 71 additions & 0 deletions src/backend/pullrequests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { BITBUCKET_BASE, server, setupMsw } from "../../test/msw/server.ts";
import {
createPullRequest,
createPullRequestComment,
findOpenPullRequestForBranch,
getPullRequest,
listPullRequests,
Expand Down Expand Up @@ -567,3 +568,73 @@ describe("createPullRequest", () => {
expect((err as PullRequestError).status).toBe(400);
});
});

describe("createPullRequestComment", () => {
const COMMENTS_PATH = (id: number) =>
`${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}/comments`;

test("POSTs body and markup=markdown, returns id and html url", async () => {
let seenBody: Record<string, any> | null = null;
server.use(
http.post(COMMENTS_PATH(42), async ({ request }) => {
seenBody = (await request.json()) as Record<string, any>;
return HttpResponse.json(
{
type: "pullrequest_comment",
id: 7,
content: {
raw: "hello",
markup: "markdown",
html: "<p>hello</p>",
},
links: {
html: {
href: "https://bitbucket.org/ws/repo/pull-requests/42/_#comment-7",
},
},
},
{ status: 201 },
);
}),
);

const out = await createPullRequestComment(creds, ref, 42, "hello");

expect(seenBody!).toEqual({
type: "pullrequest_comment",
content: { raw: "hello", markup: "markdown" },
});
expect(out).toEqual({
id: 7,
url: "https://bitbucket.org/ws/repo/pull-requests/42/_#comment-7",
});
});

test("throws PullRequestError on 403", async () => {
server.use(
http.post(COMMENTS_PATH(42), () =>
HttpResponse.json({ type: "error" }, { status: 403 }),
),
);

const err = await createPullRequestComment(creds, ref, 42, "nope").catch(
(e) => e,
);
expect(err).toBeInstanceOf(PullRequestError);
expect((err as PullRequestError).status).toBe(403);
});

test("throws PullRequestError on 404", async () => {
server.use(
http.post(COMMENTS_PATH(99), () =>
HttpResponse.json({ type: "error" }, { status: 404 }),
),
);

const err = await createPullRequestComment(creds, ref, 99, "nope").catch(
(e) => e,
);
expect(err).toBeInstanceOf(PullRequestError);
expect((err as PullRequestError).status).toBe(404);
});
});
49 changes: 49 additions & 0 deletions src/backend/pullrequests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,55 @@ export async function findOpenPullRequestForBranch(
return first ? toPullRequest(first as RawPullRequest) : null;
}

export type PullRequestCommentResult = {
id: number;
url: string;
};

/**
* Posts a top-level comment on a pull request. `markup: "markdown"` is
* explicit — the web UI defaults to it but the API's server-side default
* isn't documented, so we send it to be safe. If a later smoke-test shows
* the server already defaults to markdown we can drop the field.
*/
export async function createPullRequestComment(
credentials: Credentials,
ref: { workspace: string; slug: string },
pullRequestId: number,
body: string,
): Promise<PullRequestCommentResult> {
const client = createBitbucketClient(credentials);
const { data, response } = await client.POST(
"/repositories/{workspace}/{repo_slug}/pullrequests/{pull_request_id}/comments",
{
params: {
path: {
workspace: ref.workspace,
repo_slug: ref.slug,
pull_request_id: pullRequestId,
},
},
body: {
type: "pullrequest_comment",
content: { raw: body, markup: "markdown" },
},
},
);

if (!response.ok || !data) {
throw new PullRequestError(
`Failed to post comment on pull request #${pullRequestId}: HTTP ${response.status}.`,
response.status,
);
}

const raw = data as Record<string, any>;
return {
id: Number(raw.id ?? 0),
url: String(raw.links?.html?.href ?? ""),
};
}

function toPullRequestDetail(pr: RawPullRequest): PullRequestDetail {
const base = toPullRequest(pr);
const raw = pr as Record<string, any>;
Expand Down
69 changes: 69 additions & 0 deletions src/commands/pullrequest/comment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
createPullRequestComment,
PullRequestError,
} from "../../backend/pullrequests/index.ts";
import { loadConfigOrExit } from "../../shared/config/index.ts";
import {
BodyInputError,
resolveBodyInput,
} from "../../shared/editor/body-input.ts";
import { EditorError } from "../../shared/editor/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 PullRequestCommentOptions = {
repository?: string;
body?: string;
bodyFile?: string;
};

export async function runPullRequestComment(
renderer: Renderer,
idArg: string | undefined,
options: PullRequestCommentOptions,
): Promise<void> {
const config = await loadConfigOrExit(renderer);

if (options.body !== undefined && options.bodyFile !== undefined) {
renderer.error("Pass either --body or --body-file, not both.");
process.exit(1);
}

try {
const ref = await resolveRepository({ override: options.repository });
const id = await resolveCurrentPullRequestId(idArg, {
renderer,
config,
ref,
commandName: "comment",
});

const body = await resolveBodyInput({
body: options.body,
bodyFile: options.bodyFile,
});

if (body.trim() === "") {
renderer.error("Comment body is empty; nothing to post.");
process.exit(1);
}

const comment = await createPullRequestComment(config, ref, id, body);
renderer.message(comment.url);
} catch (err) {
if (
err instanceof RepositoryResolutionError ||
err instanceof PullRequestError ||
err instanceof BodyInputError ||
err instanceof EditorError
) {
renderer.error(err.message);
process.exit(1);
}
throw err;
}
}
29 changes: 10 additions & 19 deletions src/commands/pullrequest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
PullRequestError,
} from "../../backend/pullrequests/index.ts";
import { loadConfigOrExit } from "../../shared/config/index.ts";
import { EditorError, openEditor } from "../../shared/editor/index.ts";
import {
BodyInputError,
resolveBodyInput,
} from "../../shared/editor/body-input.ts";
import { EditorError } from "../../shared/editor/index.ts";
import type { Renderer } from "../../shared/renderer/index.ts";
import {
defaultGitRunner,
Expand Down Expand Up @@ -51,7 +55,10 @@ export async function runPullRequestCreate(

const destination = options.base ?? (await defaultBase(renderer, cwd));

const body = await resolveBody(renderer, options);
const body = await resolveBodyInput({
body: options.body,
bodyFile: options.bodyFile,
});

const pr = await createPullRequest(config, ref, {
title: options.title,
Expand All @@ -66,6 +73,7 @@ export async function runPullRequestCreate(
if (
err instanceof RepositoryResolutionError ||
err instanceof PullRequestError ||
err instanceof BodyInputError ||
err instanceof EditorError
) {
renderer.error(err.message);
Expand Down Expand Up @@ -113,20 +121,3 @@ async function defaultBase(renderer: Renderer, cwd: string): Promise<string> {
}
return branch;
}

async function resolveBody(
renderer: Renderer,
options: PullRequestCreateOptions,
): Promise<string> {
if (options.body !== undefined) return options.body;
if (options.bodyFile !== undefined) {
const file = Bun.file(options.bodyFile);
if (!(await file.exists())) {
renderer.error(`--body-file '${options.bodyFile}' does not exist.`);
process.exit(1);
}
return await file.text();
}
// No flag: drop into the user's editor with a blank file.
return await openEditor();
}
74 changes: 74 additions & 0 deletions src/commands/pullrequest/current.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
findOpenPullRequestForBranch,
PullRequestError,
} from "../../backend/pullrequests/index.ts";
import type { Credentials } from "../../shared/bitbucket-http/index.ts";
import type { Renderer } from "../../shared/renderer/index.ts";
import { defaultGitRunner } from "../../shared/repository/index.ts";

export type CurrentPullRequestLookup = {
renderer: Renderer;
config: Credentials;
ref: { workspace: string; slug: string };
/** Command name to embed in error hints, e.g. "view", "comment". */
commandName: string;
};

/**
* Resolves the PR id for a command that accepts `[<id>]` and falls back to
* "the open PR for the current branch" when omitted. Exits the process with
* a clear error if neither can be determined.
*
* Extracted from `view.ts` so every command with this UX contract uses the
* same detection, error messages, and hints.
*/
export async function resolveCurrentPullRequestId(
idArg: string | undefined,
lookup: CurrentPullRequestLookup,
): Promise<number> {
if (idArg !== undefined) {
const parsed = parseId(idArg);
if (parsed === null) {
lookup.renderer.error(
`Invalid PR id '${idArg}'. Expected a positive integer.`,
);
process.exit(1);
}
return parsed;
}

const branch = await defaultGitRunner.getCurrentBranch(process.cwd());
if (!branch) {
lookup.renderer.error(
`Could not determine the current branch (detached HEAD or not a git repo). Pass a PR number explicitly: bb pr ${lookup.commandName} <n>.`,
);
process.exit(1);
}

try {
const summary = await findOpenPullRequestForBranch(
lookup.config,
lookup.ref,
branch,
);
if (!summary) {
lookup.renderer.error(
`No open pull request for branch '${branch}'. Pass a PR number explicitly (bb pr ${lookup.commandName} <n>) or list available PRs (bb pr list).`,
);
process.exit(1);
}
return summary.id;
} catch (err) {
if (err instanceof PullRequestError) {
lookup.renderer.error(err.message);
process.exit(1);
}
throw err;
}
}

function parseId(raw: string): number | null {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
17 changes: 17 additions & 0 deletions src/commands/pullrequest/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Command } from "commander";
import { withRenderer } from "../../shared/renderer/commander.ts";
import { runPullRequestComment } from "./comment.ts";
import { runPullRequestCreate } from "./create.ts";
import { runPullRequestList } from "./list.ts";
import { runPullRequestView } from "./view.ts";
Expand Down Expand Up @@ -55,4 +56,20 @@ export function registerPullRequestCommands(program: Command): void {
)
.option("--draft", "Create as a draft pull request")
.action(withRenderer(runPullRequestCreate));

pr.command("comment")
.description(
"Post a top-level comment on a pull request (defaults to the PR for the current branch)",
)
.argument("[id]", "Pull request number")
.option(
"-R, --repository <workspace/repo>",
"Override repository detection",
)
.option("-b, --body <body>", "Comment body (markdown)")
.option(
"-F, --body-file <path>",
"Read comment body from a file ('-' for stdin)",
)
.action(withRenderer(runPullRequestComment));
}
Loading
Loading