Skip to content

feat(scm): Azure Devops#2463

Open
juliusmarminge wants to merge 8 commits intot3code/bitbucket-adapterfrom
t3code/azure-devops-provider
Open

feat(scm): Azure Devops#2463
juliusmarminge wants to merge 8 commits intot3code/bitbucket-adapterfrom
t3code/azure-devops-provider

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 2, 2026

Summary

  • Introduce a provider-neutral VCS layer and rewrite local Git handling around the new VcsDriver, VcsProcess, and registry abstractions.
  • Add a pluggable source control provider layer with GitHub support and new Azure DevOps provider scaffolding, plus provider-neutral contracts and parsing helpers.
  • Refactor GitManager, server orchestration, and web UI logic to consume the new VCS/source-control APIs and updated reference models.
  • Update release automation and Discord notification scripts, along with several supporting test and contract rewrites.

Testing

  • Not run
  • bun fmt
  • bun lint
  • bun typecheck

Note

Medium Risk
Introduces a new Azure DevOps provider that shells out to az, affecting PR/branch workflows and provider detection; failures or CLI/environment differences could impact source-control features at runtime.

Overview
Adds Azure DevOps as a fully implemented source control provider.

Implements AzureDevOpsCli (wrapper around az repos pr/az repos show) plus JSON decoding/normalization for PR records, and an AzureDevOpsSourceControlProvider that maps provider-neutral change request operations (list/get/create/checkout, clone URLs, default branch) to the CLI.

Registers the provider in SourceControlProviderRegistry (replacing the prior unsupported placeholder), wires AzureDevOpsCli.layer into server + websocket RPC layers, updates tests for discovery/registry behavior, extends web PR reference parsing to accept az repos pr checkout --id=42, and documents Azure DevOps setup/auth requirements.

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

Note

Add Azure DevOps as a functional source control provider via Azure CLI

  • Implements AzureDevOpsCli service in AzureDevOpsCli.ts that wraps az repos CLI commands to list/get/create PRs, read clone URLs, detect default branch, and checkout PRs.
  • Adds AzureDevOpsSourceControlProvider in AzureDevOpsSourceControlProvider.ts that maps Azure DevOps CLI operations to the provider-neutral SourceControlProvider interface.
  • Registers the Azure DevOps provider in SourceControlProviderRegistry and both HTTP and WebSocket server layers, replacing the previous unsupportedProvider stub.
  • Extends parseAzureDevOpsCheckoutReference in pullRequestReference.ts to handle --id=42 equals-style flags in checkout commands.
  • Adds user-facing documentation in docs/source-control-providers.md covering CLI requirements, sign-in steps, and URL format notes.
  • Risk: requires az CLI with the Azure DevOps extension installed and an active az login session; missing or unauthenticated CLI surfaces structured errors but PR operations will fail.

Macroscope summarized ccb500d.

@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: 770cf293-e508-4934-a3f0-24130f0f0e1c

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/azure-devops-provider

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

@juliusmarminge juliusmarminge changed the base branch from main to t3code/pluggable-git-integration May 2, 2026 08:41
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 2, 2026
@juliusmarminge juliusmarminge changed the title Add pluggable VCS and source control provider layers feat(scm): Azure Devops May 2, 2026
Comment thread apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts Outdated
Comment thread apps/server/src/sourceControl/azureDevOpsPullRequests.ts Outdated
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 2, 2026

Approvability

Verdict: Needs human review

This PR introduces Azure DevOps as a new source control provider, adding a new integration with ~700 lines of new code including CLI wrapper, provider implementation, and PR parsing logic. New features introducing integrations warrant human review regardless of scope.

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

Copy link
Copy Markdown
Member Author

@juliusmarminge juliusmarminge left a comment

Choose a reason for hiding this comment

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

Reviewed this against the pluggable source-control provider plan. This is a good reference for Azure command coverage and URL parsing, but I would not use this exact shape as the provider template yet.

What I would change:

  • The source-control provider should not depend on GitVcsDriver. AzureDevOpsSourceControlProvider reads remote.origin.url through the Git driver to infer repository context, which couples hosted-provider routing back to Git. Provider detection should receive generic repository/remote context from the registry or a VCS remote capability, so this can work for JJ, future VCS drivers, and non-origin remotes.
  • AzureDevOpsCli.execute appends --only-show-errors --output json to every az command. That is unsafe for commands like checkout and other side-effect commands that are not JSON-producing. Split JSON query commands from side-effect commands, or make output mode command-specific.
  • Same process-layer concern as GitLab: new provider code should not use the old processRunner/raw readFile path. We should use a shared Effect-native source-control process service with scoped processes, stream-first output, typed exit codes, and provider-specific errors.
  • createPullRequest reads the body file and passes the full text as --description <body>. That risks command length limits and exposes generated PR content in process args. Prefer body file/stdin/API transport.
  • The UI/provider presentation additions overlap with the GitLab PR’s presentation model. We should converge on one provider presentation module instead of each provider PR adding parallel terminology helpers.
  • Default branch/repository lookup appears tied to origin URL parsing. The registry should detect and pass provider/repository context once, then providers can use that context consistently for default branch, checkout, create, and list flows.

The most important architectural issue here is the Git dependency inside the Azure provider. If we merge that pattern, source-control providers stop being independent from VCS drivers, which undercuts the larger pluggable VCS + pluggable provider design.

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: Early provider resolution fails entire stacked action
    • Wrapped the early sourceControlProvider call with Effect.catch fallback to sourceControlChangeRequestTerms("unknown"), matching the pattern used in buildCompletionToast, so provider resolution failure no longer aborts commit and push steps.
  • ✅ Fixed: Nullish coalescing skips creationDate fallback for Option.none
    • Replaced the ?? chain with Option.orElse so that Option.none() from closedDate correctly falls through to creationDate instead of being treated as a truthy value.

Create PR

Or push these changes by commenting:

@cursor push f8c8084a8b
Preview (f8c8084a8b)
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -1677,7 +1677,10 @@
         const currentBranch = branchStep.name ?? initialStatus.branch;
         const commitAction = isCommitAction(input.action) ? input.action : null;
         const changeRequestTerms = wantsPr
-          ? sourceControlChangeRequestTerms((yield* sourceControlProvider(input.cwd)).kind)
+          ? yield* sourceControlProvider(input.cwd).pipe(
+              Effect.map((provider) => sourceControlChangeRequestTerms(provider.kind)),
+              Effect.catch(() => Effect.succeed(sourceControlChangeRequestTerms("unknown"))),
+            )
           : null;
 
         const commit = commitAction

diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts
--- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts
+++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts
@@ -62,7 +62,10 @@
     baseRefName: normalizeRefName(raw.targetRefName),
     headRefName: normalizeRefName(raw.sourceRefName),
     state: normalizeAzureDevOpsPullRequestState(raw.status),
-    updatedAt: raw.closedDate ?? raw.creationDate ?? Option.none(),
+    updatedAt: Option.orElse(
+      raw.closedDate ?? Option.none(),
+      () => raw.creationDate ?? Option.none(),
+    ),
   };
 }

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

