Skip to content

Commit 036b888

Browse files
authored
BBC2-10 add bb pr create to open a PR from the current branch (#10)
Flags: `--title` (required), `--body`, `--body-file`, `--base`, `-R/--repository`. Body source: `--body` > `--body-file` > drop into `$VISUAL || $EDITOR` with a blank tempfile. Destination defaults to whatever `refs/remotes/origin/HEAD` points at locally; if unset, errors with a hint to `git remote set-head origin --auto` or `--base <branch>` so teams with a `develop`-in-the-middle flow can flip the default once and forget it. Pre-flight refuses to POST when the branch isn't in sync with the remote. Uses `git ls-remote` (network-authoritative) rather than the locally-cached tracking ref, so stale state can't mask unpushed commits. Two distinct errors: "not on origin" vs. "local ahead of remote" — both name the branch and suggest the fix. Reviewers deliberately omitted on this first cut: the workspace auto-assigns reviewers server-side based on code-owner settings, so there's nothing useful to send. When we need explicit reviewers, we'll accept nicknames and resolve to uuids (Bitbucket's reviewer objects are keyed by uuid, not nickname). Adds three `GitRunner` methods with integration coverage against a real local bare remote: `getSha`, `getRemoteBranchSha`, `getDefaultBranchFromRemote`. Adds a shared `openEditor` helper (`$VISUAL || $EDITOR`, whitespace-split for `EDITOR="code --wait"` style configs, tempfile lifecycle, non-zero exit surfaces as `EditorError`). Adds `createPullRequest` backend via the typed openapi-fetch client.
1 parent fc31d04 commit 036b888

9 files changed

Lines changed: 535 additions & 0 deletions

File tree

src/backend/pullrequests/index.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { test, expect, describe } from "bun:test";
22
import { http, HttpResponse } from "msw";
33
import {
4+
createPullRequest,
45
findOpenPullRequestForBranch,
56
getPullRequest,
67
listPullRequests,
@@ -460,3 +461,56 @@ describe("findOpenPullRequestForBranch", () => {
460461
);
461462
});
462463
});
464+
465+
describe("createPullRequest", () => {
466+
test("POSTs title, description, and source/destination branches", async () => {
467+
let seenBody: Record<string, any> | null = null;
468+
server.use(
469+
http.post(PR_LIST_PATH, async ({ request }) => {
470+
seenBody = (await request.json()) as Record<string, any>;
471+
return HttpResponse.json(
472+
makePrDetail({ id: 100, title: "Add login" }),
473+
{ status: 201 },
474+
);
475+
}),
476+
);
477+
478+
const result = await createPullRequest(creds, ref, {
479+
title: "Add login",
480+
description: "Wires up auth middleware.",
481+
sourceBranch: "feature/login",
482+
destinationBranch: "main",
483+
});
484+
485+
expect(seenBody!).toEqual({
486+
type: "pullrequest",
487+
title: "Add login",
488+
description: "Wires up auth middleware.",
489+
source: { branch: { name: "feature/login" } },
490+
destination: { branch: { name: "main" } },
491+
});
492+
expect(result.id).toBe(100);
493+
expect(result.title).toBe("Add login");
494+
});
495+
496+
test("throws PullRequestError on 400 (validation failure)", async () => {
497+
server.use(
498+
http.post(PR_LIST_PATH, () =>
499+
HttpResponse.json(
500+
{ type: "error", error: { message: "Invalid source branch" } },
501+
{ status: 400 },
502+
),
503+
),
504+
);
505+
506+
const err = await createPullRequest(creds, ref, {
507+
title: "x",
508+
description: "",
509+
sourceBranch: "nope",
510+
destinationBranch: "main",
511+
}).catch((e) => e);
512+
513+
expect(err).toBeInstanceOf(PullRequestError);
514+
expect((err as PullRequestError).status).toBe(400);
515+
});
516+
});

src/backend/pullrequests/index.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,51 @@ function escapeBbql(value: string): string {
187187
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
188188
}
189189

