feat(source-control): add Bitbucket & Azure Devops providers#2473
feat(source-control): add Bitbucket & Azure Devops providers#2473juliusmarminge wants to merge 14 commits intomainfrom
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Duplicate owner-branch parsing regex across two files
- Extracted a shared
parseOwnerBranchutility inparseOwnerBranch.tsand updated bothBitbucketCli.tsandBitbucketSourceControlProvider.tsto use it, eliminating the duplicated regex.
- Extracted a shared
- ✅ Fixed: Inconsistent Layer.provideMerge leaks BitbucketCli into output
- Changed
Layer.provideMerge(BitbucketCli.layer)toLayer.provide(BitbucketCli.layer)inserver.tsto match theGitHubCli.layerwiring and avoid leaking the service into the composed layer's output.
- Changed
Or push these changes by commenting:
@cursor push 7462353156
Preview (7462353156)
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -166,7 +166,7 @@
Layer.provideMerge(
SourceControlProviderRegistry.layer.pipe(
Layer.provide(GitHubCli.layer),
- Layer.provideMerge(BitbucketCli.layer),
+ Layer.provide(BitbucketCli.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
),
),
diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts
--- a/apps/server/src/sourceControl/BitbucketCli.ts
+++ b/apps/server/src/sourceControl/BitbucketCli.ts
@@ -8,6 +8,7 @@
formatBitbucketJsonDecodeError,
type NormalizedBitbucketPullRequestRecord,
} from "./bitbucketPullRequests.ts";
+import { parseOwnerBranch } from "./parseOwnerBranch.ts";
import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
const DEFAULT_TIMEOUT_MS = 30_000;
@@ -146,9 +147,7 @@
}
function normalizeSourceBranch(headSelector: string): string {
- const trimmed = headSelector.trim();
- const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed);
- return ownerSelector?.[2]?.trim() ?? trimmed;
+ return parseOwnerBranch(headSelector)?.refName ?? headSelector.trim();
}
function sourceBranch(input: {
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
--- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
@@ -2,6 +2,7 @@
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts";
+import { parseOwnerBranch } from "./parseOwnerBranch.ts";
import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
@@ -44,10 +45,8 @@
return input.source;
}
- const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
- const owner = match?.[1]?.trim();
- const refName = match?.[2]?.trim();
- return owner && refName ? { owner, refName } : undefined;
+ const parsed = parseOwnerBranch(input.headSelector);
+ return parsed ? { owner: parsed.owner, refName: parsed.refName } : undefined;
}
export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () {
diff --git a/apps/server/src/sourceControl/parseOwnerBranch.ts b/apps/server/src/sourceControl/parseOwnerBranch.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/parseOwnerBranch.ts
@@ -1,0 +1,13 @@
+export interface OwnerBranch {
+ readonly owner: string;
+ readonly refName: string;
+}
+
+const OWNER_BRANCH_RE = /^([^:/\s]+):(.+)$/u;
+
+export function parseOwnerBranch(headSelector: string): OwnerBranch | null {
+ const match = OWNER_BRANCH_RE.exec(headSelector.trim());
+ const owner = match?.[1]?.trim();
+ const refName = match?.[2]?.trim();
+ return owner && refName ? { owner, refName } : null;
+}You can send follow-ups to the cloud agent here.
ApprovabilityVerdict: Needs human review This PR introduces two complete new source control provider integrations (Bitbucket and Azure DevOps), including new API clients, authentication flows, and server-side wiring. New feature integrations of this scope warrant human review. You can customize Macroscope's approvability policy. Learn more. |
485eb2c to
7a57722
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Asymmetric state mapping loses SUPERSEDED pull requests
- Refactored toBitbucketState into toBitbucketStateArgs so that filtering by 'closed' now emits both '--state declined' and '--state superseded' CLI flags, matching the normalization logic that maps both DECLINED and SUPERSEDED to 'closed'.
Or push these changes by commenting:
@cursor push 83b87161fd
Preview (83b87161fd)
diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts
--- a/apps/server/src/sourceControl/BitbucketCli.ts
+++ b/apps/server/src/sourceControl/BitbucketCli.ts
@@ -158,16 +158,16 @@
return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
}
-function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string {
+function toBitbucketStateArgs(state: "open" | "closed" | "merged" | "all"): readonly string[] {
switch (state) {
case "open":
- return "open";
+ return ["--state", "open"];
case "closed":
- return "declined";
+ return ["--state", "declined", "--state", "superseded"];
case "merged":
- return "merged";
+ return ["--state", "merged"];
case "all":
- return "all";
+ return ["--state", "all"];
}
}
@@ -254,8 +254,7 @@
"list",
"--head",
sourceBranch(input),
- "--state",
- toBitbucketState(input.state),
+ ...toBitbucketStateArgs(input.state),
"--limit",
String(input.limit ?? 20),
"--json",You can send follow-ups to the cloud agent here.
08c6449 to
d1441c4
Compare
7aa00ff to
f4180a4
Compare
47992a2 to
5b38c88
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: CLI state values likely need uppercase for Bitbucket
- Changed toBitbucketState to return uppercase values (OPEN, DECLINED, MERGED, ALL) matching the Bitbucket CLI's expected format, and updated the corresponding test assertion.
Or push these changes by commenting:
@cursor push fc7f3a4e67
Preview (fc7f3a4e67)
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -25,6 +25,7 @@
import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
+import * as BitbucketCli from "./sourceControl/BitbucketCli.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
@@ -166,6 +167,7 @@
Layer.provideMerge(
SourceControlProviderRegistry.layer.pipe(
Layer.provide(GitHubCli.layer),
+ Layer.provideMerge(BitbucketCli.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
),
),
diff --git a/apps/server/src/sourceControl/BitbucketCli.test.ts b/apps/server/src/sourceControl/BitbucketCli.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketCli.test.ts
@@ -1,0 +1,241 @@
+import { assert, it } from "@effect/vitest";
+import { DateTime, Effect, Layer, Option } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { afterEach, describe, vi } from "vitest";
+import type { VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import * as BitbucketCli from "./BitbucketCli.ts";
+
+const processOutput = (stdout: string): VcsProcessOutput => ({
+ exitCode: ChildProcessSpawner.ExitCode(0),
+ stdout,
+ stderr: "",
+ stdoutTruncated: false,
+ stderrTruncated: false,
+});
+
+const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();
+
+const layer = BitbucketCli.layer.pipe(
+ Layer.provide(
+ Layer.mock(VcsProcess)({
+ run: mockRun,
+ }),
+ ),
+);
+
+afterEach(() => {
+ mockRun.mockReset();
+});
+
+describe("BitbucketCli.layer", () => {
+ it.effect("parses pull request view output", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(
+ Effect.succeed(
+ processOutput(
+ JSON.stringify({
+ id: 42,
+ title: "Add Bitbucket provider",
+ state: "OPEN",
+ updated_on: "2026-01-02T00:00:00.000Z",
+ links: {
+ html: {
+ href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ },
+ },
+ source: {
+ branch: { name: "feature/source-control" },
+ repository: {
+ full_name: "octocat/t3code",
+ workspace: { slug: "octocat" },
+ },
+ },
+ destination: {
+ branch: { name: "main" },
+ repository: {
+ full_name: "pingdotgg/t3code",
+ workspace: { slug: "pingdotgg" },
+ },
+ },
+ }),
+ ),
+ ),
+ );
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const result = yield* bb.getPullRequest({
+ cwd: "/repo",
+ reference: "#42",
+ });
+
+ assert.deepStrictEqual(result, {
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "octocat/t3code",
+ headRepositoryOwnerLogin: "octocat",
+ });
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: ["pr", "view", "42", "--json"],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("lists pull requests with Bitbucket state and source branch arguments", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(
+ Effect.succeed(
+ processOutput(
+ JSON.stringify({
+ values: [
+ {
+ id: 7,
+ title: "Merged work",
+ state: "MERGED",
+ links: {
+ html: {
+ href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/7",
+ },
+ },
+ source: {
+ branch: { name: "feature/merged" },
+ repository: { full_name: "pingdotgg/t3code" },
+ },
+ destination: {
+ branch: { name: "main" },
+ repository: { full_name: "pingdotgg/t3code" },
+ },
+ },
+ ],
+ }),
+ ),
+ ),
+ );
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const result = yield* bb.listPullRequests({
+ cwd: "/repo",
+ headSelector: "origin:feature/merged",
+ state: "merged",
+ limit: 10,
+ });
+
+ assert.strictEqual(result[0]?.state, "merged");
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: [
+ "pr",
+ "list",
+ "--head",
+ "feature/merged",
+ "--state",
+ "MERGED",
+ "--limit",
+ "10",
+ "--json",
+ ],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("reads repository clone URLs and default branch", () =>
+ Effect.gen(function* () {
+ const repositoryJson = JSON.stringify({
+ full_name: "pingdotgg/t3code",
+ links: {
+ html: { href: "https://bitbucket.org/pingdotgg/t3code" },
+ clone: [
+ { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" },
+ { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" },
+ ],
+ },
+ mainbranch: { name: "main" },
+ });
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson)));
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson)));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const cloneUrls = yield* bb.getRepositoryCloneUrls({
+ cwd: "/repo",
+ repository: "pingdotgg/t3code",
+ });
+ const defaultBranch = yield* bb.getDefaultBranch({ cwd: "/repo" });
+
+ assert.deepStrictEqual(cloneUrls, {
+ nameWithOwner: "pingdotgg/t3code",
+ url: "https://bitbucket.org/pingdotgg/t3code.git",
+ sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
+ });
+ assert.strictEqual(defaultBranch, "main");
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("creates pull requests using provider-neutral branch names", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ yield* bb.createPullRequest({
+ cwd: "/repo",
+ baseBranch: "main",
+ headSelector: "owner:feature/provider",
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: [
+ "pr",
+ "create",
+ "--destination",
+ "main",
+ "--source",
+ "feature/provider",
+ "--title",
+ "Provider PR",
+ "--body-file",
+ "/tmp/body.md",
+ ],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("passes --force when checking out pull requests with force enabled", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ yield* bb.checkoutPullRequest({
+ cwd: "/repo",
+ reference: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ force: true,
+ });
+
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: ["pr", "checkout", "42", "--force"],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+});
diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketCli.ts
@@ -1,0 +1,370 @@
+import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
+import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import {
+ decodeBitbucketPullRequestJson,
+ decodeBitbucketPullRequestListJson,
+ formatBitbucketJsonDecodeError,
+ type NormalizedBitbucketPullRequestRecord,
+} from "./bitbucketPullRequests.ts";
+import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export class BitbucketCliError extends Schema.TaggedErrorClass<BitbucketCliError>()(
+ "BitbucketCliError",
+ {
+ operation: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {
+ override get message(): string {
+ return `Bitbucket CLI failed in ${this.operation}: ${this.detail}`;
+ }
+}
+
+export interface BitbucketRepositoryCloneUrls {
+ readonly nameWithOwner: string;
+ readonly url: string;
+ readonly sshUrl: string;
+}
+
+export interface BitbucketCliShape {
+ readonly execute: (input: {
+ readonly cwd: string;
+ readonly args: ReadonlyArray<string>;
+ readonly timeoutMs?: number;
+ }) => Effect.Effect<VcsProcessOutput, BitbucketCliError>;
+
+ readonly listPullRequests: (input: {
+ readonly cwd: string;
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+ readonly state: "open" | "closed" | "merged" | "all";
+ readonly limit?: number;
+ }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketCliError>;
+
+ readonly getPullRequest: (input: {
+ readonly cwd: string;
+ readonly reference: string;
+ }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketCliError>;
+
+ readonly getRepositoryCloneUrls: (input: {
+ readonly cwd: string;
+ readonly repository: string;
+ }) => Effect.Effect<BitbucketRepositoryCloneUrls, BitbucketCliError>;
+
+ readonly createPullRequest: (input: {
+ readonly cwd: string;
+ readonly baseBranch: string;
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+ readonly target?: SourceControlRefSelector;
+ readonly title: string;
+ readonly bodyFile: string;
+ }) => Effect.Effect<void, BitbucketCliError>;
+
+ readonly getDefaultBranch: (input: {
+ readonly cwd: string;
+ }) => Effect.Effect<string | null, BitbucketCliError>;
+
+ readonly checkoutPullRequest: (input: {
+ readonly cwd: string;
+ readonly reference: string;
+ readonly force?: boolean;
+ }) => Effect.Effect<void, BitbucketCliError>;
+}
+
+export class BitbucketCli extends Context.Service<BitbucketCli, BitbucketCliShape>()(
+ "t3/source-control/BitbucketCli",
+) {}
+
+function errorText(error: VcsError | unknown): string {
+ if (typeof error === "object" && error !== null) {
+ const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : "";
+ const detail = "detail" in error && typeof error.detail === "string" ? error.detail : "";
+ const message = "message" in error && typeof error.message === "string" ? error.message : "";
+ return [tag, detail, message].filter(Boolean).join("\n");
+ }
+
+ return String(error);
+}
+
+function normalizeBitbucketCliError(
+ operation: "execute",
+ error: VcsError | unknown,
+): BitbucketCliError {
+ const text = errorText(error);
+ const lower = text.toLowerCase();
+
+ if (lower.includes("command not found: bb") || lower.includes("enoent")) {
+ return new BitbucketCliError({
+ operation,
+ detail:
+ "Bitbucket CLI (`bb`) is required but not available on PATH. Install a gh-style Bitbucket CLI and retry.",
+ cause: error,
+ });
+ }
+
+ if (
+ lower.includes("bb auth login") ||
+ lower.includes("not logged in") ||
+ lower.includes("authentication failed") ||
+ lower.includes("unauthorized") ||
+ lower.includes("forbidden")
+ ) {
+ return new BitbucketCliError({
+ operation,
+ detail: "Bitbucket CLI is not authenticated. Run `bb auth login` and retry.",
+ cause: error,
+ });
+ }
+
+ if (lower.includes("pull request") && lower.includes("not found")) {
+ return new BitbucketCliError({
+ operation,
+ detail: "Pull request not found. Check the PR number or URL and try again.",
+ cause: error,
+ });
+ }
+
+ return new BitbucketCliError({
+ operation,
+ detail: text,
+ cause: error,
+ });
+}
+
+function normalizeChangeRequestId(reference: string): string {
+ const trimmed = reference.trim().replace(/^#/, "");
+ const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
+ trimmed,
+ );
+ return urlMatch?.[1] ?? trimmed;
+}
+
+function normalizeSourceBranch(headSelector: string): string {
+ const trimmed = headSelector.trim();
+ const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed);
+ return ownerSelector?.[2]?.trim() ?? trimmed;
+}
+
+function sourceBranch(input: {
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+}): string {
+ return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
+}
+
+function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string {
+ switch (state) {
+ case "open":
+ return "OPEN";
+ case "closed":
+ return "DECLINED";
+ case "merged":
+ return "MERGED";
+ case "all":
+ return "ALL";
+ }
+}
+
+const RawBitbucketRepositorySchema = Schema.Struct({
+ full_name: TrimmedNonEmptyString,
+ links: Schema.Struct({
+ html: Schema.optional(
+ Schema.Struct({
+ href: TrimmedNonEmptyString,
+ }),
+ ),
+ clone: Schema.optional(
+ Schema.Array(
+ Schema.Struct({
+ name: TrimmedNonEmptyString,
+ href: TrimmedNonEmptyString,
+ }),
+ ),
+ ),
+ }),
+ mainbranch: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ name: TrimmedNonEmptyString,
+ }),
+ ),
+ ),
+});
+
+function normalizeRepositoryCloneUrls(
+ raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>,
+): BitbucketRepositoryCloneUrls {
+ const httpClone =
+ raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
+ raw.links.html?.href;
+ const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
+
+ return {
+ nameWithOwner: raw.full_name,
+ url: httpClone ?? raw.links.html?.href ?? raw.full_name,
+ sshUrl: sshClone ?? httpClone ?? raw.full_name,
+ };
+}
+
+function decodeBitbucketJson<S extends Schema.Top>(
+ raw: string,
+ schema: S,
+ operation: "getRepositoryCloneUrls" | "getDefaultBranch",
+ invalidDetail: string,
+): Effect.Effect<S["Type"], BitbucketCliError, S["DecodingServices"]> {
+ return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
+ Effect.mapError(
+ (error) =>
+ new BitbucketCliError({
+ operation,
+ detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
+ cause: error,
+ }),
+ ),
+ );
+}
+
+export const make = Effect.fn("makeBitbucketCli")(function* () {
+ const process = yield* VcsProcess;
+
+ const execute: BitbucketCliShape["execute"] = (input) =>
+ process
+ .run({
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: input.args,
+ cwd: input.cwd,
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+ })
+ .pipe(Effect.mapError((error) => normalizeBitbucketCliError("execute", error)));
+
+ return BitbucketCli.of({
+ execute,
+ listPullRequests: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "list",
+ "--head",
+ sourceBranch(input),
+ "--state",
+ toBitbucketState(input.state),
+ "--limit",
+ String(input.limit ?? 20),
+ "--json",
+ ],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ raw.length === 0
+ ? Effect.succeed([])
+ : Effect.sync(() => decodeBitbucketPullRequestListJson(raw)).pipe(
+ Effect.flatMap((decoded) => {
+ if (!Result.isSuccess(decoded)) {
+ return Effect.fail(
+ new BitbucketCliError({
+ operation: "listPullRequests",
+ detail: `Bitbucket CLI returned invalid PR list JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`,
+ cause: decoded.failure,
+ }),
+ );
+ }
+
+ return Effect.succeed(decoded.success);
+ }),
+ ),
+ ),
+ ),
+ getPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["pr", "view", normalizeChangeRequestId(input.reference), "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ Effect.sync(() => decodeBitbucketPullRequestJson(raw)).pipe(
+ Effect.flatMap((decoded) => {
+ if (!Result.isSuccess(decoded)) {
+ return Effect.fail(
+ new BitbucketCliError({
+ operation: "getPullRequest",
+ detail: `Bitbucket CLI returned invalid pull request JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`,
+ cause: decoded.failure,
+ }),
+ );
+ }
+
+ return Effect.succeed(decoded.success);
+ }),
+ ),
+ ),
+ ),
+ getRepositoryCloneUrls: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["repo", "view", input.repository, "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ decodeBitbucketJson(
+ raw,
+ RawBitbucketRepositorySchema,
+ "getRepositoryCloneUrls",
+ "Bitbucket CLI returned invalid repository JSON.",
+ ),
+ ),
+ Effect.map(normalizeRepositoryCloneUrls),
+ ),
+ createPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "create",
+ "--destination",
+ input.target?.refName ?? input.baseBranch,
+ "--source",
+ sourceBranch(input),
+ "--title",
+ input.title,
+ "--body-file",
+ input.bodyFile,
+ ],
+ }).pipe(Effect.asVoid),
+ getDefaultBranch: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["repo", "view", "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ decodeBitbucketJson(
+ raw,
+ RawBitbucketRepositorySchema,
+ "getDefaultBranch",
+ "Bitbucket CLI returned invalid repository JSON.",
+ ),
+ ),
+ Effect.map((repository) => repository.mainbranch?.name ?? null),
+ ),
+ checkoutPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "checkout",
+ normalizeChangeRequestId(input.reference),
+ ...(input.force ? ["--force"] : []),
+ ],
+ }).pipe(Effect.asVoid),
+ });
+});
+
+export const layer = Layer.effect(BitbucketCli, make());
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts
@@ -1,0 +1,125 @@
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer, Option } from "effect";
+
+import { BitbucketCli, type BitbucketCliShape } from "./BitbucketCli.ts";
+import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
+
+function makeProvider(bitbucket: Partial<BitbucketCliShape>) {
+ return BitbucketSourceControlProvider.make().pipe(
+ Effect.provide(Layer.mock(BitbucketCli)(bitbucket)),
+ );
+}
+
+it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () =>
+ Effect.gen(function* () {
+ const provider = yield* makeProvider({
+ getPullRequest: () =>
+ Effect.succeed({
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.none(),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "fork/t3code",
+ headRepositoryOwnerLogin: "fork",
+ }),
+ });
+
+ const changeRequest = yield* provider.getChangeRequest({
+ cwd: "/repo",
+ reference: "42",
+ });
+
+ assert.deepStrictEqual(changeRequest, {
+ provider: "bitbucket",
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.none(),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "fork/t3code",
+ headRepositoryOwnerLogin: "fork",
+ });
+ }),
+);
+
+it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
+ Effect.gen(function* () {
+ let listInput: Parameters<BitbucketCliShape["listPullRequests"]>[0] | null = null;
+ const provider = yield* makeProvider({
+ listPullRequests: (input) => {
+ listInput = input;
+ return Effect.succeed([]);
+ },
+ });
+
+ yield* provider.listChangeRequests({
+ cwd: "/repo",
+ headSelector: "feature/provider",
+ state: "all",
+ limit: 10,
+ });
+
+ assert.deepStrictEqual(listInput, {
+ cwd: "/repo",
+ headSelector: "feature/provider",
+ state: "all",
+ limit: 10,
+ });
+ }),
+);
+
+it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
+ Effect.gen(function* () {
+ let createInput: Parameters<BitbucketCliShape["createPullRequest"]>[0] | null = null;
+ const provider = yield* makeProvider({
+ createPullRequest: (input) => {
+ createInput = input;
+ return Effect.void;
+ },
+ });
+
+ yield* provider.createChangeRequest({
+ cwd: "/repo",
+ baseRefName: "main",
+ headSelector: "owner:feature/provider",
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+
+ assert.deepStrictEqual(createInput, {
+ cwd: "/repo",
+ baseBranch: "main",
+ headSelector: "owner:feature/provider",
+ source: {
+ owner: "owner",
+ refName: "feature/provider",
+ },
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+ }),
+);
+
+it.effect("uses Bitbucket CLI repository detection for default branch lookup", () =>
+ Effect.gen(function* () {
+ let cwdInput: string | null = null;
+ const provider = yield* makeProvider({
+ getDefaultBranch: (input) => {
+ cwdInput = input.cwd;
+ return Effect.succeed("main");
+ },
+ });
+
+ const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" });
+
+ assert.strictEqual(defaultBranch, "main");
+ assert.strictEqual(cwdInput, "/repo");
+ }),
+);
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
@@ -1,0 +1,111 @@
+import { Effect, Layer, Option } from "effect";
+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
+
+import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts";
+import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
+
+function providerError(operation: string, cause: BitbucketCliError): SourceControlProviderError {
+ return new SourceControlProviderError({
+ provider: "bitbucket",
+ operation,
+ detail: cause.detail,
+ cause,
+ });
+}
+
+function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
+ return {
+ provider: "bitbucket",
+ number: summary.number,
+ title: summary.title,
... diff truncated: showing 800 of 1139 linesYou can send follow-ups to the cloud agent here.
159eddf to
2db4256
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated
errorTextfunction across CLI modules- Extracted the identical
errorTextfunction from bothBitbucketCli.tsandGitHubCli.tsinto a sharederrorText.tsmodule in thesourceControldirectory, and updated both files to import from it.
- Extracted the identical
Or push these changes by commenting:
@cursor push 8f3325a618
Preview (8f3325a618)
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -25,6 +25,7 @@
import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
+import * as BitbucketCli from "./sourceControl/BitbucketCli.ts";
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
@@ -166,6 +167,7 @@
Layer.provideMerge(
SourceControlProviderRegistry.layer.pipe(
Layer.provide(GitHubCli.layer),
+ Layer.provideMerge(BitbucketCli.layer),
Layer.provideMerge(VcsDriverRegistryLayerLive),
),
),
diff --git a/apps/server/src/sourceControl/BitbucketCli.test.ts b/apps/server/src/sourceControl/BitbucketCli.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketCli.test.ts
@@ -1,0 +1,241 @@
+import { assert, it } from "@effect/vitest";
+import { DateTime, Effect, Layer, Option } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { afterEach, describe, vi } from "vitest";
+import type { VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import * as BitbucketCli from "./BitbucketCli.ts";
+
+const processOutput = (stdout: string): VcsProcessOutput => ({
+ exitCode: ChildProcessSpawner.ExitCode(0),
+ stdout,
+ stderr: "",
+ stdoutTruncated: false,
+ stderrTruncated: false,
+});
+
+const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();
+
+const layer = BitbucketCli.layer.pipe(
+ Layer.provide(
+ Layer.mock(VcsProcess)({
+ run: mockRun,
+ }),
+ ),
+);
+
+afterEach(() => {
+ mockRun.mockReset();
+});
+
+describe("BitbucketCli.layer", () => {
+ it.effect("parses pull request view output", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(
+ Effect.succeed(
+ processOutput(
+ JSON.stringify({
+ id: 42,
+ title: "Add Bitbucket provider",
+ state: "OPEN",
+ updated_on: "2026-01-02T00:00:00.000Z",
+ links: {
+ html: {
+ href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ },
+ },
+ source: {
+ branch: { name: "feature/source-control" },
+ repository: {
+ full_name: "octocat/t3code",
+ workspace: { slug: "octocat" },
+ },
+ },
+ destination: {
+ branch: { name: "main" },
+ repository: {
+ full_name: "pingdotgg/t3code",
+ workspace: { slug: "pingdotgg" },
+ },
+ },
+ }),
+ ),
+ ),
+ );
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const result = yield* bb.getPullRequest({
+ cwd: "/repo",
+ reference: "#42",
+ });
+
+ assert.deepStrictEqual(result, {
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "octocat/t3code",
+ headRepositoryOwnerLogin: "octocat",
+ });
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: ["pr", "view", "42", "--json"],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("lists pull requests with Bitbucket state and source branch arguments", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(
+ Effect.succeed(
+ processOutput(
+ JSON.stringify({
+ values: [
+ {
+ id: 7,
+ title: "Merged work",
+ state: "MERGED",
+ links: {
+ html: {
+ href: "https://bitbucket.org/pingdotgg/t3code/pull-requests/7",
+ },
+ },
+ source: {
+ branch: { name: "feature/merged" },
+ repository: { full_name: "pingdotgg/t3code" },
+ },
+ destination: {
+ branch: { name: "main" },
+ repository: { full_name: "pingdotgg/t3code" },
+ },
+ },
+ ],
+ }),
+ ),
+ ),
+ );
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const result = yield* bb.listPullRequests({
+ cwd: "/repo",
+ headSelector: "origin:feature/merged",
+ state: "merged",
+ limit: 10,
+ });
+
+ assert.strictEqual(result[0]?.state, "merged");
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: [
+ "pr",
+ "list",
+ "--head",
+ "feature/merged",
+ "--state",
+ "merged",
+ "--limit",
+ "10",
+ "--json",
+ ],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("reads repository clone URLs and default branch", () =>
+ Effect.gen(function* () {
+ const repositoryJson = JSON.stringify({
+ full_name: "pingdotgg/t3code",
+ links: {
+ html: { href: "https://bitbucket.org/pingdotgg/t3code" },
+ clone: [
+ { name: "https", href: "https://bitbucket.org/pingdotgg/t3code.git" },
+ { name: "ssh", href: "git@bitbucket.org:pingdotgg/t3code.git" },
+ ],
+ },
+ mainbranch: { name: "main" },
+ });
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson)));
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput(repositoryJson)));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ const cloneUrls = yield* bb.getRepositoryCloneUrls({
+ cwd: "/repo",
+ repository: "pingdotgg/t3code",
+ });
+ const defaultBranch = yield* bb.getDefaultBranch({ cwd: "/repo" });
+
+ assert.deepStrictEqual(cloneUrls, {
+ nameWithOwner: "pingdotgg/t3code",
+ url: "https://bitbucket.org/pingdotgg/t3code.git",
+ sshUrl: "git@bitbucket.org:pingdotgg/t3code.git",
+ });
+ assert.strictEqual(defaultBranch, "main");
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("creates pull requests using provider-neutral branch names", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ yield* bb.createPullRequest({
+ cwd: "/repo",
+ baseBranch: "main",
+ headSelector: "owner:feature/provider",
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: [
+ "pr",
+ "create",
+ "--destination",
+ "main",
+ "--source",
+ "feature/provider",
+ "--title",
+ "Provider PR",
+ "--body-file",
+ "/tmp/body.md",
+ ],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
+ it.effect("passes --force when checking out pull requests with force enabled", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
+
+ const bb = yield* BitbucketCli.BitbucketCli;
+ yield* bb.checkoutPullRequest({
+ cwd: "/repo",
+ reference: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ force: true,
+ });
+
+ assert.deepStrictEqual(mockRun.mock.calls[0]?.[0], {
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: ["pr", "checkout", "42", "--force"],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+});
diff --git a/apps/server/src/sourceControl/BitbucketCli.ts b/apps/server/src/sourceControl/BitbucketCli.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketCli.ts
@@ -1,0 +1,360 @@
+import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
+import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import { errorText } from "./errorText.ts";
+import {
+ decodeBitbucketPullRequestJson,
+ decodeBitbucketPullRequestListJson,
+ formatBitbucketJsonDecodeError,
+ type NormalizedBitbucketPullRequestRecord,
+} from "./bitbucketPullRequests.ts";
+import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export class BitbucketCliError extends Schema.TaggedErrorClass<BitbucketCliError>()(
+ "BitbucketCliError",
+ {
+ operation: Schema.String,
+ detail: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ },
+) {
+ override get message(): string {
+ return `Bitbucket CLI failed in ${this.operation}: ${this.detail}`;
+ }
+}
+
+export interface BitbucketRepositoryCloneUrls {
+ readonly nameWithOwner: string;
+ readonly url: string;
+ readonly sshUrl: string;
+}
+
+export interface BitbucketCliShape {
+ readonly execute: (input: {
+ readonly cwd: string;
+ readonly args: ReadonlyArray<string>;
+ readonly timeoutMs?: number;
+ }) => Effect.Effect<VcsProcessOutput, BitbucketCliError>;
+
+ readonly listPullRequests: (input: {
+ readonly cwd: string;
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+ readonly state: "open" | "closed" | "merged" | "all";
+ readonly limit?: number;
+ }) => Effect.Effect<ReadonlyArray<NormalizedBitbucketPullRequestRecord>, BitbucketCliError>;
+
+ readonly getPullRequest: (input: {
+ readonly cwd: string;
+ readonly reference: string;
+ }) => Effect.Effect<NormalizedBitbucketPullRequestRecord, BitbucketCliError>;
+
+ readonly getRepositoryCloneUrls: (input: {
+ readonly cwd: string;
+ readonly repository: string;
+ }) => Effect.Effect<BitbucketRepositoryCloneUrls, BitbucketCliError>;
+
+ readonly createPullRequest: (input: {
+ readonly cwd: string;
+ readonly baseBranch: string;
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+ readonly target?: SourceControlRefSelector;
+ readonly title: string;
+ readonly bodyFile: string;
+ }) => Effect.Effect<void, BitbucketCliError>;
+
+ readonly getDefaultBranch: (input: {
+ readonly cwd: string;
+ }) => Effect.Effect<string | null, BitbucketCliError>;
+
+ readonly checkoutPullRequest: (input: {
+ readonly cwd: string;
+ readonly reference: string;
+ readonly force?: boolean;
+ }) => Effect.Effect<void, BitbucketCliError>;
+}
+
+export class BitbucketCli extends Context.Service<BitbucketCli, BitbucketCliShape>()(
+ "t3/source-control/BitbucketCli",
+) {}
+
+function normalizeBitbucketCliError(
+ operation: "execute",
+ error: VcsError | unknown,
+): BitbucketCliError {
+ const text = errorText(error);
+ const lower = text.toLowerCase();
+
+ if (lower.includes("command not found: bb") || lower.includes("enoent")) {
+ return new BitbucketCliError({
+ operation,
+ detail:
+ "Bitbucket CLI (`bb`) is required but not available on PATH. Install a gh-style Bitbucket CLI and retry.",
+ cause: error,
+ });
+ }
+
+ if (
+ lower.includes("bb auth login") ||
+ lower.includes("not logged in") ||
+ lower.includes("authentication failed") ||
+ lower.includes("unauthorized") ||
+ lower.includes("forbidden")
+ ) {
+ return new BitbucketCliError({
+ operation,
+ detail: "Bitbucket CLI is not authenticated. Run `bb auth login` and retry.",
+ cause: error,
+ });
+ }
+
+ if (lower.includes("pull request") && lower.includes("not found")) {
+ return new BitbucketCliError({
+ operation,
+ detail: "Pull request not found. Check the PR number or URL and try again.",
+ cause: error,
+ });
+ }
+
+ return new BitbucketCliError({
+ operation,
+ detail: text,
+ cause: error,
+ });
+}
+
+function normalizeChangeRequestId(reference: string): string {
+ const trimmed = reference.trim().replace(/^#/, "");
+ const urlMatch = /(?:pull-requests|pullrequests|pull-request|pull|pr)\/(\d+)(?:\D.*)?$/i.exec(
+ trimmed,
+ );
+ return urlMatch?.[1] ?? trimmed;
+}
+
+function normalizeSourceBranch(headSelector: string): string {
+ const trimmed = headSelector.trim();
+ const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed);
+ return ownerSelector?.[2]?.trim() ?? trimmed;
+}
+
+function sourceBranch(input: {
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+}): string {
+ return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
+}
+
+function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string {
+ switch (state) {
+ case "open":
+ return "open";
+ case "closed":
+ return "declined";
+ case "merged":
+ return "merged";
+ case "all":
+ return "all";
+ }
+}
+
+const RawBitbucketRepositorySchema = Schema.Struct({
+ full_name: TrimmedNonEmptyString,
+ links: Schema.Struct({
+ html: Schema.optional(
+ Schema.Struct({
+ href: TrimmedNonEmptyString,
+ }),
+ ),
+ clone: Schema.optional(
+ Schema.Array(
+ Schema.Struct({
+ name: TrimmedNonEmptyString,
+ href: TrimmedNonEmptyString,
+ }),
+ ),
+ ),
+ }),
+ mainbranch: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ name: TrimmedNonEmptyString,
+ }),
+ ),
+ ),
+});
+
+function normalizeRepositoryCloneUrls(
+ raw: Schema.Schema.Type<typeof RawBitbucketRepositorySchema>,
+): BitbucketRepositoryCloneUrls {
+ const httpClone =
+ raw.links.clone?.find((entry) => entry.name.toLowerCase() === "https")?.href ??
+ raw.links.html?.href;
+ const sshClone = raw.links.clone?.find((entry) => entry.name.toLowerCase() === "ssh")?.href;
+
+ return {
+ nameWithOwner: raw.full_name,
+ url: httpClone ?? raw.links.html?.href ?? raw.full_name,
+ sshUrl: sshClone ?? httpClone ?? raw.full_name,
+ };
+}
+
+function decodeBitbucketJson<S extends Schema.Top>(
+ raw: string,
+ schema: S,
+ operation: "getRepositoryCloneUrls" | "getDefaultBranch",
+ invalidDetail: string,
+): Effect.Effect<S["Type"], BitbucketCliError, S["DecodingServices"]> {
+ return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
+ Effect.mapError(
+ (error) =>
+ new BitbucketCliError({
+ operation,
+ detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
+ cause: error,
+ }),
+ ),
+ );
+}
+
+export const make = Effect.fn("makeBitbucketCli")(function* () {
+ const process = yield* VcsProcess;
+
+ const execute: BitbucketCliShape["execute"] = (input) =>
+ process
+ .run({
+ operation: "BitbucketCli.execute",
+ command: "bb",
+ args: input.args,
+ cwd: input.cwd,
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+ })
+ .pipe(Effect.mapError((error) => normalizeBitbucketCliError("execute", error)));
+
+ return BitbucketCli.of({
+ execute,
+ listPullRequests: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "list",
+ "--head",
+ sourceBranch(input),
+ "--state",
+ toBitbucketState(input.state),
+ "--limit",
+ String(input.limit ?? 20),
+ "--json",
+ ],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ raw.length === 0
+ ? Effect.succeed([])
+ : Effect.sync(() => decodeBitbucketPullRequestListJson(raw)).pipe(
+ Effect.flatMap((decoded) => {
+ if (!Result.isSuccess(decoded)) {
+ return Effect.fail(
+ new BitbucketCliError({
+ operation: "listPullRequests",
+ detail: `Bitbucket CLI returned invalid PR list JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`,
+ cause: decoded.failure,
+ }),
+ );
+ }
+
+ return Effect.succeed(decoded.success);
+ }),
+ ),
+ ),
+ ),
+ getPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["pr", "view", normalizeChangeRequestId(input.reference), "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ Effect.sync(() => decodeBitbucketPullRequestJson(raw)).pipe(
+ Effect.flatMap((decoded) => {
+ if (!Result.isSuccess(decoded)) {
+ return Effect.fail(
+ new BitbucketCliError({
+ operation: "getPullRequest",
+ detail: `Bitbucket CLI returned invalid pull request JSON: ${formatBitbucketJsonDecodeError(decoded.failure)}`,
+ cause: decoded.failure,
+ }),
+ );
+ }
+
+ return Effect.succeed(decoded.success);
+ }),
+ ),
+ ),
+ ),
+ getRepositoryCloneUrls: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["repo", "view", input.repository, "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ decodeBitbucketJson(
+ raw,
+ RawBitbucketRepositorySchema,
+ "getRepositoryCloneUrls",
+ "Bitbucket CLI returned invalid repository JSON.",
+ ),
+ ),
+ Effect.map(normalizeRepositoryCloneUrls),
+ ),
+ createPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "create",
+ "--destination",
+ input.target?.refName ?? input.baseBranch,
+ "--source",
+ sourceBranch(input),
+ "--title",
+ input.title,
+ "--body-file",
+ input.bodyFile,
+ ],
+ }).pipe(Effect.asVoid),
+ getDefaultBranch: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: ["repo", "view", "--json"],
+ }).pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.flatMap((raw) =>
+ decodeBitbucketJson(
+ raw,
+ RawBitbucketRepositorySchema,
+ "getDefaultBranch",
+ "Bitbucket CLI returned invalid repository JSON.",
+ ),
+ ),
+ Effect.map((repository) => repository.mainbranch?.name ?? null),
+ ),
+ checkoutPullRequest: (input) =>
+ execute({
+ cwd: input.cwd,
+ args: [
+ "pr",
+ "checkout",
+ normalizeChangeRequestId(input.reference),
+ ...(input.force ? ["--force"] : []),
+ ],
+ }).pipe(Effect.asVoid),
+ });
+});
+
+export const layer = Layer.effect(BitbucketCli, make());
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts
@@ -1,0 +1,125 @@
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer, Option } from "effect";
+
+import { BitbucketCli, type BitbucketCliShape } from "./BitbucketCli.ts";
+import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
+
+function makeProvider(bitbucket: Partial<BitbucketCliShape>) {
+ return BitbucketSourceControlProvider.make().pipe(
+ Effect.provide(Layer.mock(BitbucketCli)(bitbucket)),
+ );
+}
+
+it.effect("maps Bitbucket PR summaries into provider-neutral change requests", () =>
+ Effect.gen(function* () {
+ const provider = yield* makeProvider({
+ getPullRequest: () =>
+ Effect.succeed({
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.none(),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "fork/t3code",
+ headRepositoryOwnerLogin: "fork",
+ }),
+ });
+
+ const changeRequest = yield* provider.getChangeRequest({
+ cwd: "/repo",
+ reference: "42",
+ });
+
+ assert.deepStrictEqual(changeRequest, {
+ provider: "bitbucket",
+ number: 42,
+ title: "Add Bitbucket provider",
+ url: "https://bitbucket.org/pingdotgg/t3code/pull-requests/42",
+ baseRefName: "main",
+ headRefName: "feature/source-control",
+ state: "open",
+ updatedAt: Option.none(),
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "fork/t3code",
+ headRepositoryOwnerLogin: "fork",
+ });
+ }),
+);
+
+it.effect("lists Bitbucket PRs through provider-neutral input names", () =>
+ Effect.gen(function* () {
+ let listInput: Parameters<BitbucketCliShape["listPullRequests"]>[0] | null = null;
+ const provider = yield* makeProvider({
+ listPullRequests: (input) => {
+ listInput = input;
+ return Effect.succeed([]);
+ },
+ });
+
+ yield* provider.listChangeRequests({
+ cwd: "/repo",
+ headSelector: "feature/provider",
+ state: "all",
+ limit: 10,
+ });
+
+ assert.deepStrictEqual(listInput, {
+ cwd: "/repo",
+ headSelector: "feature/provider",
+ state: "all",
+ limit: 10,
+ });
+ }),
+);
+
+it.effect("creates Bitbucket PRs through provider-neutral input names", () =>
+ Effect.gen(function* () {
+ let createInput: Parameters<BitbucketCliShape["createPullRequest"]>[0] | null = null;
+ const provider = yield* makeProvider({
+ createPullRequest: (input) => {
+ createInput = input;
+ return Effect.void;
+ },
+ });
+
+ yield* provider.createChangeRequest({
+ cwd: "/repo",
+ baseRefName: "main",
+ headSelector: "owner:feature/provider",
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+
+ assert.deepStrictEqual(createInput, {
+ cwd: "/repo",
+ baseBranch: "main",
+ headSelector: "owner:feature/provider",
+ source: {
+ owner: "owner",
+ refName: "feature/provider",
+ },
+ title: "Provider PR",
+ bodyFile: "/tmp/body.md",
+ });
+ }),
+);
+
+it.effect("uses Bitbucket CLI repository detection for default branch lookup", () =>
+ Effect.gen(function* () {
+ let cwdInput: string | null = null;
+ const provider = yield* makeProvider({
+ getDefaultBranch: (input) => {
+ cwdInput = input.cwd;
+ return Effect.succeed("main");
+ },
+ });
+
+ const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" });
+
+ assert.strictEqual(defaultBranch, "main");
+ assert.strictEqual(cwdInput, "/repo");
+ }),
+);
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
@@ -1,0 +1,111 @@
+import { Effect, Layer, Option } from "effect";
+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
+
+import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts";
+import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
+
+function providerError(operation: string, cause: BitbucketCliError): SourceControlProviderError {
+ return new SourceControlProviderError({
+ provider: "bitbucket",
+ operation,
+ detail: cause.detail,
+ cause,
+ });
+}
+
+function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeRequest {
+ return {
+ provider: "bitbucket",
+ number: summary.number,
+ title: summary.title,
+ url: summary.url,
+ baseRefName: summary.baseRefName,
+ headRefName: summary.headRefName,
+ state: summary.state,
+ updatedAt: summary.updatedAt ?? Option.none(),
+ ...(summary.isCrossRepository !== undefined
+ ? { isCrossRepository: summary.isCrossRepository }
+ : {}),
+ ...(summary.headRepositoryNameWithOwner !== undefined
+ ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
... diff truncated: showing 800 of 1177 linesYou can send follow-ups to the cloud agent here.
ed9fe5c to
03f60cb
Compare
9dd7636 to
82f7b50
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 5 total unresolved issues (including 4 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated
sourceFromInputacross two provider files- Extracted the duplicated sourceFromInput function into SourceControlProvider.ts and updated both BitbucketSourceControlProvider and GitLabSourceControlProvider to import the shared implementation.
Or push these changes by commenting:
@cursor push 9db4798680
Preview (9db4798680)
diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
--- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
+++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts
@@ -2,7 +2,7 @@
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
import { BitbucketCli, type BitbucketCliError } from "./BitbucketCli.ts";
-import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+import { SourceControlProvider, sourceFromInput } from "./SourceControlProvider.ts";
import type { NormalizedBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts";
function providerError(operation: string, cause: BitbucketCliError): SourceControlProviderError {
@@ -36,20 +36,6 @@
};
}
-function sourceFromInput(input: {
- readonly headSelector: string;
- readonly source?: SourceControlRefSelector;
-}): SourceControlRefSelector | undefined {
- if (input.source) {
- return input.source;
- }
-
- const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
- const owner = match?.[1]?.trim();
- const refName = match?.[2]?.trim();
- return owner && refName ? { owner, refName } : undefined;
-}
-
export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () {
const bitbucket = yield* BitbucketCli;
diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts
--- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts
+++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts
@@ -2,7 +2,7 @@
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
import { GitLabCli, type GitLabCliError, type GitLabMergeRequestSummary } from "./GitLabCli.ts";
-import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+import { SourceControlProvider, sourceFromInput } from "./SourceControlProvider.ts";
function providerError(operation: string, cause: GitLabCliError): SourceControlProviderError {
return new SourceControlProviderError({
@@ -35,20 +35,6 @@
};
}
-function sourceFromInput(input: {
- readonly headSelector: string;
- readonly source?: SourceControlRefSelector;
-}): SourceControlRefSelector | undefined {
- if (input.source) {
- return input.source;
- }
-
- const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
- const owner = match?.[1]?.trim();
- const refName = match?.[2]?.trim();
- return owner && refName ? { owner, refName } : undefined;
-}
-
export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () {
const gitlab = yield* GitLabCli;
diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts
--- a/apps/server/src/sourceControl/SourceControlProvider.ts
+++ b/apps/server/src/sourceControl/SourceControlProvider.ts
@@ -62,6 +62,20 @@
}) => Effect.Effect<void, SourceControlProviderError>;
}
+export function sourceFromInput(input: {
+ readonly headSelector: string;
+ readonly source?: SourceControlRefSelector;
+}): SourceControlRefSelector | undefined {
+ if (input.source) {
+ return input.source;
+ }
+
+ const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
+ const owner = match?.[1]?.trim();
+ const refName = match?.[2]?.trim();
+ return owner && refName ? { owner, refName } : undefined;
+}
+
export class SourceControlProvider extends Context.Service<
SourceControlProvider,
SourceControlProviderShapeYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 7 total unresolved issues (including 5 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: State "all" only returns OPEN pull requests
- Changed toBitbucketState to toBitbucketStates returning an array of all Bitbucket states (OPEN, MERGED, DECLINED, SUPERSEDED) for the "all" case, and used appendUrlParam to add each state as a separate query parameter so the API correctly returns PRs in all states.
- ✅ Fixed: Unused exported functions in bitbucketPullRequests module
- Removed the dead code: decodeBitbucketPullRequestListJson, decodeBitbucketPullRequestJson, formatBitbucketJsonDecodeError, and their internal helpers (decodeBitbucketPullRequestList, decodeBitbucketPullRequest, decodeBitbucketPullRequestEntry), along with the now-unused Cause, Exit, Result imports and the decodeJsonResult/formatSchemaError import.
Or push these changes by commenting:
@cursor push f4836b44cd
Preview (f4836b44cd)
diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts
--- a/apps/server/src/sourceControl/BitbucketApi.ts
+++ b/apps/server/src/sourceControl/BitbucketApi.ts
@@ -162,16 +162,16 @@
return ownerSelector?.[1]?.trim();
}
-function toBitbucketState(state: "open" | "closed" | "merged" | "all"): string | null {
+function toBitbucketStates(state: "open" | "closed" | "merged" | "all"): ReadonlyArray<string> {
switch (state) {
case "open":
- return "OPEN";
+ return ["OPEN"];
case "closed":
- return "DECLINED";
+ return ["DECLINED"];
case "merged":
- return "MERGED";
+ return ["MERGED"];
case "all":
- return null;
+ return ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"];
}
}
@@ -405,26 +405,24 @@
listPullRequests: (input) =>
resolveRepository(input).pipe(
Effect.flatMap((repository) => {
- const state = toBitbucketState(input.state);
+ const states = toBitbucketStates(input.state);
const query: Record<string, string> = {
pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
sort: "-updated_on",
q: `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`,
};
- if (state !== null) {
- query.state = state;
- }
- return executeJson(
- "listPullRequests",
- HttpClientRequest.get(
- apiUrl(
- `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
- ),
- { urlParams: query },
+ let request = HttpClientRequest.get(
+ apiUrl(
+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
),
- BitbucketPullRequestListSchema,
+ { urlParams: query },
);
+ for (const s of states) {
+ request = HttpClientRequest.appendUrlParam(request, "state", s);
+ }
+
+ return executeJson("listPullRequests", request, BitbucketPullRequestListSchema);
}),
Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
),
diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts
--- a/apps/server/src/sourceControl/bitbucketPullRequests.ts
+++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts
@@ -1,6 +1,5 @@
-import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
+import { DateTime, Option, Schema } from "effect";
import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
-import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
export interface NormalizedBitbucketPullRequestRecord {
readonly number: number;
@@ -103,49 +102,3 @@
...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
};
}
-
-const decodeBitbucketPullRequestList = decodeJsonResult(Schema.Unknown);
-const decodeBitbucketPullRequest = decodeJsonResult(BitbucketPullRequestSchema);
-const decodeBitbucketPullRequestEntry = Schema.decodeUnknownExit(BitbucketPullRequestSchema);
-
-export const formatBitbucketJsonDecodeError = formatSchemaError;
-
-export function decodeBitbucketPullRequestListJson(
- raw: string,
-): Result.Result<
- ReadonlyArray<NormalizedBitbucketPullRequestRecord>,
- Cause.Cause<Schema.SchemaError>
-> {
- const result = decodeBitbucketPullRequestList(raw);
- if (Result.isFailure(result)) {
- return Result.fail(result.failure);
- }
-
- const entries: ReadonlyArray<unknown> = Array.isArray(result.success)
- ? result.success
- : typeof result.success === "object" &&
- result.success !== null &&
- "values" in result.success &&
- Array.isArray(result.success.values)
- ? result.success.values
- : [];
- const pullRequests: NormalizedBitbucketPullRequestRecord[] = [];
- for (const entry of entries) {
- const decodedEntry = decodeBitbucketPullRequestEntry(entry);
- if (Exit.isFailure(decodedEntry)) {
- continue;
- }
- pullRequests.push(normalizeBitbucketPullRequestRecord(decodedEntry.value));
- }
- return Result.succeed(pullRequests);
-}
-
-export function decodeBitbucketPullRequestJson(
- raw: string,
-): Result.Result<NormalizedBitbucketPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
- const result = decodeBitbucketPullRequest(raw);
- if (Result.isSuccess(result)) {
- return Result.succeed(normalizeBitbucketPullRequestRecord(result.success));
- }
- return Result.fail(result.failure);
-}You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 5 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Missing
stateparams for closed PR queries- Replaced the single-key Record-based approach with iterating over all states and calling HttpClientRequest.appendUrlParam for each, so both DECLINED and SUPERSEDED state params are sent for closed PR queries.
- ✅ Fixed: Discovery hardcodes Bitbucket for all API providers
- Added a probeAuth effect to the ApiProviderProbe type and moved API probe construction into the layer closure, so probeProvider now dispatches via input.probeAuth instead of hardcoding bitbucketApi.probeAuth.
Or push these changes by commenting:
@cursor push 3f24b1c09c
Preview (3f24b1c09c)
diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts
--- a/apps/server/src/sourceControl/BitbucketApi.ts
+++ b/apps/server/src/sourceControl/BitbucketApi.ts
@@ -414,7 +414,7 @@
resolveRepository(input).pipe(
Effect.flatMap((repository) => {
const states = toBitbucketStates(input.state);
- const query: Record<string, string> = {
+ const baseQuery: Record<string, string> = {
pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
sort: "-updated_on",
q: bitbucketQueryString([
@@ -422,20 +422,20 @@
bitbucketStateFilter(states),
]),
};
- if (input.state !== "all" && states.length === 1) {
- query.state = states[0] ?? "OPEN";
- }
- return executeJson(
- "listPullRequests",
- HttpClientRequest.get(
- apiUrl(
- `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
- ),
- { urlParams: query },
+ let request = HttpClientRequest.get(
+ apiUrl(
+ `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/pullrequests`,
),
- BitbucketPullRequestListSchema,
+ { urlParams: baseQuery },
);
+ if (input.state !== "all") {
+ for (const state of states) {
+ request = HttpClientRequest.appendUrlParam(request, "state", state);
+ }
+ }
+
+ return executeJson("listPullRequests", request, BitbucketPullRequestListSchema);
}),
Effect.map((list) => list.values.map(normalizeBitbucketPullRequestRecord)),
),
diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts
--- a/apps/server/src/sourceControl/SourceControlDiscovery.ts
+++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts
@@ -35,6 +35,7 @@
readonly type: "api";
readonly kind: SourceControlProviderKind;
readonly executable: string;
+ readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
};
type ProviderProbe = CliProviderProbe | ApiProviderProbe;
@@ -75,7 +76,7 @@
},
];
-const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
+const CLI_PROVIDER_PROBES: ReadonlyArray<CliProviderProbe> = [
{
type: "cli",
kind: "github",
@@ -111,15 +112,6 @@
installHint:
"Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
},
- {
- type: "api",
- kind: "bitbucket",
- label: "Bitbucket",
- executable: "Bitbucket REST API",
- implemented: true,
- installHint:
- "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.",
- },
];
function firstNonEmptyLine(text: string): Option.Option<string> {
@@ -337,9 +329,23 @@
),
);
+ const sourceControlProviderProbes: ReadonlyArray<ProviderProbe> = [
+ ...CLI_PROVIDER_PROBES,
+ {
+ type: "api",
+ kind: "bitbucket",
+ label: "Bitbucket",
+ executable: "Bitbucket REST API",
+ implemented: true,
+ installHint:
+ "Create a Bitbucket API token with pull request/repository scopes, then set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN.",
+ probeAuth: bitbucketApi.probeAuth,
+ },
+ ];
+
const probeProvider = (input: ProviderProbe) =>
input.type === "api"
- ? bitbucketApi.probeAuth.pipe(
+ ? input.probeAuth.pipe(
Effect.map(
(auth) =>
({
@@ -400,7 +406,7 @@
{ concurrency: "unbounded" },
),
sourceControlProviders: Effect.all(
- SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
+ sourceControlProviderProbes.map((entry) => probeProvider(entry)) as ReadonlyArray<
Effect.Effect<SourceControlProviderDiscoveryItem>
>,
{ concurrency: "unbounded" },You can send follow-ups to the cloud agent here.
2806cc0 to
9f10d14
Compare
Co-authored-by: Julius Marminge <julius@macmini.local>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Redundant
sourceFromInputduplicates shared utility function- Removed the redundant private
sourceFromInputfunction and replaced its usages with the sharedsourceControlRefFromInpututility, consistent with the other providers.
- Removed the redundant private
Or push these changes by commenting:
@cursor push 74eac4dd65
Preview (74eac4dd65)
diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
--- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
+++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
@@ -2,7 +2,7 @@
import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
import { AzureDevOpsCli, type AzureDevOpsCliError } from "./AzureDevOpsCli.ts";
-import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+import { sourceControlRefFromInput, SourceControlProvider } from "./SourceControlProvider.ts";
import {
combinedAuthOutput,
firstSafeAuthLine,
@@ -77,27 +77,13 @@
};
}
-function sourceFromInput(input: {
- readonly headSelector: string;
- readonly source?: SourceControlRefSelector;
-}): SourceControlRefSelector | undefined {
- if (input.source) {
- return input.source;
- }
-
- const match = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
- const owner = match?.[1]?.trim();
- const refName = match?.[2]?.trim();
- return owner && refName ? { owner, refName } : undefined;
-}
-
export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* () {
const azure = yield* AzureDevOpsCli;
return SourceControlProvider.of({
kind: "azure-devops",
listChangeRequests: (input) => {
- const source = sourceFromInput(input);
+ const source = sourceControlRefFromInput(input);
return azure
.listPullRequests({
cwd: input.cwd,
@@ -117,7 +103,7 @@
Effect.mapError((error) => providerError("getChangeRequest", error)),
),
createChangeRequest: (input) => {
- const source = sourceFromInput(input);
+ const source = sourceControlRefFromInput(input);
return azure
.createPullRequest({
cwd: input.cwd,You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 3d3960f. Configure here.
| const owner = match?.[1]?.trim(); | ||
| const refName = match?.[2]?.trim(); | ||
| return owner && refName ? { owner, refName } : undefined; | ||
| } |
There was a problem hiding this comment.
Redundant sourceFromInput duplicates shared utility function
Low Severity
The private sourceFromInput in AzureDevOpsSourceControlProvider.ts is functionally identical to the shared sourceControlRefFromInput exported from SourceControlProvider.ts. In this same commit, GitLabSourceControlProvider.ts was refactored to replace its local copy with the shared version, and the new BitbucketSourceControlProvider.ts already uses it. The new Azure DevOps file was not updated to follow suit, introducing redundant logic that increases maintenance burden.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 3d3960f. Configure here.
…cross VCS modules - Consolidated import statements for VCS-related modules to improve readability. - Updated type references to use the new structure for VCS driver shapes and related services. - Ensured consistency in the usage of layer mocks and type definitions across various source control providers, including GitHub, Bitbucket, and Azure DevOps. - Enhanced type safety in function signatures by utilizing the updated types from the respective modules.



Note
Medium Risk
Adds two new source-control integrations (Bitbucket via REST API and Azure DevOps via
az) and changes discovery/registry wiring, which can affect provider selection, auth probing, and checkout behavior across the server. Risk is moderated by extensive new unit tests but still touches core source-control routing and process/HTTP interactions.Overview
Adds Bitbucket (REST API) and Azure DevOps (
azCLI) source-control providers, including PR listing/view/creation, repository clone/create, default-branch lookup, and provider-specific checkout behavior (Bitbucket adds a Git-based checkout path for fork PRs).Refactors provider discovery so
SourceControlDiscoverydelegates toSourceControlProviderRegistry.discovervia a newSourceControlProviderDiscoverymodule and per-providerdiscoveryspecs;SourceControlProviderRegistrynow registers all four providers (GitHub/GitLab/Bitbucket/Azure DevOps), binds detected remote context into provider calls, and reports availability/auth status.Updates server composition to include the new provider layers and adjusts tests/mocks to the new registry/discovery shape (plus minor import/style refactors).
Reviewed by Cursor Bugbot for commit 7cf2c5f. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add Bitbucket and Azure DevOps source control providers
azCLI with equivalent PR and repository capabilities.SourceControlProviderDiscoverySpecobjects; refactorsSourceControlDiscoveryto delegate probing to the registry instead of handling it inline.SourceControlProviderRegistryand wires them into the HTTP server and WebSocket RPC route.checkoutCommandExampleoptional inChangeRequestPresentationand removes it from Bitbucket and generic presentations.SourceControlDiscovery.discovernow returns provider availability from the registry rather than internal probes; Azure DevOps and Bitbucket are now markedimplemented: true.Macroscope summarized 3d3960f.