From ad6fce3e597e40dd881c294ccd6fe0ddf1c4a31a Mon Sep 17 00:00:00 2001 From: Nicolas Medda Date: Wed, 15 Apr 2026 13:26:33 +0000 Subject: [PATCH] BBC2-14 add bb pr diff Prints the unified diff of a PR to stdout. Defaults to the open PR for the current branch when no id is given. JSON mode wraps as {"diff": "..."}. GET /pullrequests/{id}/diff is a 302 redirect to /repositories/{ws}/{slug}/diff/{spec} which serves text/plain, so we bypass the typed openapi-fetch client and hit fetch() directly with Accept: text/plain and redirect: follow. To let the command branch between 'write bytes verbatim' and 'emit {"diff": ...}', the Renderer interface gains a `json: boolean` flag. Cheap addition; removes the awkwardness of abusing renderer.detail() for raw text output. --- src/backend/pullrequests/index.test.ts | 40 +++++++++++++++++++ src/backend/pullrequests/index.ts | 37 ++++++++++++++++++ src/commands/pullrequest/diff.ts | 53 ++++++++++++++++++++++++++ src/commands/pullrequest/index.ts | 12 ++++++ src/shared/renderer/json.ts | 2 + src/shared/renderer/text.ts | 2 + src/shared/renderer/types.ts | 3 ++ 7 files changed, 149 insertions(+) create mode 100644 src/commands/pullrequest/diff.ts diff --git a/src/backend/pullrequests/index.test.ts b/src/backend/pullrequests/index.test.ts index 6d01244..e30b5f4 100644 --- a/src/backend/pullrequests/index.test.ts +++ b/src/backend/pullrequests/index.test.ts @@ -7,6 +7,7 @@ import { createPullRequestComment, findOpenPullRequestForBranch, getPullRequest, + getPullRequestDiff, listEffectiveDefaultReviewers, listPullRequests, type PullRequest, @@ -919,3 +920,42 @@ describe("review action endpoints (approve / unapprove / request-changes / unreq expect((err as PullRequestError).status).toBe(404); }); }); + +describe("getPullRequestDiff", () => { + const DIFF_PATH = (id: number) => + `${BITBUCKET_BASE}/repositories/ws/repo/pullrequests/${id}/diff`; + + test("returns the raw text/plain body the server sent back", async () => { + const diffText = + "diff --git a/foo.ts b/foo.ts\n--- a/foo.ts\n+++ b/foo.ts\n@@ -1 +1 @@\n-old\n+new\n"; + server.use( + http.get(DIFF_PATH(42), () => + HttpResponse.text(diffText, { + headers: { "content-type": "text/plain" }, + }), + ), + ); + + const out = await getPullRequestDiff(creds, ref, 42); + expect(out).toBe(diffText); + }); + + test("empty PR diffs come back as empty string", async () => { + server.use(http.get(DIFF_PATH(42), () => HttpResponse.text(""))); + + const out = await getPullRequestDiff(creds, ref, 42); + expect(out).toBe(""); + }); + + test("throws PullRequestError on 404", async () => { + server.use( + http.get(DIFF_PATH(99), () => + HttpResponse.text("not found", { status: 404 }), + ), + ); + + const err = await getPullRequestDiff(creds, ref, 99).catch((e) => e); + expect(err).toBeInstanceOf(PullRequestError); + expect((err as PullRequestError).status).toBe(404); + }); +}); diff --git a/src/backend/pullrequests/index.ts b/src/backend/pullrequests/index.ts index 535401e..0d7236f 100644 --- a/src/backend/pullrequests/index.ts +++ b/src/backend/pullrequests/index.ts @@ -1,5 +1,7 @@ import type { components } from "../../shared/bitbucket-http/generated"; import { + BASE_URL, + basicAuthHeader, type Credentials, createBitbucketClient, } from "../../shared/bitbucket-http/index.ts"; @@ -382,6 +384,41 @@ export async function listEffectiveDefaultReviewers( return uuids; } +/** + * Returns the raw unified-diff text for a PR. Bypasses the typed openapi-fetch + * client because `GET /pullrequests/{id}/diff` returns a 302 redirect to + * `/repositories/{ws}/{slug}/diff/{spec}` which serves `text/plain`, not JSON + * (see docs/bb-notes.md → PR diff). Content is returned as a string — the + * API's actual encoding is "whatever the files use" and we pass it through + * verbatim. + */ +export async function getPullRequestDiff( + credentials: Credentials, + ref: { workspace: string; slug: string }, + pullRequestId: number, +): Promise { + const url = `${BASE_URL}/repositories/${encodeURIComponent(ref.workspace)}/${encodeURIComponent(ref.slug)}/pullrequests/${pullRequestId}/diff`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: basicAuthHeader(credentials), + // Don't request application/json — the target serves text/plain. + Accept: "text/plain", + }, + // `fetch` follows the 302 automatically. + redirect: "follow", + }); + + if (!response.ok) { + throw new PullRequestError( + `Failed to fetch diff for pull request #${pullRequestId}: HTTP ${response.status}.`, + response.status, + ); + } + + return await response.text(); +} + export type PullRequestCommentResult = { id: number; url: string; diff --git a/src/commands/pullrequest/diff.ts b/src/commands/pullrequest/diff.ts new file mode 100644 index 0000000..6ffacf6 --- /dev/null +++ b/src/commands/pullrequest/diff.ts @@ -0,0 +1,53 @@ +import { + getPullRequestDiff, + PullRequestError, +} 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 PullRequestDiffOptions = { + repository?: string; +}; + +export async function runPullRequestDiff( + renderer: Renderer, + idArg: string | undefined, + options: PullRequestDiffOptions, +): Promise { + const config = await loadConfigOrExit(renderer); + + try { + const ref = await resolveRepository({ override: options.repository }); + const id = await resolveCurrentPullRequestId(idArg, { + renderer, + config, + ref, + commandName: "diff", + }); + + const diff = await getPullRequestDiff(config, ref, id); + + if (renderer.json) { + renderer.detail({ diff }, []); + return; + } + // Text mode: write the diff verbatim so pipes to `less` / `delta` + // work. No trailing newline added — the diff itself already ends + // with one when non-empty. + process.stdout.write(diff); + } 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 02ec253..eb4197f 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 { runPullRequestDiff } from "./diff.ts"; import { runPullRequestList } from "./list.ts"; import { runPullRequestReview } from "./review.ts"; import { runPullRequestView } from "./view.ts"; @@ -74,6 +75,17 @@ export function registerPullRequestCommands(program: Command): void { ) .action(withRenderer(runPullRequestComment)); + pr.command("diff") + .description( + "Print the unified diff of a pull request (defaults to the PR for the current branch)", + ) + .argument("[id]", "Pull request number") + .option( + "-R, --repository ", + "Override repository detection", + ) + .action(withRenderer(runPullRequestDiff)); + pr.command("review") .description( "Submit a review on a pull request (defaults to the PR for the current branch)", diff --git a/src/shared/renderer/json.ts b/src/shared/renderer/json.ts index 53002ac..557c884 100644 --- a/src/shared/renderer/json.ts +++ b/src/shared/renderer/json.ts @@ -19,6 +19,8 @@ export function createJsonRenderer( streams: Streams = defaultStreams, ): Renderer { return { + json: true, + message() { // Info output is noise in a JSON pipeline — swallow it. }, diff --git a/src/shared/renderer/text.ts b/src/shared/renderer/text.ts index c879365..7f72e07 100644 --- a/src/shared/renderer/text.ts +++ b/src/shared/renderer/text.ts @@ -76,6 +76,8 @@ export function createTextRenderer( streams: Streams = defaultStreams, ): Renderer { return { + json: false, + message(text) { streams.stdout(`${text}\n`); }, diff --git a/src/shared/renderer/types.ts b/src/shared/renderer/types.ts index d46666c..4be6914 100644 --- a/src/shared/renderer/types.ts +++ b/src/shared/renderer/types.ts @@ -20,6 +20,9 @@ export type Field = { }; export interface Renderer { + /** True when output is being produced in JSON mode (vs text). Lets commands + * whose natural output is a raw blob (e.g. a diff) branch explicitly. */ + readonly json: boolean; /** Info output. Stdout in text mode, suppressed in JSON mode. */ message(text: string): void; /** Errors. Always stderr, plain text regardless of mode. */