Comment thread apps/server/src/git/GitManager.ts
Comment thread apps/server/src/sourceControl/azureDevOpsPullRequests.ts Outdated
Comment thread apps/server/src/sourceControl/AzureDevOpsCli.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 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Identical errorText helper duplicated across two files
    • Extracted the duplicated errorText function into a shared vcsErrorText utility at apps/server/src/vcs/vcsErrorText.ts and updated both AzureDevOpsCli.ts and GitHubCli.ts to import from it.
  • ✅ Fixed: Azure DevOps checkout silently ignores force parameter
    • Added force?: boolean to AzureDevOpsCliShape.checkoutPullRequest input type and implemented force support by running git checkout --force before az repos pr checkout when the flag is set, matching the GitHub provider's behavior.

Create PR

Or push these changes by commenting:

@cursor push 61dddcfcb3
Preview (61dddcfcb3)
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
@@ -2,6 +2,7 @@
 import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
 
 import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import { vcsErrorText } from "../vcs/vcsErrorText.ts";
 import {
   decodeAzureDevOpsPullRequestJson,
   decodeAzureDevOpsPullRequestListJson,
@@ -69,6 +70,7 @@
   readonly checkoutPullRequest: (input: {
     readonly cwd: string;
     readonly reference: string;
+    readonly force?: boolean;
   }) => Effect.Effect<void, AzureDevOpsCliError>;
 }
 
@@ -76,22 +78,11 @@
   "t3/source-control/AzureDevOpsCli",
 ) {}
 
-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 normalizeAzureDevOpsCliError(
   operation: "execute" | "readBodyFile",
   error: VcsError | unknown,
 ): AzureDevOpsCliError {
-  const text = errorText(error);
+  const text = vcsErrorText(error);
   const lower = text.toLowerCase();
 
   if (lower.includes("command not found: az") || lower.includes("enoent")) {
@@ -363,19 +354,38 @@
         Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
       ),
     checkoutPullRequest: (input) =>
-      execute({
-        cwd: input.cwd,
-        args: [
-          "repos",
-          "pr",
-          "checkout",
-          "--only-show-errors",
-          "--id",
-          normalizeChangeRequestId(input.reference),
-          "--remote-name",
-          "origin",
-        ],
-      }).pipe(Effect.asVoid),
+      (input.force
+        ? process
+            .run({
+              operation: "AzureDevOpsCli.checkoutPullRequest",
+              command: "git",
+              args: ["checkout", "--force"],
+              cwd: input.cwd,
+              timeoutMs: DEFAULT_TIMEOUT_MS,
+            })
+            .pipe(
+              Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error)),
+              Effect.asVoid,
+            )
+        : Effect.void
+      ).pipe(
+        Effect.andThen(
+          execute({
+            cwd: input.cwd,
+            args: [
+              "repos",
+              "pr",
+              "checkout",
+              "--only-show-errors",
+              "--id",
+              normalizeChangeRequestId(input.reference),
+              "--remote-name",
+              "origin",
+            ],
+          }),
+        ),
+        Effect.asVoid,
+      ),
   });
 });
 

diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts
--- a/apps/server/src/sourceControl/GitHubCli.ts
+++ b/apps/server/src/sourceControl/GitHubCli.ts
@@ -3,6 +3,7 @@
 import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
 
 import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import { vcsErrorText } from "../vcs/vcsErrorText.ts";
 import {
   decodeGitHubPullRequestJson,
   decodeGitHubPullRequestListJson,
@@ -75,22 +76,11 @@
   "t3/source-control/GitHubCli",
 ) {}
 
-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 normalizeGitHubCliError(
   operation: "execute" | "stdout",
   error: VcsError | unknown,
 ): GitHubCliError {
-  const text = errorText(error);
+  const text = vcsErrorText(error);
   const lower = text.toLowerCase();
 
   if (lower.includes("command not found: gh") || lower.includes("enoent")) {

diff --git a/apps/server/src/vcs/vcsErrorText.ts b/apps/server/src/vcs/vcsErrorText.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/vcs/vcsErrorText.ts
@@ -1,0 +1,12 @@
+import type { VcsError } from "@t3tools/contracts";
+
+export function vcsErrorText(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);
+}

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

Comment thread apps/server/src/sourceControl/AzureDevOpsCli.ts
Comment thread apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
Copy link
Copy Markdown
Member Author

@juliusmarminge juliusmarminge left a comment

Choose a reason for hiding this comment

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

Second pass after the follow-up commits. This fixed some concrete problems: Azure checkout no longer gets forced JSON output, CLI process execution moved to VcsProcess, and the provider no longer directly imports GitVcsDriver inside AzureDevOpsSourceControlProvider. Those are good fixes.

I still do not think this adheres to the long-term architecture yet.

Required changes / architectural blockers:

  • The Git dependency moved from AzureDevOpsSourceControlProvider into SourceControlProviderRegistry, but it is still there. Provider routing still calls GitVcsDriver.readConfigValue("remote.origin.url") and falls back to git remote -v. That means source-control provider routing is Git-only. The provider layer must sit above generic VCS remote/repository context, not above Git. JJ + Azure DevOps, Sapling + Azure DevOps, or any future VCS/provider mix should not need Git commands for provider detection.
  • SourceControlProviderShape still passes only cwd plus string selectors. Azure then relies on az --detect true and origin assumptions (checkoutPullRequest hardcodes --remote-name origin). That is not mix-and-match provider architecture. Detection should produce a provider context with remote name/url/repository identity, and provider operations should receive that context explicitly.
  • createPullRequest still reads the entire body and passes it as --description <body>. This remains a process-argv leak and command-length risk. GitLab moved to a body-file/API approach; Azure needs an equivalent safer transport or a documented API fallback.
  • The central provider registry is still hardcoded and duplicated with the GitLab PR. Adding Azure should not require copying the same registry detection/cache/map logic and editing central provider construction. We need a registration/layer model for providers before multiple provider PRs land.
  • The UI presentation model diverges from the GitLab PR (sourceControlTerms.ts + sourceControlPresentation.tsx here versus a different sourceControlPresentation.ts shape there). This duplication will conflict and makes provider terminology/icon support ad hoc. We should put one provider presentation model in the core branch and have provider PRs add data entries.

The most important issue is still architectural: this adds Azure command coverage, but provider resolution is still Git-derived and operation inputs are still stringly/provider-detected. That does not preserve the “generic VCS core + VCS extensions + separate source-control provider layer” model. I would hold this until provider routing uses generic remote context and the shared provider registration/presentation pieces are pulled into the core branch.

@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch from 18d781b to 58afff8 Compare May 2, 2026 16:37
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: Test expects file content but code passes file path
    • Fixed the test assertion to expect the @-prefixed file path (@${bodyFile}) that the production code actually passes, instead of the file contents string.

Create PR

Or push these changes by commenting:

@cursor push 84e43710c6
Preview (84e43710c6)
diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
--- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
@@ -197,7 +197,7 @@
         expect.objectContaining({
           command: "az",
           cwd: "/repo",
-          args: expect.arrayContaining(["--description", "Generated body"]),
+          args: expect.arrayContaining(["--description", `@${bodyFile}`]),
         }),
       );
       expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");

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

