Skip to content

feat(source-control): add Bitbucket & Azure Devops providers#2473

Merged
juliusmarminge merged 26 commits intomainfrom
t3code/bitbucket-adapter
May 4, 2026
Merged

feat(source-control): add Bitbucket & Azure Devops providers#2473
juliusmarminge merged 26 commits intomainfrom
t3code/bitbucket-adapter

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 2, 2026

Note

Medium Risk
Adds new source-control integrations (Azure DevOps via az, Bitbucket via REST) and rewires provider discovery/registration, which can affect checkout/publish flows and discovery UI behavior if parsing or context binding is wrong.

Overview
Adds Bitbucket and Azure DevOps source-control providers, including new BitbucketApi (REST) and AzureDevOpsCli (Azure CLI) services plus provider wrappers that implement change-request and repository operations (list/get/create/checkout, clone URLs, default branch).

Refactors discovery to be driven by SourceControlProviderRegistry.discover and a new SourceControlProviderDiscovery helper module (providers now expose discovery specs and auth parsing), and updates wiring so providers receive a bound remote context derived from detected remotes.

Updates server runtime layering to provide the new provider layers, and adjusts/expands tests to cover provider routing, discovery/auth probing, and the new Bitbucket/Azure flows.

Reviewed by Cursor Bugbot for commit 5505016. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add Bitbucket and Azure DevOps as source control providers

  • Adds BitbucketApi and AzureDevOpsCli Effect services with full PR and repository operations (list, get, create, checkout, clone URLs, default branch).
  • Implements BitbucketSourceControlProvider and AzureDevOpsSourceControlProvider that map provider-neutral inputs/outputs to each backend's API or CLI shape.
  • Registers both providers in SourceControlProviderRegistry, wiring discovery specs and binding provider context from remote URL resolution alongside the existing GitHub and GitLab providers.
  • Adds a SourceControlProviderDiscovery module centralizing CLI/API probing and auth-status parsing; GitHub and GitLab providers now export discovery specs using this facility.
  • Extends the PublishRepositoryDialog and command palette add-project flow to surface all four providers, gating submission on per-provider readiness derived from discovery state.
  • Extracts AnimatedHeight into a shared component used by the publish dialog, add-provider wizard, and connections settings panel.
  • SourceControlProviderDiscoveryItem no longer carries an implemented boolean; provider rows remain interactive when status is available regardless of prior implemented checks.
  • Risk: the websocket RPC layer and server runtime now initialize four provider layers and the Git VCS driver on startup; any misconfiguration of Azure DevOps or Bitbucket environment variables will surface at runtime rather than at call time.

Macroscope summarized 5505016.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 060d1ad6-714d-47ea-a129-292063d05327

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/bitbucket-adapter

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 2, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 parseOwnerBranch utility in parseOwnerBranch.ts and updated both BitbucketCli.ts and BitbucketSourceControlProvider.ts to use it, eliminating the duplicated regex.
  • ✅ Fixed: Inconsistent Layer.provideMerge leaks BitbucketCli into output
    • Changed Layer.provideMerge(BitbucketCli.layer) to Layer.provide(BitbucketCli.layer) in server.ts to match the GitHubCli.layer wiring and avoid leaking the service into the composed layer's output.

Create PR

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.

Comment thread apps/server/src/sourceControl/BitbucketSourceControlProvider.ts Outdated
Comment thread apps/server/src/server.ts Outdated
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 2, 2026

Approvability

Verdict: Needs human review

1 blocking correctness issue found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 2 times, most recently from 485eb2c to 7a57722 Compare May 2, 2026 19:22
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'.

Create PR

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.

Comment thread apps/server/src/sourceControl/BitbucketCli.ts Outdated
@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 2 times, most recently from 08c6449 to d1441c4 Compare May 2, 2026 21:16
@juliusmarminge juliusmarminge force-pushed the t3code/pluggable-git-integration branch from 7aa00ff to f4180a4 Compare May 2, 2026 21:27
@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 3 times, most recently from 47992a2 to 5b38c88 Compare May 2, 2026 22:00
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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 lines

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/BitbucketCli.ts Outdated
@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 4 times, most recently from 159eddf to 2db4256 Compare May 2, 2026 22:22
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 errorText function across CLI modules
    • Extracted the identical errorText function from both BitbucketCli.ts and GitHubCli.ts into a shared errorText.ts module in the sourceControl directory, and updated both files to import from it.

