diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index b8c66e9b745..c9341edb317 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -117,6 +117,7 @@ describe("DesktopShellEnvironment", () => { PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/secretive.sock", HOMEBREW_PREFIX: "/opt/homebrew", + AZURE_DEVOPS_EXT_PAT: "pat-from-login-shell", }); }, }); @@ -126,6 +127,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); + assert.equal(env.AZURE_DEVOPS_EXT_PAT, "pat-from-login-shell"); }), ); @@ -135,6 +137,7 @@ describe("DesktopShellEnvironment", () => { SHELL: "/bin/zsh", PATH: "/usr/bin", SSH_AUTH_SOCK: "/tmp/inherited.sock", + AZURE_DEVOPS_EXT_PAT: "inherited-pat", }; yield* runShellEnvironment({ @@ -144,11 +147,13 @@ describe("DesktopShellEnvironment", () => { envOutput({ PATH: "/opt/homebrew/bin:/usr/bin", SSH_AUTH_SOCK: "/tmp/login-shell.sock", + AZURE_DEVOPS_EXT_PAT: "pat-from-login-shell", }), }); assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock"); + assert.equal(env.AZURE_DEVOPS_EXT_PAT, "inherited-pat"); }), ); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 5627eec54de..d2d4fc783d8 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -78,6 +78,9 @@ const LOGIN_SHELL_ENV_NAMES = [ "XDG_CONFIG_HOME", "XDG_CURRENT_DESKTOP", "XDG_DATA_HOME", + // Azure DevOps CLI commonly receives its PAT from the user's login shell. + // Keep it available when the desktop app is launched outside a terminal. + "AZURE_DEVOPS_EXT_PAT", "XDG_RUNTIME_DIR", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE", @@ -456,6 +459,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix "HOMEBREW_REPOSITORY", "XDG_CONFIG_HOME", "XDG_DATA_HOME", + "AZURE_DEVOPS_EXT_PAT", "XDG_RUNTIME_DIR", "WAYLAND_DISPLAY", ] as const) { diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 1cd4b388552..34820bdaa30 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -19,6 +19,12 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ stderrTruncated: false, }); +const repositoryContext = { + organization: "acme", + project: "project", + repository: "repo", +} as const; + const mockRun = vi.fn(); const supportLayer = Layer.mergeAll( @@ -33,6 +39,16 @@ afterEach(() => { mockRun.mockReset(); }); +it("parses the Azure DevOps SSH clone URL used by Azure Repos", () => { + expect( + AzureDevOpsCli.parseAzureDevOpsRemoteUrl("git@ssh.dev.azure.com:v3/acme/project/repo"), + ).toEqual({ + organization: "acme", + project: "project", + repository: "repo", + }); +}); + describe("AzureDevOpsCli.layer", () => { it.effect("parses pull request view output", () => Effect.gen(function* () { @@ -92,6 +108,56 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("uses explicit organization context when showing an SSH-backed pull request", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.getPullRequest({ + cwd: "/repo", + reference: "42", + repositoryContext, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--organization", + "https://dev.azure.com/acme", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("builds a web URL when Azure returns only the pull request REST URL", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -188,6 +254,52 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("uses explicit repository arguments when Azure cannot detect an SSH remote", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "feature/source-control", + state: "open", + limit: 10, + repositoryContext: { + organization: "acme", + project: "project", + repository: "repo", + }, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--organization", + "https://dev.azure.com/acme", + "--project", + "project", + "--repository", + "repo", + "--source-branch", + "feature/source-control", + "--status", + "active", + "--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( @@ -210,7 +322,8 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; const result = yield* az.getRepositoryCloneUrls({ cwd: "/repo", - repository: "repo", + repository: "requested-repo", + repositoryContext: { ...repositoryContext, repository: "working-repo" }, }); assert.deepStrictEqual(result, { @@ -218,6 +331,25 @@ describe("AzureDevOpsCli.layer", () => { url: "https://dev.azure.com/acme/project/_git/repo", sshUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "show", + "--organization", + "https://dev.azure.com/acme", + "--project", + "project", + "--repository", + "requested-repo", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); }).pipe(Effect.provide(layer)), ); @@ -274,6 +406,52 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("uses explicit repository context when reading the default branch", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + 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", + defaultBranch: "refs/heads/main", + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const branch = yield* az.getDefaultBranch({ + cwd: "/repo", + repositoryContext, + }); + + assert.strictEqual(branch, "main"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "show", + "--organization", + "https://dev.azure.com/acme", + "--project", + "project", + "--repository", + "repo", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).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; @@ -286,6 +464,7 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", baseBranch: "main", headSelector: "feature/provider", + repositoryContext, title: "Provider PR", bodyFile, }); @@ -294,9 +473,19 @@ describe("AzureDevOpsCli.layer", () => { expect.objectContaining({ command: "az", cwd: "/repo", - args: expect.arrayContaining(["--description", `@${bodyFile}`]), + args: expect.arrayContaining([ + "--organization", + "https://dev.azure.com/acme", + "--project", + "project", + "--repository", + "repo", + "--description", + `@${bodyFile}`, + ]), }), ); + expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--detect"); expect(mockRun.mock.calls[0]?.[0].args).not.toContain("--output"); }).pipe(Effect.provide(layer)), ); @@ -319,8 +508,6 @@ describe("AzureDevOpsCli.layer", () => { "pr", "checkout", "--only-show-errors", - "--detect", - "true", "--id", "42", "--remote-name", @@ -332,6 +519,151 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("checks out an SSH-backed pull request without Azure remote auto-detection", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(processOutput(""))) + .mockReturnValueOnce(Effect.succeed(processOutput(""))) + .mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + repositoryContext, + }); + + expect(mockRun.mock.calls.map(([input]) => input)).toEqual([ + { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--organization", + "https://dev.azure.com/acme", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }, + { + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: ["fetch", "origin", "+refs/heads/feature/source-control"], + cwd: "/repo", + }, + { + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: ["checkout", "-B", "feature/source-control", "FETCH_HEAD"], + cwd: "/repo", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("checks out a pull request from an Azure DevOps fork repository", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 42, + title: "Add Azure provider", + sourceRefName: "refs/heads/feature/source-control", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { + repository: { + remoteUrl: "https://dev.azure.com/acme/fork-project/_git/fork-repo", + sshUrl: "git@ssh.dev.azure.com:v3/acme/fork-project/fork-repo", + }, + }, + _links: { + web: { + href: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + }, + }, + }), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(processOutput(""))) + .mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.checkoutPullRequest({ + cwd: "/repo", + reference: "42", + repositoryContext, + }); + + expect(mockRun.mock.calls.map(([input]) => input)).toEqual([ + { + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "show", + "--organization", + "https://dev.azure.com/acme", + "--id", + "42", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }, + { + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: [ + "fetch", + "git@ssh.dev.azure.com:v3/acme/fork-project/fork-repo", + "+refs/heads/feature/source-control", + ], + cwd: "/repo", + }, + { + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: ["checkout", "-B", "feature/source-control", "FETCH_HEAD"], + cwd: "/repo", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + it.effect("preserves VCS causes without copying upstream details into messages", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c..3f73148c52a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -21,6 +21,116 @@ import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +/** Identifies the Azure DevOps repository targeted by a CLI request. */ +export interface AzureDevOpsRepositoryContext { + readonly organization: string; + readonly project: string; + readonly repository: string; +} + +/** Decodes one URL path segment, returning null for malformed or empty values. */ +function decodeRemotePathSegment(segment: string): string | null { + try { + const decoded = decodeURIComponent(segment).trim(); + return decoded.length > 0 ? decoded : null; + } catch { + return null; + } +} + +/** Extracts repository coordinates from an Azure DevOps SSH or HTTPS clone URL. */ +export function parseAzureDevOpsRemoteUrl( + remoteUrl: string, +): AzureDevOpsRepositoryContext | undefined { + const trimmed = remoteUrl.trim(); + let segments: ReadonlyArray; + + if (trimmed.startsWith("git@")) { + const separatorIndex = trimmed.indexOf(":"); + if (separatorIndex <= "git@".length) { + return undefined; + } + + const host = trimmed.slice("git@".length, separatorIndex).toLowerCase(); + if (host !== "ssh.dev.azure.com") { + return undefined; + } + + segments = trimmed + .slice(separatorIndex + 1) + .split("/") + .filter((segment) => segment.length > 0); + } else { + try { + const url = new URL(trimmed); + if (url.hostname.toLowerCase() !== "dev.azure.com") { + return undefined; + } + segments = url.pathname.split("/").filter((segment) => segment.length > 0); + } catch { + return undefined; + } + } + + const isSshClonePath = segments[0]?.toLowerCase() === "v3"; + const isHttpClonePath = segments[2]?.toLowerCase() === "_git"; + const [organizationSegment, projectSegment, repositorySegment] = isSshClonePath + ? [segments[1], segments[2], segments[3]] + : isHttpClonePath + ? [segments[0], segments[1], segments[3]] + : []; + + const organization = organizationSegment ? decodeRemotePathSegment(organizationSegment) : null; + const project = projectSegment ? decodeRemotePathSegment(projectSegment) : null; + const repository = repositorySegment + ? decodeRemotePathSegment(repositorySegment.replace(/\.git$/iu, "")) + : null; + + return organization && project && repository ? { organization, project, repository } : undefined; +} + +/** Builds explicit Azure CLI repository arguments when remote detection is unreliable. */ +function repositoryDetectionArgs( + repositoryContext: AzureDevOpsRepositoryContext | undefined, +): ReadonlyArray { + return repositoryContext + ? [ + "--organization", + `https://dev.azure.com/${repositoryContext.organization}`, + "--project", + repositoryContext.project, + "--repository", + repositoryContext.repository, + ] + : ["--detect", "true"]; +} + +/** Builds the organization argument used by PR commands that do not accept a repository. */ +function repositoryOrganizationArgs( + repositoryContext: AzureDevOpsRepositoryContext | undefined, +): ReadonlyArray { + return repositoryContext + ? ["--organization", `https://dev.azure.com/${repositoryContext.organization}`] + : ["--detect", "true"]; +} + +/** Builds repository-show arguments while retaining the CLI's fallback repository selector. */ +function repositoryShowArgs( + repositoryContext: AzureDevOpsRepositoryContext | undefined, + repository: string, +): ReadonlyArray { + return repositoryContext + ? [ + "--organization", + `https://dev.azure.com/${repositoryContext.organization}`, + "--project", + repositoryContext.project, + "--repository", + repository, + ] + : ["--detect", "true", "--repository", repository]; +} + const azureDevOpsCommandErrorFields = { operation: Schema.Literal("execute"), command: Schema.Literal("az"), @@ -114,6 +224,26 @@ export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsGitCommandFailedError", + { + operation: Schema.Literal("checkoutPullRequest"), + stage: Schema.Literals(["fetch", "checkout"]), + command: Schema.Literal("git"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return `Git ${this.stage} command failed.`; + } + + override get message(): string { + return `Azure DevOps Git ${this.stage} command failed in ${this.operation}: ${this.detail}`; + } +} + const azureDevOpsDecodeErrorFields = { command: Schema.Literal("az"), cwd: Schema.String, @@ -180,6 +310,7 @@ export const AzureDevOpsCliError = Schema.Union([ AzureDevOpsCliAuthenticationError, AzureDevOpsPullRequestNotFoundError, AzureDevOpsCommandFailedError, + AzureDevOpsGitCommandFailedError, AzureDevOpsPullRequestListDecodeError, AzureDevOpsPullRequestDecodeError, AzureDevOpsRepositoryDecodeError, @@ -206,6 +337,7 @@ export class AzureDevOpsCli extends Context.Service< readonly listPullRequests: (input: { readonly cwd: string; readonly headSelector: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; readonly source?: SourceControlProvider.SourceControlRefSelector; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; @@ -214,11 +346,13 @@ export class AzureDevOpsCli extends Context.Service< readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; }) => Effect.Effect; readonly createRepository: (input: { @@ -231,6 +365,7 @@ export class AzureDevOpsCli extends Context.Service< readonly cwd: string; readonly baseBranch: string; readonly headSelector: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; readonly source?: SourceControlProvider.SourceControlRefSelector; readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; @@ -239,11 +374,13 @@ export class AzureDevOpsCli extends Context.Service< readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; + readonly repositoryContext?: AzureDevOpsRepositoryContext; readonly remoteName?: string; }) => Effect.Effect; } @@ -366,6 +503,68 @@ export const make = Effect.gen(function* () { args: [...input.args, "--only-show-errors", "--output", "json"], }); + /** Runs one git step of the SSH-safe pull-request checkout flow. */ + const runGit = (input: { + readonly cwd: string; + readonly stage: "fetch" | "checkout"; + readonly args: ReadonlyArray; + }) => + process + .run({ + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: input.args, + cwd: input.cwd, + }) + .pipe( + Effect.mapError( + (cause) => + new AzureDevOpsGitCommandFailedError({ + operation: "checkoutPullRequest", + stage: input.stage, + command: "git", + cwd: input.cwd, + argumentCount: input.args.length, + cause, + }), + ), + ); + + /** Loads a pull request using explicit organization context when available. */ + const getPullRequest: AzureDevOpsCli["Service"]["getPullRequest"] = (input) => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "show", + ...repositoryOrganizationArgs(input.repositoryContext), + "--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 AzureDevOpsPullRequestDecodeError({ + operation: "getPullRequest", + command: "az", + cwd: input.cwd, + outputLength: raw.length, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed(decoded.success); + }), + ), + ), + ); + return AzureDevOpsCli.of({ execute, listPullRequests: (input) => @@ -375,8 +574,7 @@ export const make = Effect.gen(function* () { "repos", "pr", "list", - "--detect", - "true", + ...repositoryDetectionArgs(input.repositoryContext), "--source-branch", SourceControlProvider.sourceBranch(input), "--status", @@ -408,44 +606,11 @@ export const make = Effect.gen(function* () { ), ), ), - 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 AzureDevOpsPullRequestDecodeError({ - operation: "getPullRequest", - command: "az", - cwd: input.cwd, - outputLength: raw.length, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed(decoded.success); - }), - ), - ), - ), + getPullRequest, getRepositoryCloneUrls: (input) => executeJson({ cwd: input.cwd, - args: ["repos", "show", "--detect", "true", "--repository", input.repository], + args: ["repos", "show", ...repositoryShowArgs(input.repositoryContext, input.repository)], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -491,8 +656,7 @@ export const make = Effect.gen(function* () { "pr", "create", "--only-show-errors", - "--detect", - "true", + ...repositoryDetectionArgs(input.repositoryContext), "--target-branch", input.target?.refName ?? input.baseBranch, "--source-branch", @@ -506,7 +670,7 @@ export const make = Effect.gen(function* () { getDefaultBranch: (input) => executeJson({ cwd: input.cwd, - args: ["repos", "show", "--detect", "true"], + args: ["repos", "show", ...repositoryDetectionArgs(input.repositoryContext)], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -514,22 +678,52 @@ export const make = Effect.gen(function* () { ), Effect.map((repo) => normalizeDefaultBranch(repo.defaultBranch)), ), - checkoutPullRequest: (input) => - execute({ + checkoutPullRequest: (input) => { + const reference = normalizeChangeRequestId(input.reference); + const remoteName = input.remoteName ?? "origin"; + + if (input.repositoryContext === undefined) { + return execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "checkout", + "--only-show-errors", + "--id", + reference, + "--remote-name", + remoteName, + ], + }).pipe(Effect.asVoid); + } + + return getPullRequest({ cwd: input.cwd, - args: [ - "repos", - "pr", - "checkout", - "--only-show-errors", - "--detect", - "true", - "--id", - normalizeChangeRequestId(input.reference), - "--remote-name", - input.remoteName ?? "origin", - ], - }).pipe(Effect.asVoid), + reference, + repositoryContext: input.repositoryContext, + }).pipe( + Effect.flatMap((pullRequest) => { + const branch = pullRequest.headRefName; + return Effect.gen(function* () { + yield* runGit({ + cwd: input.cwd, + stage: "fetch", + args: [ + "fetch", + pullRequest.sourceRepositoryUrl ?? remoteName, + `+refs/heads/${branch}`, + ], + }); + yield* runGit({ + cwd: input.cwd, + stage: "checkout", + args: ["checkout", "-B", branch, "FETCH_HEAD"], + }); + }); + }), + ); + }, }); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e7991..4b1a03c5858 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -12,6 +12,16 @@ function makeProvider(azure: Partial) ); } +const sshRepositoryContext = { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", +} as const; + it.effect("maps Azure DevOps PR summaries into provider-neutral change requests", () => Effect.gen(function* () { const provider = yield* makeProvider({ @@ -46,6 +56,94 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("passes the SSH remote repository context to Azure CLI PR lookup", () => + Effect.gen(function* () { + let getInput: + | Parameters[0] + | undefined; + const provider = yield* makeProvider({ + getPullRequest: (input) => { + getInput = input; + return Effect.succeed({ + number: 42, + title: "Azure provider", + url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + }); + }, + }); + + yield* provider.getChangeRequest({ + cwd: "/repo", + context: sshRepositoryContext, + reference: "42", + }); + + assert.deepStrictEqual(getInput?.repositoryContext, { + organization: "acme", + project: "project", + repository: "repo", + }); + }), +); + +it.effect("passes the SSH remote repository context to Azure CLI PR listing", () => + Effect.gen(function* () { + let listInput: + | Parameters[0] + | undefined; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: sshRepositoryContext, + headSelector: "feature/source-control", + state: "open", + }); + + assert.deepStrictEqual(listInput?.repositoryContext, { + organization: "acme", + project: "project", + repository: "repo", + }); + }), +); + +it.effect("passes the SSH remote repository context to Azure CLI checkout", () => + Effect.gen(function* () { + let checkoutInput: + | Parameters[0] + | undefined; + const provider = yield* makeProvider({ + checkoutPullRequest: (input) => { + checkoutInput = input; + return Effect.void; + }, + }); + + yield* provider.checkoutChangeRequest({ + cwd: "/repo", + context: sshRepositoryContext, + reference: "42", + }); + + assert.deepStrictEqual(checkoutInput?.repositoryContext, { + organization: "acme", + project: "project", + repository: "repo", + }); + assert.strictEqual(checkoutInput?.remoteName, "origin"); + }), +); + it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ @@ -100,6 +198,7 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => yield* provider.createChangeRequest({ cwd: "/repo", + context: sshRepositoryContext, baseRefName: "main", headSelector: "feature/provider", title: "Provider PR", @@ -110,6 +209,11 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => cwd: "/repo", baseBranch: "main", headSelector: "feature/provider", + repositoryContext: { + organization: "acme", + project: "project", + repository: "repo", + }, title: "Provider PR", bodyFile: "/tmp/body.md", }); @@ -118,17 +222,27 @@ it.effect("creates Azure DevOps PRs through provider-neutral input names", () => it.effect("uses Azure CLI repository detection for default branch lookup", () => Effect.gen(function* () { - let cwdInput: string | null = null; + let defaultBranchInput: + | Parameters[0] + | undefined; const provider = yield* makeProvider({ getDefaultBranch: (input) => { - cwdInput = input.cwd; + defaultBranchInput = input; return Effect.succeed("main"); }, }); - const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + const defaultBranch = yield* provider.getDefaultBranch({ + cwd: "/repo", + context: sshRepositoryContext, + }); assert.strictEqual(defaultBranch, "main"); - assert.strictEqual(cwdInput, "/repo"); + assert.strictEqual(defaultBranchInput?.cwd, "/repo"); + assert.deepStrictEqual(defaultBranchInput?.repositoryContext, { + organization: "acme", + project: "project", + repository: "repo", + }); }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac982927..f75b8df1947 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -58,6 +58,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly updatedAt: ChangeRequest["updatedAt"]; + readonly sourceRepositoryUrl?: string; }): ChangeRequest { return { provider: "azure-devops", @@ -68,10 +69,19 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.sourceRepositoryUrl !== undefined, }; } +/** Parses the repository coordinates carried by a provider source-control context. */ +function parseRepositoryContext(input: { + readonly context?: SourceControlProvider.SourceControlProviderContext; +}) { + return input.context + ? AzureDevOpsCli.parseAzureDevOpsRemoteUrl(input.context.remoteUrl) + : undefined; +} + export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; @@ -79,10 +89,12 @@ export const make = Effect.gen(function* () { kind: "azure-devops", listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); + const repositoryContext = parseRepositoryContext(input); return azure .listPullRequests({ cwd: input.cwd, headSelector: input.headSelector, + ...(repositoryContext ? { repositoryContext } : {}), ...(source !== undefined ? { source } : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), @@ -105,31 +117,41 @@ export const make = Effect.gen(function* () { ), ); }, - getChangeRequest: (input) => - azure.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), - ), - ), + getChangeRequest: (input) => { + const repositoryContext = parseRepositoryContext(input); + return azure + .getPullRequest({ + cwd: input.cwd, + reference: input.reference, + ...(repositoryContext ? { repositoryContext } : {}), + }) + .pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, createChangeRequest: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); + const repositoryContext = parseRepositoryContext(input); return azure .createPullRequest({ cwd: input.cwd, baseBranch: input.baseRefName, headSelector: input.headSelector, + ...(repositoryContext ? { repositoryContext } : {}), ...(source !== undefined ? { source } : {}), ...(input.target !== undefined ? { target: input.target } : {}), title: input.title, @@ -152,23 +174,31 @@ export const make = Effect.gen(function* () { ), ); }, - getRepositoryCloneUrls: (input) => - azure.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), - ), - ), + getRepositoryCloneUrls: (input) => { + const repositoryContext = parseRepositoryContext(input); + return azure + .getRepositoryCloneUrls({ + cwd: input.cwd, + repository: input.repository, + ...(repositoryContext ? { repositoryContext } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + command: error.command, + cwd: input.cwd, + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + input.repository, + ), + detail: error.detail, + cause: error, + }), + ), + ); + }, createRepository: (input) => azure.createRepository(input).pipe( Effect.mapError( @@ -186,25 +216,34 @@ export const make = Effect.gen(function* () { }), ), ), - getDefaultBranch: (input) => - azure.getDefaultBranch({ cwd: input.cwd }).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), - ), - ), - checkoutChangeRequest: (input) => - azure + getDefaultBranch: (input) => { + const repositoryContext = parseRepositoryContext(input); + return azure + .getDefaultBranch({ + cwd: input.cwd, + ...(repositoryContext ? { repositoryContext } : {}), + }) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + command: error.command, + cwd: input.cwd, + detail: error.detail, + cause: error, + }), + ), + ); + }, + checkoutChangeRequest: (input) => { + const repositoryContext = parseRepositoryContext(input); + return azure .checkoutPullRequest({ cwd: input.cwd, reference: input.reference, + ...(repositoryContext ? { repositoryContext } : {}), ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) .pipe( @@ -222,7 +261,8 @@ export const make = Effect.gen(function* () { cause: error, }), ), - ), + ); + }, }); }); diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index c059f6f0f9e..99bfeeee829 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly updatedAt: Option.Option; + /** Clone URL for a fork source repository, when Azure reports one. */ + readonly sourceRepositoryUrl?: string; } const AzureDevOpsPullRequestSchema = Schema.Struct({ @@ -32,6 +34,20 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ ), }), ), + forkSource: Schema.optional( + Schema.NullOr( + Schema.Struct({ + repository: Schema.optional( + Schema.NullOr( + Schema.Struct({ + remoteUrl: Schema.optional(Schema.NullOr(Schema.String)), + sshUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, status: Schema.String, @@ -132,6 +148,10 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const forkRepository = raw.forkSource?.repository; + const sourceRepositoryUrl = + trimOptionalString(forkRepository?.sshUrl) ?? trimOptionalString(forkRepository?.remoteUrl); + return { number: raw.pullRequestId, title: raw.title, @@ -142,6 +162,7 @@ function normalizeAzureDevOpsPullRequestRecord( updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), + ...(sourceRepositoryUrl ? { sourceRepositoryUrl } : {}), }; } diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..02edea83b37 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -52,6 +52,13 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { expect( detectSourceControlProviderFromRemoteUrl("https://dev.azure.com/org/project/_git/repo")?.kind, ).toBe("azure-devops"); + expect( + detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo"), + ).toEqual({ + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }); expect( detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, ).toBe("bitbucket"); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..72db09d6310 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -176,7 +176,9 @@ function isGitLabHost(host: string): boolean { } function isAzureDevOpsHost(host: string): boolean { - return host === "dev.azure.com" || host.endsWith(".visualstudio.com"); + return ( + host === "dev.azure.com" || host === "ssh.dev.azure.com" || host.endsWith(".visualstudio.com") + ); } function isBitbucketHost(host: string): boolean { @@ -212,7 +214,7 @@ export function detectSourceControlProviderFromRemoteUrl( return { kind: "azure-devops", name: "Azure DevOps", - baseUrl: toBaseUrl(host), + baseUrl: toBaseUrl(hostname === "ssh.dev.azure.com" ? "dev.azure.com" : host), }; }