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
40 changes: 40 additions & 0 deletions src/backend/pullrequests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createPullRequestComment,
findOpenPullRequestForBranch,
getPullRequest,
getPullRequestDiff,
listEffectiveDefaultReviewers,
listPullRequests,
type PullRequest,
Expand Down Expand Up @@ -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);
});
});
37 changes: 37 additions & 0 deletions src/backend/pullrequests/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string> {
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;
Expand Down
53 changes: 53 additions & 0 deletions src/commands/pullrequest/diff.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
}
12 changes: 12 additions & 0 deletions src/commands/pullrequest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <workspace/repo>",
"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)",
Expand Down
2 changes: 2 additions & 0 deletions src/shared/renderer/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
},
Expand Down
2 changes: 2 additions & 0 deletions src/shared/renderer/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function createTextRenderer(
streams: Streams = defaultStreams,
): Renderer {
return {
json: false,

message(text) {
streams.stdout(`${text}\n`);
},
Expand Down
3 changes: 3 additions & 0 deletions src/shared/renderer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export type Field<T> = {
};

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. */
Expand Down
Loading