190+
export type CreatePullRequestInput = {
191+
title: string;
192+
description: string;
193+
sourceBranch: string;
194+
destinationBranch: string;
195+
};
196+
197+
/**
198+
* POSTs a new pull request to Bitbucket. The source repo is implied by the
199+
* path (we only create PRs in the current repo, never from forks at this
200+
* stage). Reviewers are omitted: our Bitbucket workspaces auto-assign
201+
* reviewers based on code-owner settings.
202+
*/
203+
export async function createPullRequest(
204+
credentials: Credentials,
205+
ref: { workspace: string; slug: string },
206+
input: CreatePullRequestInput,
207+
): Promise<PullRequestDetail> {
208+
const client = createBitbucketClient(credentials);
209+
const { data, response } = await client.POST(
210+
"/repositories/{workspace}/{repo_slug}/pullrequests",
211+
{
212+
params: {
213+
path: { workspace: ref.workspace, repo_slug: ref.slug },
214+
},
215+
body: {
216+
type: "pullrequest",
217+
title: input.title,
218+
description: input.description,
219+
source: { branch: { name: input.sourceBranch } },
220+
destination: { branch: { name: input.destinationBranch } },
221+
},
222+
},
223+
);
224+
225+
if (!response.ok || !data) {
226+
throw new PullRequestError(
227+
`Failed to create pull request: HTTP ${response.status}.`,
228+
response.status,
229+
);
230+
}
231+
232+
return toPullRequestDetail(data as RawPullRequest);
233+
}
234+
190235
/**
191236
* Fetches a single pull request by id. Single typed call — the overlay
192237
* (BBC2-38) gives us typed path params and response shape, so we stay on

src/commands/pullrequest/create.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
createPullRequest,
3+
PullRequestError,
4+
} from "../../backend/pullrequests/index.ts";
5+
import { loadConfigOrExit } from "../../shared/config/index.ts";
6+
import { openEditor, EditorError } from "../../shared/editor/index.ts";
7+
import type { Renderer } from "../../shared/renderer/index.ts";
8+
import {
9+
resolveRepository,
10+
RepositoryResolutionError,
11+
defaultGitRunner,
12+
} from "../../shared/repository/index.ts";
13+
14+
export type PullRequestCreateOptions = {
15+
repository?: string;
16+
title?: string;
17+
body?: string;
18+
bodyFile?: string;
19+
base?: string;
20+
};
21+
22+
export async function runPullRequestCreate(
23+
renderer: Renderer,
24+
options: PullRequestCreateOptions,
25+
): Promise<void> {
26+
const config = await loadConfigOrExit(renderer);
27+
28+
if (!options.title) {
29+
renderer.error("--title is required.");
30+
process.exit(1);
31+
}
32+
if (options.body !== undefined && options.bodyFile !== undefined) {
33+
renderer.error("Pass either --body or --body-file, not both.");
34+
process.exit(1);
35+
}
36+
37+
try {
38+
const ref = await resolveRepository({ override: options.repository });
39+
const cwd = process.cwd();
40+
41+
const branch = await defaultGitRunner.getCurrentBranch(cwd);
42+
if (!branch) {
43+
renderer.error(
44+
"Could not determine the current branch (detached HEAD or not a git repo).",
45+
);
46+
process.exit(1);
47+
}
48+
49+
await assertBranchPushedAndInSync(renderer, cwd, branch);
50+
51+
const destination = options.base ?? await defaultBase(renderer, cwd);
52+
53+
const body = await resolveBody(renderer, options);
54+
55+
const pr = await createPullRequest(config, ref, {
56+
title: options.title,
57+
description: body,
58+
sourceBranch: branch,
59+
destinationBranch: destination,
60+
});
61+
62+
renderer.message(pr.url);
63+
} catch (err) {
64+
if (
65+
err instanceof RepositoryResolutionError ||
66+
err instanceof PullRequestError ||
67+
err instanceof EditorError
68+
) {
69+
renderer.error(err.message);
70+
process.exit(1);
71+
}
72+
throw err;
73+
}
74+
}
75+
76+
async function assertBranchPushedAndInSync(
77+
renderer: Renderer,
78+
cwd: string,
79+
branch: string,
80+
): Promise<void> {
81+
const remoteSha = await defaultGitRunner.getRemoteBranchSha(
82+
cwd,
83+
"origin",
84+
branch,
85+
);
86+
if (!remoteSha) {
87+
renderer.error(
88+
`Branch '${branch}' is not on origin. Push it first: git push -u origin ${branch}`,
89+
);
90+
process.exit(1);
91+
}
92+
const localSha = await defaultGitRunner.getSha(cwd, "HEAD");
93+
if (localSha && localSha !== remoteSha) {
94+
renderer.error(
95+
`Branch '${branch}' has unpushed commits (local ${localSha.slice(0, 7)} vs remote ${remoteSha.slice(0, 7)}). Push them first.`,
96+
);
97+
process.exit(1);
98+
}
99+
}
100+
101+
async function defaultBase(renderer: Renderer, cwd: string): Promise<string> {
102+
const branch = await defaultGitRunner.getDefaultBranchFromRemote(cwd, "origin");
103+
if (!branch) {
104+
renderer.error(
105+
"Could not determine the default branch. Run 'git remote set-head origin --auto' or pass --base explicitly.",
106+
);
107+
process.exit(1);
108+
}
109+
return branch;
110+
}
111+
112+
async function resolveBody(
113+
renderer: Renderer,
114+
options: PullRequestCreateOptions,
115+
): Promise<string> {
116+
if (options.body !== undefined) return options.body;
117+
if (options.bodyFile !== undefined) {
118+
const file = Bun.file(options.bodyFile);
119+
if (!(await file.exists())) {
120+
renderer.error(`--body-file '${options.bodyFile}' does not exist.`);
121+
process.exit(1);
122+
}
123+
return await file.text();
124+
}
125+
// No flag: drop into the user's editor with a blank file.
126+
return await openEditor();
127+
}

src/commands/pullrequest/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Command } from "commander";
22
import { withRenderer } from "../../shared/renderer/commander.ts";
3+
import { runPullRequestCreate } from "./create.ts";
34
import { runPullRequestList } from "./list.ts";
45
import { runPullRequestView } from "./view.ts";
56

@@ -41,4 +42,23 @@ export function registerPullRequestCommands(program: Command): void {
4142
"Override repository detection",
4243
)
4344
.action(withRenderer(runPullRequestView));
45+
46+
pr
47+
.command("create")
48+
.description("Open a pull request from the current branch")
49+
.option(
50+
"-R, --repository <workspace/repo>",
51+
"Override repository detection",
52+
)
53+
.option("-t, --title <title>", "Pull request title (required)")
54+
.option("-b, --body <body>", "Pull request description")
55+
.option(
56+
"-F, --body-file <path>",
57+
"Read description from a file ('-' for stdin support deferred)",
58+
)
59+
.option(
60+
"--base <branch>",
61+
"Destination branch (defaults to the remote's default branch)",
62+
)
63+
.action(withRenderer(runPullRequestCreate));
4464
}

src/shared/editor/index.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test, expect, describe, afterEach, beforeAll, afterAll } from "bun:test";
2+
import { $ } from "bun";
3+
import { mkdtemp, rm } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { openEditor, EditorError } from "./index.ts";
7+
8+
/**
9+
* Integration tests. Real editors are interactive; we point `$EDITOR` at a
10+
* tiny shell script that mutates the tempfile and exits.
11+
*/
12+
13+
let scriptDir: string;
14+
let appendScript: string;
15+
let visualScript: string;
16+
let failScript: string;
17+
18+
beforeAll(async () => {
19+
scriptDir = await mkdtemp(join(tmpdir(), "bbcli-editor-test-"));
20+
21+
appendScript = join(scriptDir, "append.sh");
22+
await Bun.write(appendScript, '#!/bin/sh\necho "hello from editor" >> "$1"\n');
23+
await $`chmod +x ${appendScript}`.quiet();
24+
25+
visualScript = join(scriptDir, "visual.sh");
26+
await Bun.write(visualScript, '#!/bin/sh\necho "VISUAL_ran" >> "$1"\n');
27+
await $`chmod +x ${visualScript}`.quiet();
28+
29+
failScript = join(scriptDir, "fail.sh");
30+
await Bun.write(failScript, "#!/bin/sh\nexit 2\n");
31+
await $`chmod +x ${failScript}`.quiet();
32+
});
33+
34+
afterAll(async () => {
35+
if (scriptDir) await rm(scriptDir, { recursive: true, force: true });
36+
});
37+
38+
const originalEditor = process.env["EDITOR"];
39+
const originalVisual = process.env["VISUAL"];
40+
41+
afterEach(() => {
42+
if (originalEditor === undefined) delete process.env["EDITOR"];
43+
else process.env["EDITOR"] = originalEditor;
44+
if (originalVisual === undefined) delete process.env["VISUAL"];
45+
else process.env["VISUAL"] = originalVisual;
46+
});
47+
48+
describe("openEditor", () => {
49+
test("invokes $EDITOR and returns the file contents after exit", async () => {
50+
process.env["EDITOR"] = appendScript;
51+
delete process.env["VISUAL"];
52+
53+
const result = await openEditor("seed line\n");
54+
expect(result).toBe("seed line\nhello from editor\n");
55+
});
56+
57+
test("prefers $VISUAL over $EDITOR", async () => {
58+
process.env["EDITOR"] = appendScript;
59+
process.env["VISUAL"] = visualScript;
60+
61+
const result = await openEditor();
62+
expect(result).toContain("VISUAL_ran");
63+
expect(result).not.toContain("hello from editor");
64+
});
65+
66+
test("throws EditorError when neither env var is set", async () => {
67+
delete process.env["EDITOR"];
68+
delete process.env["VISUAL"];
69+
70+
const err = await openEditor().catch((e) => e);
71+
expect(err).toBeInstanceOf(EditorError);
72+
});
73+
74+
test("throws EditorError when the editor exits non-zero", async () => {
75+
process.env["EDITOR"] = failScript;
76+
delete process.env["VISUAL"];
77+
78+
const err = await openEditor().catch((e) => e);
79+
expect(err).toBeInstanceOf(EditorError);
80+
expect((err as Error).message).toContain("code 2");
81+
});
82+
});

0 commit comments

Comments
 (0)