Create PR

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 lines

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/BitbucketCli.ts Outdated
@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 2 times, most recently from ed9fe5c to 03f60cb Compare May 2, 2026 22:42
Base automatically changed from t3code/pluggable-git-integration to main May 2, 2026 23:08
@juliusmarminge juliusmarminge force-pushed the t3code/bitbucket-adapter branch 2 times, most recently from 9dd7636 to 82f7b50 Compare May 3, 2026 02:06
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sourceFromInput across two provider files
    • Extracted the duplicated sourceFromInput function into SourceControlProvider.ts and updated both BitbucketSourceControlProvider and GitLabSourceControlProvider to import the shared implementation.

Create PR

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,
   SourceControlProviderShape

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/BitbucketSourceControlProvider.ts Outdated
Comment thread packages/shared/src/sourceControl.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread apps/server/src/sourceControl/BitbucketApi.ts
Comment thread apps/server/src/sourceControl/bitbucketPullRequests.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 state params 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.

Create PR

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.

Comment thread apps/server/src/sourceControl/BitbucketApi.ts Outdated
Comment thread apps/server/src/sourceControl/SourceControlDiscovery.ts Outdated
@github-actions github-actions Bot removed the size:XL 500-999 changed lines (additions + deletions). label May 3, 2026
@juliusmarminge juliusmarminge changed the title feat(source-control): add Bitbucket provider feat(source-control): add Bitbucket & Azure Devops providers May 4, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Redundant sourceFromInput duplicates shared utility function
    • Removed the redundant private sourceFromInput function and replaced its usages with the shared sourceControlRefFromInput utility, consistent with the other providers.

Create PR

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.

Comment thread apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts Outdated
…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.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicate branch-parsing logic instead of reusing shared function
    • Replaced the inline regex in AzureDevOpsCli's normalizeSourceBranch with a delegation to the shared parseSourceControlOwnerRef function, matching the pattern already used by BitbucketApi.

Create PR

Or push these changes by commenting:

@cursor push 7166117faf
Preview (7166117faf)
diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts
--- a/apps/server/src/sourceControl/AzureDevOpsCli.ts
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts
@@ -7,7 +7,7 @@
 
 import * as VcsProcess from "../vcs/VcsProcess.ts";
 import * as AzureDevOpsPullRequests from "./azureDevOpsPullRequests.ts";
-import type * as SourceControlProvider from "./SourceControlProvider.ts";
+import * as SourceControlProvider from "./SourceControlProvider.ts";
 
 const DEFAULT_TIMEOUT_MS = 30_000;
 