Comment thread apps/server/src/sourceControl/AzureDevOpsCli.test.ts
Comment thread apps/web/src/pullRequestReference.ts
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch from 28541d8 to 09485ee Compare May 2, 2026 19:18
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: Missing bitbucket case causes undefined return value
    • Added a BitbucketIcon component and the missing "bitbucket" case to the switch in getSourceControlPresentation, so the function now returns a valid SourceControlPresentation for Bitbucket providers instead of undefined.

Create PR

Or push these changes by commenting:

@cursor push ae60b34c38
Preview (ae60b34c38)
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -5,6 +5,7 @@
 import * as NodeServices from "@effect/platform-node/NodeServices";
 import { it } from "@effect/vitest";
 import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
 import { expect } from "vitest";
 import type {
   GitActionProgressEvent,
@@ -55,6 +56,16 @@
   failWith?: GitHubCliError;
 }
 
+function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
+  return {
+    exitCode: ChildProcessSpawner.ExitCode(0),
+    stdout,
+    stderr: "",
+    stdoutTruncated: false,
+    stderrTruncated: false,
+  };
+}
+
 interface FakeGitTextGeneration {
   generateCommitMessage: (input: {
     cwd: string;
@@ -390,24 +401,15 @@
           ? scenario.prListByHeadSelector?.[headSelector]
           : undefined;
       const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
-      return Effect.succeed({
-        stdout,
-        stderr: "",
-        code: 0,
-        signal: null,
-        timedOut: false,
-      });
+      return Effect.succeed(fakeGhOutput(stdout));
     }
 
     if (args[0] === "pr" && args[1] === "create") {
-      return Effect.succeed({
-        stdout:
+      return Effect.succeed(
+        fakeGhOutput(
           (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",
-        stderr: "",
-        code: 0,
-        signal: null,
-        timedOut: false,
-      });
+        ),
+      );
     }
 
     if (args[0] === "pr" && args[1] === "view") {
@@ -419,8 +421,8 @@
         headRefName: "feature/pull-request",
         state: "open",
       };
-      return Effect.succeed({
-        stdout:
+      return Effect.succeed(
+        fakeGhOutput(
           JSON.stringify({
             ...pullRequest,
             ...(pullRequest.headRepositoryNameWithOwner
@@ -438,11 +440,8 @@
                 }
               : {}),
           }) + "\n",
-        stderr: "",
-        code: 0,
-        signal: null,
-        timedOut: false,
-      });
+        ),
+      );
     }
 
     if (args[0] === "pr" && args[1] === "checkout") {
@@ -464,13 +463,7 @@
               runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]);
             }
           }
-          return {
-            stdout: "",
-            stderr: "",
-            code: 0,
-            signal: null,
-            timedOut: false,
-          };
+          return fakeGhOutput("");
         },
         catch: (error) =>
           isGitHubCliError(error)
@@ -497,26 +490,17 @@
             }),
           );
         }
-        return Effect.succeed({
-          stdout:
+        return Effect.succeed(
+          fakeGhOutput(
             JSON.stringify({
               nameWithOwner: repository,
               url: cloneUrls.url,
               sshUrl: cloneUrls.sshUrl,
             }) + "\n",
-          stderr: "",
-          code: 0,
-          signal: null,
-          timedOut: false,
-        });
+          ),
+        );
       }
-      return Effect.succeed({
-        stdout: `${scenario.defaultBranch ?? "main"}\n`,
-        stderr: "",
-        code: 0,
-        signal: null,
-        timedOut: false,
-      });
+      return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));
     }
 
     return Effect.fail(

diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -1,5 +1,4 @@
 import { randomUUID } from "node:crypto";
-import { realpathSync } from "node:fs";
 
 import {
   Array as Arr,
@@ -49,7 +48,7 @@
 import type { GitManagerServiceError } from "@t3tools/contracts";
 import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts";
 import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
-import type { ChangeRequest } from "@t3tools/contracts";
+import type { ChangeRequest, SourceControlProviderKind } from "@t3tools/contracts";
 
 export interface GitActionProgressReporter {
   readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
@@ -147,6 +146,24 @@
   isCrossRepository: boolean;
 }
 
+interface ChangeRequestTerms {
+  shortLabel: string;
+  singular: string;
+}
+
+function sourceControlChangeRequestTerms(kind: SourceControlProviderKind): ChangeRequestTerms {
+  if (kind === "gitlab") {
+    return {
+      shortLabel: "MR",
+      singular: "merge request",
+    };
+  }
+  return {
+    shortLabel: "PR",
+    singular: "pull request",
+  };
+}
+
 function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
   const trimmed = url.trim();
   const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
@@ -354,13 +371,14 @@
 
 function summarizeGitActionResult(
   result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">,
+  terms: ChangeRequestTerms,
 ): {
   title: string;
   description?: string;
 } {
   if (result.pr.status === "created" || result.pr.status === "opened_existing") {
     const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
-    const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
+    const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`;
     return withDescription(title, truncateText(result.pr.title));
   }
 
@@ -485,14 +503,6 @@
   return hashNumber?.[1] ?? trimmed;
 }
 
-function canonicalizeExistingPath(value: string): string {
-  try {
-    return realpathSync.native(value);
-  } catch {
-    return value;
-  }
-}
-
 function toResolvedPullRequest(pr: {
   number: number;
   title: string;
@@ -680,7 +690,9 @@
   const path = yield* Path.Path;
 
   const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
-  const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
+  const canonicalizeExistingPath = (value: string) =>
+    fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value)));
+  const normalizeStatusCacheKey = canonicalizeExistingPath;
   const nonRepositoryStatusDetails = {
     isRepo: false,
     hasOriginRemote: false,
@@ -718,7 +730,9 @@
     timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
   });
   const invalidateLocalStatusResultCache = (cwd: string) =>
-    Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
+    normalizeStatusCacheKey(cwd).pipe(
+      Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)),
+    );
   const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
     const details = yield* gitCore
       .statusDetails(cwd)
@@ -756,7 +770,9 @@
     timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
   });
   const invalidateRemoteStatusResultCache = (cwd: string) =>
-    Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
+    normalizeStatusCacheKey(cwd).pipe(
+      Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)),
+    );
 
   const readConfigValueNullable = (cwd: string, key: string) =>
     gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
@@ -941,7 +957,11 @@
     cwd: string,
     result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
   ) {
-    const summary = summarizeGitActionResult(result);
+    const terms = yield* sourceControlProvider(cwd).pipe(
+      Effect.map((provider) => sourceControlChangeRequestTerms(provider.kind)),
+      Effect.catch(() => Effect.succeed(sourceControlChangeRequestTerms("unknown"))),
+    );
+    const summary = summarizeGitActionResult(result, terms);
     let latestOpenPr: PullRequestInfo | null = null;
     let currentBranchIsDefault = false;
     let finalBranchContext: {
@@ -1006,7 +1026,7 @@
               result.pr.status === "opened_existing")
           ? {
               kind: "open_pr" as const,
-              label: "View PR",
+              label: `View ${terms.shortLabel}`,
               url: openPr.url,
             }
           : (result.action === "push" || result.action === "commit_push") &&
@@ -1014,7 +1034,7 @@
               !currentBranchIsDefault
             ? {
                 kind: "run_action" as const,
-                label: "Create PR",
+                label: `Create ${terms.shortLabel}`,
                 action: { kind: "create_pr" as const },
               }
             : {
@@ -1222,6 +1242,8 @@
     fallbackBranch: string | null,
     emit: GitActionProgressEmitter,
   ) {
+    const provider = yield* sourceControlProvider(cwd);
+    const terms = sourceControlChangeRequestTerms(provider.kind);
     const details = yield* gitCore.statusDetails(cwd);
     const branch = details.branch ?? fallbackBranch;
     if (!branch) {
@@ -1258,7 +1280,7 @@
     yield* emit({
       kind: "phase_started",
       phase: "pr",
-      label: "Generating PR content...",
+      label: `Generating ${terms.shortLabel} content...`,
     });
     const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
 
@@ -1283,9 +1305,9 @@
     yield* emit({
       kind: "phase_started",
       phase: "pr",
-      label: "Creating pull request...",
+      label: `Creating ${terms.singular}...`,
     });
-    yield* (yield* sourceControlProvider(cwd))
+    yield* provider
       .createChangeRequest({
         cwd,
         baseRefName: baseBranch,
@@ -1316,11 +1338,13 @@
   });
 
   const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
-    return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
+    const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
+    return yield* Cache.get(localStatusResultCache, cacheKey);
   });
   const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
     function* (input) {
-      return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
+      const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
+      return yield* Cache.get(remoteStatusResultCache, cacheKey);
     },
   );
   const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
@@ -1380,7 +1404,7 @@
     };
     return yield* Effect.gen(function* () {
       const normalizedReference = normalizePullRequestReference(input.reference);
-      const rootWorktreePath = canonicalizeExistingPath(input.cwd);
+      const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
       const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
         cwd: input.cwd,
         reference: normalizedReference,
@@ -1430,33 +1454,35 @@
       const localPullRequestBranch =
         resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
 
-      const findLocalHeadBranch = (cwd: string) =>
-        gitCore.listRefs({ cwd }).pipe(
-          Effect.map((result) => {
-            const localBranch = result.refs.find(
-              (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
-            );
-            if (localBranch) {
-              return localBranch;
-            }
-            if (localPullRequestBranch === pullRequest.headBranch) {
-              return null;
-            }
-            return (
-              result.refs.find(
-                (branch) =>
-                  !branch.isRemote &&
-                  branch.name === pullRequest.headBranch &&
-                  branch.worktreePath !== null &&
-                  canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
-              ) ?? null
-            );
-          }),
+      const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) {
+        const result = yield* gitCore.listRefs({ cwd });
+        const localBranch = result.refs.find(
+          (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
         );
+        if (localBranch) {
+          return localBranch;
+        }
+        if (localPullRequestBranch === pullRequest.headBranch) {
+          return null;
+        }
 
+        for (const branch of result.refs) {
+          if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) {
+            continue;
+          }
+
+          const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath);
+          if (worktreePath !== rootWorktreePath) {
+            return branch;
+          }
+        }
+
+        return null;
+      });
+
       const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
       const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
-        ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
+        ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
         : null;
       if (
         existingBranchBeforeFetch?.worktreePath &&
@@ -1484,7 +1510,7 @@
 
       const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
       const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
-        ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
+        ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
         : null;
       if (
         existingBranchAfterFetch?.worktreePath &&
@@ -1650,6 +1676,9 @@
 
         const currentBranch = branchStep.name ?? initialStatus.branch;
         const commitAction = isCommitAction(input.action) ? input.action : null;
+        const changeRequestTerms = wantsPr
+          ? sourceControlChangeRequestTerms((yield* sourceControlProvider(input.cwd)).kind)
+          : null;
 
         const commit = commitAction
           ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
@@ -1687,7 +1716,7 @@
               .emit({
                 kind: "phase_started",
                 phase: "pr",
-                label: "Preparing PR...",
+                label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`,
               })
               .pipe(
                 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),

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 AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
 import * as GitHubCli from "./sourceControl/GitHubCli.ts";
 import * as TextGeneration from "./textGeneration/TextGeneration.ts";
 import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
@@ -164,7 +165,7 @@
   Layer.provideMerge(GitVcsDriver.layer),
   Layer.provideMerge(
     SourceControlProviderRegistry.layer.pipe(
-      Layer.provide(GitHubCli.layer),
+      Layer.provide(Layer.mergeAll(AzureDevOpsCli.layer, GitHubCli.layer)),
       Layer.provideMerge(VcsDriverRegistryLayerLive),
     ),
   ),

diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
@@ -1,0 +1,235 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import { Effect, FileSystem, Layer, Option } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { afterEach, describe, expect, vi } from "vitest";
+import type { VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import * as AzureDevOpsCli from "./AzureDevOpsCli.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 supportLayer = Layer.mergeAll(
+  Layer.mock(VcsProcess)({
+    run: mockRun,
+  }),
+  NodeServices.layer,
+);
+const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer);
+
+afterEach(() => {
+  mockRun.mockReset();
+});
+
+describe("AzureDevOpsCli.layer", () => {
+  it.effect("parses pull request view output", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              pullRequestId: 42,
+              title: "Add Azure provider",
+              sourceRefName: "refs/heads/feature/source-control",
+              targetRefName: "refs/heads/main",
+              status: "active",
+              creationDate: "2026-01-02T00:00:00.000Z",
+              closedDate: null,
+              _links: {
+                web: {
+                  href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
+                },
+              },
+            }),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.getPullRequest({
+        cwd: "/repo",
+        reference: "#42",
+      });
+
+      assert.strictEqual(result.number, 42);
+      assert.strictEqual(result.title, "Add Azure provider");
+      assert.strictEqual(result.baseRefName, "main");
+      assert.strictEqual(result.headRefName, "feature/source-control");
+      assert.strictEqual(result.state, "open");
+      assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "show",
+          "--detect",
+          "true",
+          "--id",
+          "42",
+          "--only-show-errors",
+          "--output",
+          "json",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("lists pull requests with Azure status and source branch arguments", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify([
+              {
+                pullRequestId: 7,
+                title: "Merged work",
+                sourceRefName: "refs/heads/feature/merged",
+                targetRefName: "refs/heads/main",
+                status: "completed",
+                closedDate: "2026-01-03T00:00:00.000Z",
+                _links: {
+                  web: {
+                    href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
+                  },
+                },
+              },
+            ]),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.listPullRequests({
+        cwd: "/repo",
+        headSelector: "origin:feature/merged",
+        state: "merged",
+        limit: 10,
+      });
+
+      assert.strictEqual(result[0]?.state, "merged");
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "list",
+          "--detect",
+          "true",
+          "--source-branch",
+          "feature/merged",
+          "--status",
+          "completed",
+          "--top",
+          "10",
+          "--only-show-errors",
+          "--output",
+          "json",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("reads repository clone URLs", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              name: "repo",
+              webUrl: "https://dev.azure.com/acme/project/_git/repo",
+              remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
+              sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
+              project: {
+                name: "project",
+              },
+            }),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.getRepositoryCloneUrls({
+        cwd: "/repo",
+        repository: "repo",
+      });
+
+      assert.deepStrictEqual(result, {
+        nameWithOwner: "project/repo",
+        url: "https://dev.azure.com/acme/project/_git/repo",
+        sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("creates pull requests using the body file as the Azure description", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`;
+      yield* fileSystem.writeFileString(bodyFile, "Generated body");
+      mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      yield* az.createPullRequest({
+        cwd: "/repo",
+        baseBranch: "main",
+        headSelector: "feature/provider",
+        title: "Provider PR",
+        bodyFile,
+      });
+
+      expect(mockRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "az",
+          cwd: "/repo",
+          args: expect.arrayContaining(["--description", `@${bodyFile}`]),
+        }),
+      );
+      expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("does not force JSON output on checkout side-effect commands", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      yield* az.checkoutPullRequest({
+        cwd: "/repo",
+        reference: "42",
+      });
+
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "checkout",
+          "--only-show-errors",
+          "--id",
+          "42",
+          "--remote-name",
+          "origin",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+});

diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts
@@ -1,0 +1,383 @@
+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 {
+  decodeAzureDevOpsPullRequestJson,
+  decodeAzureDevOpsPullRequestListJson,
+  formatAzureDevOpsJsonDecodeError,
+  type NormalizedAzureDevOpsPullRequestRecord,
+} from "./azureDevOpsPullRequests.ts";
+import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()(
+  "AzureDevOpsCliError",
+  {
+    operation: Schema.String,
+    detail: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  },
+) {
+  override get message(): string {
+    return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
+  }
+}
+
+export interface AzureDevOpsRepositoryCloneUrls {
+  readonly nameWithOwner: string;
+  readonly url: string;
+  readonly sshUrl: string;
+}
+
+export interface AzureDevOpsCliShape {
+  readonly execute: (input: {
+    readonly cwd: string;
+    readonly args: ReadonlyArray<string>;
+    readonly timeoutMs?: number;
+  }) => Effect.Effect<VcsProcessOutput, AzureDevOpsCliError>;
+
+  readonly listPullRequests: (input: {
+    readonly cwd: string;
+    readonly headSelector: string;
+    readonly source?: SourceControlRefSelector;
+    readonly state: "open" | "closed" | "merged" | "all";
+    readonly limit?: number;
+  }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
+
+  readonly getPullRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+  }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
+
+  readonly getRepositoryCloneUrls: (input: {
+    readonly cwd: string;
+    readonly repository: string;
+  }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
+
+  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, AzureDevOpsCliError>;
+
+  readonly getDefaultBranch: (input: {
+    readonly cwd: string;
+  }) => Effect.Effect<string | null, AzureDevOpsCliError>;
+
+  readonly checkoutPullRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+    readonly remoteName?: string;
+  }) => Effect.Effect<void, AzureDevOpsCliError>;
+}
+
+export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
+  "t3/source-control/AzureDevOpsCli",
+) {}
+
+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 normalizeAzureDevOpsCliError(
+  operation: "execute",
+  error: VcsError | unknown,
+): AzureDevOpsCliError {
+  const text = errorText(error);
+  const lower = text.toLowerCase();
+
+  if (lower.includes("command not found: az") || lower.includes("enoent")) {
+    return new AzureDevOpsCliError({
+      operation,
+      detail:
+        "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.",
+      cause: error,
+    });
+  }
+
+  if (
+    lower.includes("az devops login") ||
+    lower.includes("please run az login") ||
+    lower.includes("not logged in") ||
+    lower.includes("authentication failed") ||
+    lower.includes("unauthorized")
+  ) {
+    return new AzureDevOpsCliError({
+      operation,
+      detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.",
+      cause: error,
... diff truncated: showing 800 of 2889 lines

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

Comment thread apps/web/src/sourceControlPresentation.ts
Comment thread apps/web/src/sourceControlPresentation.ts
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch 3 times, most recently from ae87f69 to e9fd83e Compare May 2, 2026 21:14
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: Server and client terminology diverge for unknown provider
    • Added an explicit 'unknown' case to the server-side sourceControlChangeRequestTerms function that returns shortLabel: 'change request' and singular: 'change request', matching the client-side resolveChangeRequestPresentation mapping.

Create PR

Or push these changes by commenting:

@cursor push 6dc308244b
Preview (6dc308244b)
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -152,16 +152,14 @@
 }
 
 function sourceControlChangeRequestTerms(kind: SourceControlProviderKind): ChangeRequestTerms {
-  if (kind === "gitlab") {
-    return {
-      shortLabel: "MR",
-      singular: "merge request",
-    };
+  switch (kind) {
+    case "gitlab":
+      return { shortLabel: "MR", singular: "merge request" };
+    case "unknown":
+      return { shortLabel: "change request", singular: "change request" };
+    default:
+      return { shortLabel: "PR", singular: "pull request" };
   }
-  return {
-    shortLabel: "PR",
-    singular: "pull request",
-  };
 }
 
 function parseRepositoryNameFromPullRequestUrl(url: string): string | null {

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

Comment thread apps/server/src/git/GitManager.ts Outdated
@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/azure-devops-provider branch 3 times, most recently from 5937070 to 7b88c9b Compare May 2, 2026 22:00
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:XXL 1,000+ changed lines (additions + deletions). 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 1 potential issue.

Autofix Details

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

  • ✅ Fixed: Missing --detect true in checkoutPullRequest command
    • Added the missing "--detect", "true" arguments to the checkoutPullRequest command's args array, consistent with all other az repos pr commands.

Create PR

Or push these changes by commenting:

@cursor push 42755a102f
Preview (42755a102f)
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 AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
 import * as GitHubCli from "./sourceControl/GitHubCli.ts";
 import * as TextGeneration from "./textGeneration/TextGeneration.ts";
 import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
@@ -165,7 +166,7 @@
   Layer.provideMerge(GitVcsDriver.layer),
   Layer.provideMerge(
     SourceControlProviderRegistry.layer.pipe(
-      Layer.provide(GitHubCli.layer),
+      Layer.provide(Layer.mergeAll(AzureDevOpsCli.layer, GitHubCli.layer)),
       Layer.provideMerge(VcsDriverRegistryLayerLive),
     ),
   ),

diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts
@@ -1,0 +1,235 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import { Effect, FileSystem, Layer, Option } from "effect";
+import { ChildProcessSpawner } from "effect/unstable/process";
+import { afterEach, describe, expect, vi } from "vitest";
+import type { VcsError } from "@t3tools/contracts";
+
+import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
+import * as AzureDevOpsCli from "./AzureDevOpsCli.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 supportLayer = Layer.mergeAll(
+  Layer.mock(VcsProcess)({
+    run: mockRun,
+  }),
+  NodeServices.layer,
+);
+const layer = Layer.mergeAll(AzureDevOpsCli.layer.pipe(Layer.provide(supportLayer)), supportLayer);
+
+afterEach(() => {
+  mockRun.mockReset();
+});
+
+describe("AzureDevOpsCli.layer", () => {
+  it.effect("parses pull request view output", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              pullRequestId: 42,
+              title: "Add Azure provider",
+              sourceRefName: "refs/heads/feature/source-control",
+              targetRefName: "refs/heads/main",
+              status: "active",
+              creationDate: "2026-01-02T00:00:00.000Z",
+              closedDate: null,
+              _links: {
+                web: {
+                  href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
+                },
+              },
+            }),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.getPullRequest({
+        cwd: "/repo",
+        reference: "#42",
+      });
+
+      assert.strictEqual(result.number, 42);
+      assert.strictEqual(result.title, "Add Azure provider");
+      assert.strictEqual(result.baseRefName, "main");
+      assert.strictEqual(result.headRefName, "feature/source-control");
+      assert.strictEqual(result.state, "open");
+      assert.deepStrictEqual(result.updatedAt._tag, Option.some(1)._tag);
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "show",
+          "--detect",
+          "true",
+          "--id",
+          "42",
+          "--only-show-errors",
+          "--output",
+          "json",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("lists pull requests with Azure status and source branch arguments", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify([
+              {
+                pullRequestId: 7,
+                title: "Merged work",
+                sourceRefName: "refs/heads/feature/merged",
+                targetRefName: "refs/heads/main",
+                status: "completed",
+                closedDate: "2026-01-03T00:00:00.000Z",
+                _links: {
+                  web: {
+                    href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/7",
+                  },
+                },
+              },
+            ]),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.listPullRequests({
+        cwd: "/repo",
+        headSelector: "origin:feature/merged",
+        state: "merged",
+        limit: 10,
+      });
+
+      assert.strictEqual(result[0]?.state, "merged");
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "list",
+          "--detect",
+          "true",
+          "--source-branch",
+          "feature/merged",
+          "--status",
+          "completed",
+          "--top",
+          "10",
+          "--only-show-errors",
+          "--output",
+          "json",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("reads repository clone URLs", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(
+        Effect.succeed(
+          processOutput(
+            JSON.stringify({
+              name: "repo",
+              webUrl: "https://dev.azure.com/acme/project/_git/repo",
+              remoteUrl: "https://dev.azure.com/acme/project/_git/repo",
+              sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
+              project: {
+                name: "project",
+              },
+            }),
+          ),
+        ),
+      );
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      const result = yield* az.getRepositoryCloneUrls({
+        cwd: "/repo",
+        repository: "repo",
+      });
+
+      assert.deepStrictEqual(result, {
+        nameWithOwner: "project/repo",
+        url: "https://dev.azure.com/acme/project/_git/repo",
+        sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo",
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("creates pull requests using the body file as the Azure description", () =>
+    Effect.gen(function* () {
+      const fileSystem = yield* FileSystem.FileSystem;
+      const bodyFile = `/tmp/t3code-azure-devops-cli-${Date.now()}.md`;
+      yield* fileSystem.writeFileString(bodyFile, "Generated body");
+      mockRun.mockReturnValueOnce(Effect.succeed(processOutput("{}")));
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      yield* az.createPullRequest({
+        cwd: "/repo",
+        baseBranch: "main",
+        headSelector: "feature/provider",
+        title: "Provider PR",
+        bodyFile,
+      });
+
+      expect(mockRun).toHaveBeenCalledWith(
+        expect.objectContaining({
+          command: "az",
+          cwd: "/repo",
+          args: expect.arrayContaining(["--description", `@${bodyFile}`]),
+        }),
+      );
+      expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output");
+    }).pipe(Effect.provide(layer)),
+  );
+
+  it.effect("does not force JSON output on checkout side-effect commands", () =>
+    Effect.gen(function* () {
+      mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
+
+      const az = yield* AzureDevOpsCli.AzureDevOpsCli;
+      yield* az.checkoutPullRequest({
+        cwd: "/repo",
+        reference: "42",
+      });
+
+      expect(mockRun).toHaveBeenCalledWith({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: [
+          "repos",
+          "pr",
+          "checkout",
+          "--only-show-errors",
+          "--id",
+          "42",
+          "--remote-name",
+          "origin",
+        ],
+        cwd: "/repo",
+        timeoutMs: 30_000,
+      });
+    }).pipe(Effect.provide(layer)),
+  );
+});

diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts
@@ -1,0 +1,385 @@
+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 {
+  decodeAzureDevOpsPullRequestJson,
+  decodeAzureDevOpsPullRequestListJson,
+  formatAzureDevOpsJsonDecodeError,
+  type NormalizedAzureDevOpsPullRequestRecord,
+} from "./azureDevOpsPullRequests.ts";
+import type { SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+const DEFAULT_TIMEOUT_MS = 30_000;
+
+export class AzureDevOpsCliError extends Schema.TaggedErrorClass<AzureDevOpsCliError>()(
+  "AzureDevOpsCliError",
+  {
+    operation: Schema.String,
+    detail: Schema.String,
+    cause: Schema.optional(Schema.Defect),
+  },
+) {
+  override get message(): string {
+    return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`;
+  }
+}
+
+export interface AzureDevOpsRepositoryCloneUrls {
+  readonly nameWithOwner: string;
+  readonly url: string;
+  readonly sshUrl: string;
+}
+
+export interface AzureDevOpsCliShape {
+  readonly execute: (input: {
+    readonly cwd: string;
+    readonly args: ReadonlyArray<string>;
+    readonly timeoutMs?: number;
+  }) => Effect.Effect<VcsProcessOutput, AzureDevOpsCliError>;
+
+  readonly listPullRequests: (input: {
+    readonly cwd: string;
+    readonly headSelector: string;
+    readonly source?: SourceControlRefSelector;
+    readonly state: "open" | "closed" | "merged" | "all";
+    readonly limit?: number;
+  }) => Effect.Effect<ReadonlyArray<NormalizedAzureDevOpsPullRequestRecord>, AzureDevOpsCliError>;
+
+  readonly getPullRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+  }) => Effect.Effect<NormalizedAzureDevOpsPullRequestRecord, AzureDevOpsCliError>;
+
+  readonly getRepositoryCloneUrls: (input: {
+    readonly cwd: string;
+    readonly repository: string;
+  }) => Effect.Effect<AzureDevOpsRepositoryCloneUrls, AzureDevOpsCliError>;
+
+  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, AzureDevOpsCliError>;
+
+  readonly getDefaultBranch: (input: {
+    readonly cwd: string;
+  }) => Effect.Effect<string | null, AzureDevOpsCliError>;
+
+  readonly checkoutPullRequest: (input: {
+    readonly cwd: string;
+    readonly reference: string;
+    readonly remoteName?: string;
+  }) => Effect.Effect<void, AzureDevOpsCliError>;
+}
+
+export class AzureDevOpsCli extends Context.Service<AzureDevOpsCli, AzureDevOpsCliShape>()(
+  "t3/source-control/AzureDevOpsCli",
+) {}
+
+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 normalizeAzureDevOpsCliError(
+  operation: "execute",
+  error: VcsError | unknown,
+): AzureDevOpsCliError {
+  const text = errorText(error);
+  const lower = text.toLowerCase();
+
+  if (lower.includes("command not found: az") || lower.includes("enoent")) {
+    return new AzureDevOpsCliError({
+      operation,
+      detail:
+        "Azure CLI (`az`) with the Azure DevOps extension is required but not available on PATH.",
+      cause: error,
+    });
+  }
+
+  if (
+    lower.includes("az devops login") ||
+    lower.includes("please run az login") ||
+    lower.includes("not logged in") ||
+    lower.includes("authentication failed") ||
+    lower.includes("unauthorized")
+  ) {
+    return new AzureDevOpsCliError({
+      operation,
+      detail: "Azure DevOps CLI is not authenticated. Run `az devops login` and retry.",
+      cause: error,
+    });
+  }
+
+  if (
+    lower.includes("pull request") &&
+    (lower.includes("not found") || lower.includes("does not exist"))
+  ) {
+    return new AzureDevOpsCliError({
+      operation,
+      detail: "Pull request not found. Check the PR number or URL and try again.",
+      cause: error,
+    });
+  }
+
+  return new AzureDevOpsCliError({
+    operation,
+    detail: text,
+    cause: error,
+  });
+}
+
+function normalizeChangeRequestId(reference: string): string {
+  const trimmed = reference.trim().replace(/^#/, "");
+  const urlMatch = /(?:pullrequest|pull-request|pull|_pulls?)\/(\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 toAzureStatus(state: "open" | "closed" | "merged" | "all"): string {
+  switch (state) {
+    case "open":
+      return "active";
+    case "closed":
+      return "abandoned";
+    case "merged":
+      return "completed";
+    case "all":
+      return "all";
+  }
+}
+
+const RawAzureDevOpsRepositorySchema = Schema.Struct({
+  name: TrimmedNonEmptyString,
+  webUrl: TrimmedNonEmptyString,
+  remoteUrl: TrimmedNonEmptyString,
+  sshUrl: TrimmedNonEmptyString,
+  project: Schema.optional(
+    Schema.Struct({
+      name: TrimmedNonEmptyString,
+    }),
+  ),
+  defaultBranch: Schema.optional(Schema.NullOr(Schema.String)),
+});
+
+function normalizeDefaultBranch(value: string | null | undefined): string | null {
+  const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? "";
+  return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizeRepositoryCloneUrls(
+  raw: Schema.Schema.Type<typeof RawAzureDevOpsRepositorySchema>,
+): AzureDevOpsRepositoryCloneUrls {
+  const projectName = raw.project?.name.trim();
+  return {
+    nameWithOwner: projectName ? `${projectName}/${raw.name}` : raw.name,
+    url: raw.remoteUrl,
+    sshUrl: raw.sshUrl,
+  };
+}
+
+function decodeAzureDevOpsJson<S extends Schema.Top>(
+  raw: string,
+  schema: S,
+  operation: "getRepositoryCloneUrls" | "getDefaultBranch",
+  invalidDetail: string,
+): Effect.Effect<S["Type"], AzureDevOpsCliError, S["DecodingServices"]> {
+  return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
+    Effect.mapError(
+      (error) =>
+        new AzureDevOpsCliError({
+          operation,
+          detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
+          cause: error,
+        }),
+    ),
+  );
+}
+
+export const make = Effect.fn("makeAzureDevOpsCli")(function* () {
+  const process = yield* VcsProcess;
+
+  const execute: AzureDevOpsCliShape["execute"] = (input) =>
+    process
+      .run({
+        operation: "AzureDevOpsCli.execute",
+        command: "az",
+        args: input.args,
+        cwd: input.cwd,
+        timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
+      })
+      .pipe(Effect.mapError((error) => normalizeAzureDevOpsCliError("execute", error)));
+
+  const executeJson = (input: Parameters<AzureDevOpsCliShape["execute"]>[0]) =>
+    execute({
+      ...input,
+      args: [...input.args, "--only-show-errors", "--output", "json"],
+    });
+
+  return AzureDevOpsCli.of({
+    execute,
+    listPullRequests: (input) =>
+      executeJson({
+        cwd: input.cwd,
+        args: [
+          "repos",
+          "pr",
+          "list",
+          "--detect",
+          "true",
+          "--source-branch",
+          sourceBranch(input),
+          "--status",
+          toAzureStatus(input.state),
+          "--top",
+          String(input.limit ?? 20),
+        ],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          raw.length === 0
+            ? Effect.succeed([])
+            : Effect.sync(() => decodeAzureDevOpsPullRequestListJson(raw)).pipe(
+                Effect.flatMap((decoded) => {
+                  if (!Result.isSuccess(decoded)) {
+                    return Effect.fail(
+                      new AzureDevOpsCliError({
+                        operation: "listPullRequests",
+                        detail: `Azure DevOps CLI returned invalid PR list JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
+                        cause: decoded.failure,
+                      }),
+                    );
+                  }
+
+                  return Effect.succeed(decoded.success);
+                }),
+              ),
+        ),
+      ),
+    getPullRequest: (input) =>
+      executeJson({
+        cwd: input.cwd,
+        args: [
+          "repos",
+          "pr",
+          "show",
+          "--detect",
+          "true",
+          "--id",
+          normalizeChangeRequestId(input.reference),
+        ],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          Effect.sync(() => decodeAzureDevOpsPullRequestJson(raw)).pipe(
+            Effect.flatMap((decoded) => {
+              if (!Result.isSuccess(decoded)) {
+                return Effect.fail(
+                  new AzureDevOpsCliError({
+                    operation: "getPullRequest",
+                    detail: `Azure DevOps CLI returned invalid pull request JSON: ${formatAzureDevOpsJsonDecodeError(decoded.failure)}`,
+                    cause: decoded.failure,
+                  }),
+                );
+              }
+
+              return Effect.succeed(decoded.success);
+            }),
+          ),
+        ),
+      ),
+    getRepositoryCloneUrls: (input) =>
+      executeJson({
+        cwd: input.cwd,
+        args: ["repos", "show", "--detect", "true", "--repository", input.repository],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          decodeAzureDevOpsJson(
+            raw,
+            RawAzureDevOpsRepositorySchema,
+            "getRepositoryCloneUrls",
+            "Azure DevOps CLI returned invalid repository JSON.",
+          ),
+        ),
+        Effect.map(normalizeRepositoryCloneUrls),
+      ),
+    createPullRequest: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: [
+          "repos",
+          "pr",
+          "create",
+          "--only-show-errors",
+          "--detect",
+          "true",
+          "--target-branch",
+          input.target?.refName ?? input.baseBranch,
+          "--source-branch",
+          sourceBranch(input),
+          "--title",
+          input.title,
+          "--description",
+          `@${input.bodyFile}`,
+        ],
+      }).pipe(Effect.asVoid),
+    getDefaultBranch: (input) =>
+      executeJson({
+        cwd: input.cwd,
+        args: ["repos", "show", "--detect", "true"],
+      }).pipe(
+        Effect.map((result) => result.stdout.trim()),
+        Effect.flatMap((raw) =>
+          decodeAzureDevOpsJson(
+            raw,
+            RawAzureDevOpsRepositorySchema,
+            "getDefaultBranch",
+            "Azure DevOps CLI returned invalid repository JSON.",
+          ),
+        ),
+        Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)),
+      ),
+    checkoutPullRequest: (input) =>
+      execute({
+        cwd: input.cwd,
+        args: [
+          "repos",
+          "pr",
+          "checkout",
+          "--detect",
+          "true",
+          "--only-show-errors",
+          "--id",
+          normalizeChangeRequestId(input.reference),
+          "--remote-name",
+          input.remoteName ?? "origin",
+        ],
+      }).pipe(Effect.asVoid),
+  });
+});
+
+export const layer = Layer.effect(AzureDevOpsCli, make());

diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts
@@ -1,0 +1,90 @@
+import { assert, it } from "@effect/vitest";
+import { Effect, Layer, Option } from "effect";
+
+import { AzureDevOpsCli, type AzureDevOpsCliShape } from "./AzureDevOpsCli.ts";
+import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts";
+
+function makeProvider(azure: Partial<AzureDevOpsCliShape>) {
+  return AzureDevOpsSourceControlProvider.make().pipe(
+    Effect.provide(Layer.mock(AzureDevOpsCli)(azure)),
+  );
+}
+
+it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () =>
+  Effect.gen(function* () {
+    const provider = yield* makeProvider({
+      getPullRequest: () =>
+        Effect.succeed({
+          number: 42,
+          title: "Add Azure provider",
+          url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
+          baseRefName: "main",
+          headRefName: "feature/source-control",
+          state: "open",
+          updatedAt: Option.none(),
+        }),
+    });
+
+    const changeRequest = yield* provider.getChangeRequest({
+      cwd: "/repo",
+      reference: "42",
+    });
+
+    assert.deepStrictEqual(changeRequest, {
+      provider: "azure-devops",
+      number: 42,
+      title: "Add Azure provider",
+      url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
+      baseRefName: "main",
+      headRefName: "feature/source-control",
+      state: "open",
+      updatedAt: Option.none(),
+      isCrossRepository: false,
+    });
+  }),
+);
+
+it.effect("creates Azure DevOps PRs through provider-neutral input names", () =>
+  Effect.gen(function* () {
+    let createInput: Parameters<AzureDevOpsCliShape["createPullRequest"]>[0] | null = null;
+    const provider = yield* makeProvider({
+      createPullRequest: (input) => {
+        createInput = input;
+        return Effect.void;
+      },
+    });
+
+    yield* provider.createChangeRequest({
+      cwd: "/repo",
+      baseRefName: "main",
+      headSelector: "feature/provider",
+      title: "Provider PR",
+      bodyFile: "/tmp/body.md",
+    });
+
+    assert.deepStrictEqual(createInput, {
+      cwd: "/repo",
+      baseBranch: "main",
+      headSelector: "feature/provider",
+      title: "Provider PR",
+      bodyFile: "/tmp/body.md",
+    });
+  }),
+);
+
+it.effect("uses Azure 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/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
new file mode 100644
--- /dev/null
+++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
@@ -1,0 +1,110 @@
+import { Effect, Layer } from "effect";
+import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";
+
+import { AzureDevOpsCli, type AzureDevOpsCliError } from "./AzureDevOpsCli.ts";
+import { SourceControlProvider, type SourceControlRefSelector } from "./SourceControlProvider.ts";
+
+function providerError(operation: string, cause: AzureDevOpsCliError): SourceControlProviderError {
+  return new SourceControlProviderError({
+    provider: "azure-devops",
+    operation,
+    detail: cause.detail,
+    cause,
+  });
+}
+
+function toChangeRequest(summary: {
+  readonly number: number;
+  readonly title: string;
+  readonly url: string;
+  readonly baseRefName: string;
+  readonly headRefName: string;
+  readonly state: "open" | "closed" | "merged";
+  readonly updatedAt: ChangeRequest["updatedAt"];
+}): ChangeRequest {
+  return {
+    provider: "azure-devops",
+    number: summary.number,
+    title: summary.title,
+    url: summary.url,
+    baseRefName: summary.baseRefName,
+    headRefName: summary.headRefName,
+    state: summary.state,
+    updatedAt: summary.updatedAt,
+    isCrossRepository: false,
+  };
+}
+
+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());
... diff truncated: showing 800 of 1073 lines

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

Comment thread apps/server/src/sourceControl/AzureDevOpsCli.ts
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch from 7b88c9b to a46a126 Compare May 2, 2026 22:07
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch 5 times, most recently from 9f2089d to 605f6b9 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/azure-devops-provider branch 2 times, most recently from fd50f50 to a2fa18e 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 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

  • ✅ Fixed: Duplicated sourceFromInput function across provider files
    • Extracted the duplicated sourceFromInput function into SourceControlProvider.ts as a shared export, and updated both AzureDevOpsSourceControlProvider.ts and GitLabSourceControlProvider.ts to import it from there.

Create PR

Or push these changes by commenting:

@cursor push f28771fb04
Preview (f28771fb04)
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 { SourceControlProvider, sourceFromInput } from "./SourceControlProvider.ts";
 
 function providerError(operation: string, cause: AzureDevOpsCliError): 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("makeAzureDevOpsSourceControlProvider")(function* () {
   const azure = yield* AzureDevOpsCli;
 

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.

Reviewed by Cursor Bugbot for commit a2fa18e. Configure here.

Comment thread apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch from a2fa18e to 1f5b53a Compare May 3, 2026 07:40
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels May 3, 2026
@juliusmarminge juliusmarminge changed the base branch from main to t3code/bitbucket-adapter May 3, 2026 07:42
Julius Marminge added 8 commits May 3, 2026 00:45
- Introduce Azure DevOps CLI and provider adapters
- Route remote detection, PR actions, and UI labels through provider terms
- Update server wiring and tests for Azure DevOps remotes
- Route GitHub and Azure DevOps CLI calls through the shared VcsProcess service
- Improve Azure DevOps error normalization and body file reading
- Update GitManager path canonicalization and related tests
- Use CLI repository detection for default-branch lookup
- Stop forcing JSON output on checkout commands
- Normalize pull request timestamps from closed date
@juliusmarminge juliusmarminge force-pushed the t3code/azure-devops-provider branch from 1f5b53a to ccb500d Compare May 3, 2026 07:46
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:XXL 1,000+ changed lines (additions + deletions). labels May 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 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.

1 participant