feat(scm): Azure Devops#2463
feat(scm): Azure Devops#2463juliusmarminge wants to merge 8 commits intot3code/bitbucket-adapterfrom
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
ApprovabilityVerdict: 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. |
juliusmarminge
left a comment
There was a problem hiding this comment.
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.AzureDevOpsSourceControlProviderreadsremote.origin.urlthrough 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-originremotes. AzureDevOpsCli.executeappends--only-show-errors --output jsonto everyazcommand. 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/rawreadFilepath. We should use a shared Effect-native source-control process service with scoped processes, stream-first output, typed exit codes, and provider-specific errors. createPullRequestreads 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
originURL 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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: 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.
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.
There was a problem hiding this comment.
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
errorTexthelper duplicated across two files- Extracted the duplicated
errorTextfunction into a sharedvcsErrorTextutility atapps/server/src/vcs/vcsErrorText.tsand updated bothAzureDevOpsCli.tsandGitHubCli.tsto import from it.
- Extracted the duplicated
- ✅ Fixed: Azure DevOps checkout silently ignores
forceparameter- Added
force?: booleantoAzureDevOpsCliShape.checkoutPullRequestinput type and implemented force support by runninggit checkout --forcebeforeaz repos pr checkoutwhen the flag is set, matching the GitHub provider's behavior.
- Added
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.
juliusmarminge
left a comment
There was a problem hiding this comment.
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
AzureDevOpsSourceControlProviderintoSourceControlProviderRegistry, but it is still there. Provider routing still callsGitVcsDriver.readConfigValue("remote.origin.url")and falls back togit 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. SourceControlProviderShapestill passes onlycwdplus string selectors. Azure then relies onaz --detect trueandoriginassumptions (checkoutPullRequesthardcodes--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.createPullRequeststill 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.tsxhere versus a differentsourceControlPresentation.tsshape 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.
18d781b to
58afff8
Compare
There was a problem hiding this comment.
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.
- Fixed the test assertion to expect the @-prefixed file path (
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.
28541d8 to
09485ee
Compare
There was a problem hiding this comment.
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
bitbucketcase causes undefined return value- Added a
BitbucketIconcomponent and the missing"bitbucket"case to the switch ingetSourceControlPresentation, so the function now returns a validSourceControlPresentationfor Bitbucket providers instead ofundefined.
- Added a
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 linesYou can send follow-ups to the cloud agent here.
ae87f69 to
e9fd83e
Compare
There was a problem hiding this comment.
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.
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.
7aa00ff to
f4180a4
Compare
5937070 to
7b88c9b
Compare
There was a problem hiding this comment.
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 trueincheckoutPullRequestcommand- Added the missing
"--detect", "true"arguments to thecheckoutPullRequestcommand's args array, consistent with all otheraz repos prcommands.
- Added the missing
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 linesYou can send follow-ups to the cloud agent here.
7b88c9b to
a46a126
Compare
9f2089d to
605f6b9
Compare
fd50f50 to
a2fa18e
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated
sourceFromInputfunction 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.
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,
SourceControlProviderShapeYou can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit a2fa18e. Configure here.
a2fa18e to
1f5b53a
Compare
- 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
1f5b53a to
ccb500d
Compare


Summary
VcsDriver,VcsProcess, and registry abstractions.GitManager, server orchestration, and web UI logic to consume the new VCS/source-control APIs and updated reference models.Testing
bun fmtbun lintbun typecheckNote
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 aroundaz repos pr/az repos show) plus JSON decoding/normalization for PR records, and anAzureDevOpsSourceControlProviderthat 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), wiresAzureDevOpsCli.layerinto server + websocket RPC layers, updates tests for discovery/registry behavior, extends web PR reference parsing to acceptaz 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
AzureDevOpsCliservice in AzureDevOpsCli.ts that wrapsaz reposCLI commands to list/get/create PRs, read clone URLs, detect default branch, and checkout PRs.AzureDevOpsSourceControlProviderin AzureDevOpsSourceControlProvider.ts that maps Azure DevOps CLI operations to the provider-neutralSourceControlProviderinterface.SourceControlProviderRegistryand both HTTP and WebSocket server layers, replacing the previousunsupportedProviderstub.parseAzureDevOpsCheckoutReferencein pullRequestReference.ts to handle--id=42equals-style flags in checkout commands.azCLI with the Azure DevOps extension installed and an activeaz loginsession; missing or unauthenticated CLI surfaces structured errors but PR operations will fail.Macroscope summarized ccb500d.