@@ -158,9 +158,9 @@
 }
 
 function normalizeSourceBranch(headSelector: string): string {
-  const trimmed = headSelector.trim();
-  const ownerSelector = /^([^:/\s]+):(.+)$/u.exec(trimmed);
-  return ownerSelector?.[2]?.trim() ?? trimmed;
+  return (
+    SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim()
+  );
 }
 
 function sourceBranch(input: {

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/AzureDevOpsCli.ts Outdated
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 7166117

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Identical helper functions duplicated across two new files
    • Extracted the identical normalizeSourceBranch and sourceBranch helpers from AzureDevOpsCli.ts and BitbucketApi.ts into the shared SourceControlProvider.ts module, updating all call sites to use the shared exports.

Create PR

Or push these changes by commenting:

@cursor push 2f4cb0cd43
Preview (2f4cb0cd43)
diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts
--- a/apps/server/src/sourceControl/AzureDevOpsCli.ts
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts
@@ -157,19 +157,6 @@
   return urlMatch?.[1] ?? trimmed;
 }
 
-function normalizeSourceBranch(headSelector: string): string {
-  return (
-    SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim()
-  );
-}
-
-function sourceBranch(input: {
-  readonly headSelector: string;
-  readonly source?: SourceControlProvider.SourceControlRefSelector;
-}): string {
-  return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
-}
-
 function toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
   switch (state) {
     case "open":
@@ -276,7 +263,7 @@
           "--detect",
           "true",
           "--source-branch",
-          sourceBranch(input),
+          SourceControlProvider.sourceBranch(input),
           "--status",
           toAzureStatus(input.state),
           "--top",
@@ -397,7 +384,7 @@
           "--target-branch",
           input.target?.refName ?? input.baseBranch,
           "--source-branch",
-          sourceBranch(input),
+          SourceControlProvider.sourceBranch(input),
           "--title",
           input.title,
           "--description",

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
@@ -146,19 +146,6 @@
   return urlMatch?.[1] ?? trimmed;
 }
 
-function normalizeSourceBranch(headSelector: string): string {
-  return (
-    SourceControlProvider.parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim()
-  );
-}
-
-function sourceBranch(input: {
-  readonly headSelector: string;
-  readonly source?: SourceControlProvider.SourceControlRefSelector;
-}): string {
-  return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
-}
-
 function sourceWorkspace(input: {
   readonly headSelector: string;
   readonly source?: SourceControlProvider.SourceControlRefSelector;
@@ -547,7 +534,7 @@
             pagelen: String(Math.max(1, Math.min(input.limit ?? 20, 50))),
             sort: "-updated_on",
             q: bitbucketQueryString([
-              `source.branch.name = "${sourceBranch(input).replaceAll('"', '\\"')}"`,
+              `source.branch.name = "${SourceControlProvider.sourceBranch(input).replaceAll('"', '\\"')}"`,
               bitbucketStateFilter(states),
             ]),
             state: states,
@@ -613,7 +600,7 @@
           description,
           source: {
             branch: {
-              name: sourceBranch(input),
+              name: SourceControlProvider.sourceBranch(input),
             },
             ...(sourceOwner
               ? {

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
@@ -30,6 +30,17 @@
   return owner && refName ? { owner, refName } : undefined;
 }
 
+export function normalizeSourceBranch(headSelector: string): string {
+  return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim();
+}
+
+export function sourceBranch(input: {
+  readonly headSelector: string;
+  readonly source?: SourceControlRefSelector;
+}): string {
+  return input.source?.refName ?? normalizeSourceBranch(input.headSelector);
+}
+
 export function sourceControlRefFromInput(input: {
   readonly headSelector: string;
   readonly source?: SourceControlRefSelector;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 82a6f5a. Configure here.

Comment thread apps/server/src/sourceControl/AzureDevOpsCli.ts Outdated
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 2f4cb0c

cursoragent and others added 2 commits May 4, 2026 05:47
…ceControlProvider

Move identical normalizeSourceBranch and sourceBranch helper functions
from AzureDevOpsCli.ts and BitbucketApi.ts into the shared
SourceControlProvider.ts module, where parseSourceControlOwnerRef
already lives. Update call sites to use the shared exports.

Applied via @cursor push command
… flags

- Revised install hints for Azure DevOps, Bitbucket, GitHub, and GitLab to improve clarity and consistency.
- Removed the `implemented` flag from source control provider discovery specifications as it is no longer necessary.
- Updated related tests and components to reflect these changes.
Comment thread docs/source-control-providers.md Outdated
Comment thread apps/web/src/components/GitActionsControl.tsx Outdated
@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
t3code-app Ready Ready Preview, Comment May 4, 2026 7:54am

Request Review


- Push a branch and create a pull request from the Git panel
- T3 Code can suggest titles and descriptions based on your commits
- Supports GitHub Pull Requests, GitLab Merge Requests, and Bitbucket Pull Requests
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium docs/source-control-providers.md:36

Line 36 lists PR creation support for GitHub, GitLab, and Bitbucket but omits Azure DevOps Pull Requests, which contradicts the Azure DevOps support claimed in line 12 and the existing AzureDevOpsSourceControlProvider implementation. Users would incorrectly conclude Azure DevOps PRs are unsupported. Consider adding "Azure DevOps Pull Requests" to the list.

- +- Supports GitHub Pull Requests, GitLab Merge Requests, and Bitbucket Pull Requests
+ +- Supports GitHub Pull Requests, GitLab Merge Requests, Bitbucket Pull Requests, and Azure DevOps Pull Requests
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file docs/source-control-providers.md around line 36:

Line 36 lists PR creation support for GitHub, GitLab, and Bitbucket but omits Azure DevOps Pull Requests, which contradicts the Azure DevOps support claimed in line 12 and the existing `AzureDevOpsSourceControlProvider` implementation. Users would incorrectly conclude Azure DevOps PRs are unsupported. Consider adding "Azure DevOps Pull Requests" to the list.

@juliusmarminge juliusmarminge merged commit 91a03e0 into main May 4, 2026
14 checks passed
@juliusmarminge juliusmarminge deleted the t3code/bitbucket-adapter branch May 4, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants