From 2e99448ba8af7dec1dc47e44c7162d229a570ece Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 3 Aug 2026 14:25:20 +0200 Subject: [PATCH 1/8] fix(shared): detect Azure DevOps SSH remotes --- packages/shared/src/sourceControl.test.ts | 7 +++++++ packages/shared/src/sourceControl.ts | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) 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), }; } From 4cbde3bbd35c54113b4e9c57afa794b10d5b04b1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 3 Aug 2026 14:51:53 +0200 Subject: [PATCH 2/8] fix(server): pass Azure repository context for SSH PRs --- .../src/sourceControl/AzureDevOpsCli.test.ts | 58 +++++++++++++ .../src/sourceControl/AzureDevOpsCli.ts | 84 ++++++++++++++++++- .../AzureDevOpsSourceControlProvider.test.ts | 35 ++++++++ .../AzureDevOpsSourceControlProvider.ts | 4 + 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 1cd4b388552..577fd165c02 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -33,6 +33,18 @@ afterEach(() => { mockRun.mockReset(); }); +it("parses the Azure DevOps SSH clone URL used by Azure Repos", () => { + expect( + AzureDevOpsCli.parseAzureDevOpsRemoteUrl( + "git@ssh.dev.azure.com:v3/ClubTidy/ClubTidy/ClubTidy.Web.BackOffice", + ), + ).toEqual({ + organization: "ClubTidy", + project: "ClubTidy", + repository: "ClubTidy.Web.BackOffice", + }); +}); + describe("AzureDevOpsCli.layer", () => { it.effect("parses pull request view output", () => Effect.gen(function* () { @@ -188,6 +200,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: "t3code/promo-referral-communications", + state: "open", + limit: 10, + repositoryContext: { + organization: "ClubTidy", + project: "ClubTidy", + repository: "ClubTidy.Web.BackOffice", + }, + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--organization", + "https://dev.azure.com/ClubTidy", + "--project", + "ClubTidy", + "--repository", + "ClubTidy.Web.BackOffice", + "--source-branch", + "t3code/promo-referral-communications", + "--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( diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c..22d938c4c64 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -21,6 +21,86 @@ import * as SourceControlProvider from "./SourceControlProvider.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +export interface AzureDevOpsRepositoryContext { + readonly organization: string; + readonly project: string; + readonly repository: string; +} + +function decodeRemotePathSegment(segment: string): string | null { + try { + const decoded = decodeURIComponent(segment).trim(); + return decoded.length > 0 ? decoded : null; + } catch { + return null; + } +} + +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; +} + +function repositoryDetectionArgs( + repositoryContext: AzureDevOpsRepositoryContext | undefined, +): ReadonlyArray { + return repositoryContext + ? [ + "--organization", + `https://dev.azure.com/${repositoryContext.organization}`, + "--project", + repositoryContext.project, + "--repository", + repositoryContext.repository, + ] + : ["--detect", "true"]; +} + const azureDevOpsCommandErrorFields = { operation: Schema.Literal("execute"), command: Schema.Literal("az"), @@ -206,6 +286,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; @@ -375,8 +456,7 @@ export const make = Effect.gen(function* () { "repos", "pr", "list", - "--detect", - "true", + ...repositoryDetectionArgs(input.repositoryContext), "--source-branch", SourceControlProvider.sourceBranch(input), "--status", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e7991..60e1420f56a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -46,6 +46,41 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +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: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/ClubTidy/ClubTidy/ClubTidy.Web.BackOffice", + }, + headSelector: "t3code/promo-referral-communications", + state: "open", + }); + + assert.deepStrictEqual(listInput?.repositoryContext, { + organization: "ClubTidy", + project: "ClubTidy", + repository: "ClubTidy.Web.BackOffice", + }); + }), +); + it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac982927..bf1a8c7f94f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -79,10 +79,14 @@ export const make = Effect.gen(function* () { kind: "azure-devops", listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); + const repositoryContext = input.context + ? AzureDevOpsCli.parseAzureDevOpsRemoteUrl(input.context.remoteUrl) + : undefined; return azure .listPullRequests({ cwd: input.cwd, headSelector: input.headSelector, + ...(repositoryContext ? { repositoryContext } : {}), ...(source !== undefined ? { source } : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), From 0eab33e1e9f6ca6948f30c8808b631c777945614 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 3 Aug 2026 15:24:50 +0200 Subject: [PATCH 3/8] fix(desktop): inherit Azure DevOps auth from login shell --- apps/desktop/src/shell/DesktopShellEnvironment.test.ts | 5 +++++ apps/desktop/src/shell/DesktopShellEnvironment.ts | 4 ++++ 2 files changed, 9 insertions(+) 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) { From dad8d99828b6c358b43a97f96bf1a5ad6383a42e Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Mon, 3 Aug 2026 16:13:28 +0200 Subject: [PATCH 4/8] test(server): use neutral Azure DevOps fixtures --- .../src/sourceControl/AzureDevOpsCli.test.ts | 26 +++++++++---------- .../AzureDevOpsSourceControlProvider.test.ts | 10 +++---- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 577fd165c02..cf63d16523a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -35,13 +35,11 @@ afterEach(() => { it("parses the Azure DevOps SSH clone URL used by Azure Repos", () => { expect( - AzureDevOpsCli.parseAzureDevOpsRemoteUrl( - "git@ssh.dev.azure.com:v3/ClubTidy/ClubTidy/ClubTidy.Web.BackOffice", - ), + AzureDevOpsCli.parseAzureDevOpsRemoteUrl("git@ssh.dev.azure.com:v3/acme/project/repo"), ).toEqual({ - organization: "ClubTidy", - project: "ClubTidy", - repository: "ClubTidy.Web.BackOffice", + organization: "acme", + project: "project", + repository: "repo", }); }); @@ -207,13 +205,13 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; yield* az.listPullRequests({ cwd: "/repo", - headSelector: "t3code/promo-referral-communications", + headSelector: "feature/source-control", state: "open", limit: 10, repositoryContext: { - organization: "ClubTidy", - project: "ClubTidy", - repository: "ClubTidy.Web.BackOffice", + organization: "acme", + project: "project", + repository: "repo", }, }); @@ -225,13 +223,13 @@ describe("AzureDevOpsCli.layer", () => { "pr", "list", "--organization", - "https://dev.azure.com/ClubTidy", + "https://dev.azure.com/acme", "--project", - "ClubTidy", + "project", "--repository", - "ClubTidy.Web.BackOffice", + "repo", "--source-branch", - "t3code/promo-referral-communications", + "feature/source-control", "--status", "active", "--top", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 60e1420f56a..55750d4350d 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -67,16 +67,16 @@ it.effect("passes the SSH remote repository context to Azure CLI PR listing", () baseUrl: "https://dev.azure.com", }, remoteName: "origin", - remoteUrl: "git@ssh.dev.azure.com:v3/ClubTidy/ClubTidy/ClubTidy.Web.BackOffice", + remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", }, - headSelector: "t3code/promo-referral-communications", + headSelector: "feature/source-control", state: "open", }); assert.deepStrictEqual(listInput?.repositoryContext, { - organization: "ClubTidy", - project: "ClubTidy", - repository: "ClubTidy.Web.BackOffice", + organization: "acme", + project: "project", + repository: "repo", }); }), ); From c665bcb41b57e787dc9ef4b666a3ec725ae94b22 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Tue, 4 Aug 2026 13:51:50 +0200 Subject: [PATCH 5/8] docs(server): document Azure DevOps helpers --- apps/server/src/sourceControl/AzureDevOpsCli.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 22d938c4c64..999691a4b7e 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -21,12 +21,14 @@ 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(); @@ -36,6 +38,7 @@ function decodeRemotePathSegment(segment: string): string | null { } } +/** Extracts repository coordinates from an Azure DevOps SSH or HTTPS clone URL. */ export function parseAzureDevOpsRemoteUrl( remoteUrl: string, ): AzureDevOpsRepositoryContext | undefined { @@ -86,6 +89,7 @@ export function parseAzureDevOpsRemoteUrl( 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 { From 7e694517b5a3b4610eda1e074b59e8838339d5b7 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Tue, 4 Aug 2026 14:10:45 +0200 Subject: [PATCH 6/8] fix(server): preserve Azure context across PR operations --- .../src/sourceControl/AzureDevOpsCli.test.ts | 211 +++++++++++++++++- .../src/sourceControl/AzureDevOpsCli.ts | 195 +++++++++++----- .../AzureDevOpsSourceControlProvider.test.ts | 105 +++++++-- .../AzureDevOpsSourceControlProvider.ts | 145 +++++++----- 4 files changed, 532 insertions(+), 124 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index cf63d16523a..e446375bfae 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( @@ -102,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( @@ -267,6 +323,7 @@ describe("AzureDevOpsCli.layer", () => { const result = yield* az.getRepositoryCloneUrls({ cwd: "/repo", repository: "repo", + repositoryContext, }); assert.deepStrictEqual(result, { @@ -274,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", + "repo", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); }).pipe(Effect.provide(layer)), ); @@ -330,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; @@ -342,6 +464,7 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", baseBranch: "main", headSelector: "feature/provider", + repositoryContext, title: "Provider PR", bodyFile, }); @@ -350,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)), ); @@ -375,8 +508,6 @@ describe("AzureDevOpsCli.layer", () => { "pr", "checkout", "--only-show-errors", - "--detect", - "true", "--id", "42", "--remote-name", @@ -388,6 +519,80 @@ 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", "feature/source-control"], + cwd: "/repo", + }, + { + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: ["pull", "origin", "feature/source-control"], + 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 999691a4b7e..e434ad2e33f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -105,6 +105,25 @@ function repositoryDetectionArgs( : ["--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 + ? repositoryDetectionArgs(repositoryContext) + : ["--detect", "true", "--repository", repository]; +} + const azureDevOpsCommandErrorFields = { operation: Schema.Literal("execute"), command: Schema.Literal("az"), @@ -198,6 +217,25 @@ export class AzureDevOpsCommandFailedError extends Schema.TaggedErrorClass()( + "AzureDevOpsGitCommandFailedError", + { + operation: Schema.Literal("checkoutPullRequest"), + command: Schema.Literal("git"), + cwd: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), + }, +) { + get detail(): string { + return "Git checkout command failed."; + } + + override get message(): string { + return `Azure DevOps CLI failed in ${this.operation}: ${this.detail}`; + } +} + const azureDevOpsDecodeErrorFields = { command: Schema.Literal("az"), cwd: Schema.String, @@ -264,6 +302,7 @@ export const AzureDevOpsCliError = Schema.Union([ AzureDevOpsCliAuthenticationError, AzureDevOpsPullRequestNotFoundError, AzureDevOpsCommandFailedError, + AzureDevOpsGitCommandFailedError, AzureDevOpsPullRequestListDecodeError, AzureDevOpsPullRequestDecodeError, AzureDevOpsRepositoryDecodeError, @@ -299,11 +338,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: { @@ -316,6 +357,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; @@ -324,11 +366,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; } @@ -451,6 +495,63 @@ 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 args: ReadonlyArray }) => + process + .run({ + operation: "AzureDevOpsCli.checkoutPullRequest", + command: "git", + args: input.args, + cwd: input.cwd, + }) + .pipe( + Effect.mapError( + (cause) => + new AzureDevOpsGitCommandFailedError({ + operation: "checkoutPullRequest", + 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) => @@ -492,44 +593,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) => @@ -575,8 +643,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", @@ -590,7 +657,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) => @@ -598,22 +665,44 @@ 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, + args: ["fetch", remoteName, `refs/heads/${branch}`], + }); + yield* runGit({ cwd: input.cwd, args: ["checkout", branch] }); + yield* runGit({ cwd: input.cwd, args: ["pull", remoteName, branch] }); + }); + }), + ); + }, }); }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 55750d4350d..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,40 @@ 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: @@ -60,15 +104,7 @@ it.effect("passes the SSH remote repository context to Azure CLI PR listing", () yield* provider.listChangeRequests({ cwd: "/repo", - context: { - provider: { - kind: "azure-devops", - name: "Azure DevOps", - baseUrl: "https://dev.azure.com", - }, - remoteName: "origin", - remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", - }, + context: sshRepositoryContext, headSelector: "feature/source-control", state: "open", }); @@ -81,6 +117,33 @@ it.effect("passes the SSH remote repository context to Azure CLI PR listing", () }), ); +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({ @@ -135,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", @@ -145,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", }); @@ -153,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 bf1a8c7f94f..feaf8f93154 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -72,6 +72,15 @@ function toChangeRequest(summary: { }; } +/** 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,9 +88,7 @@ export const make = Effect.gen(function* () { kind: "azure-devops", listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); - const repositoryContext = input.context - ? AzureDevOpsCli.parseAzureDevOpsRemoteUrl(input.context.remoteUrl) - : undefined; + const repositoryContext = parseRepositoryContext(input); return azure .listPullRequests({ cwd: input.cwd, @@ -109,31 +116,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, @@ -156,23 +173,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( @@ -190,25 +215,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( @@ -226,7 +260,8 @@ export const make = Effect.gen(function* () { cause: error, }), ), - ), + ); + }, }); }); From 595ca4d07a974761bf0ba53da8429e4b74a4afc1 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Tue, 4 Aug 2026 14:26:16 +0200 Subject: [PATCH 7/8] fix(server): handle Azure DevOps fork PR checkouts --- .../src/sourceControl/AzureDevOpsCli.test.ts | 83 +++++++++++++++++-- .../src/sourceControl/AzureDevOpsCli.ts | 20 +++-- .../AzureDevOpsSourceControlProvider.ts | 3 +- .../sourceControl/azureDevOpsPullRequests.ts | 21 +++++ 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index e446375bfae..34820bdaa30 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -322,8 +322,8 @@ describe("AzureDevOpsCli.layer", () => { const az = yield* AzureDevOpsCli.AzureDevOpsCli; const result = yield* az.getRepositoryCloneUrls({ cwd: "/repo", - repository: "repo", - repositoryContext, + repository: "requested-repo", + repositoryContext: { ...repositoryContext, repository: "working-repo" }, }); assert.deepStrictEqual(result, { @@ -342,7 +342,7 @@ describe("AzureDevOpsCli.layer", () => { "--project", "project", "--repository", - "repo", + "requested-repo", "--only-show-errors", "--output", "json", @@ -574,19 +574,90 @@ describe("AzureDevOpsCli.layer", () => { { operation: "AzureDevOpsCli.checkoutPullRequest", command: "git", - args: ["fetch", "origin", "refs/heads/feature/source-control"], + args: ["fetch", "origin", "+refs/heads/feature/source-control"], cwd: "/repo", }, { operation: "AzureDevOpsCli.checkoutPullRequest", command: "git", - args: ["checkout", "feature/source-control"], + 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: ["pull", "origin", "feature/source-control"], + args: ["checkout", "-B", "feature/source-control", "FETCH_HEAD"], cwd: "/repo", }, ]); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index e434ad2e33f..2808cbdb622 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -120,7 +120,14 @@ function repositoryShowArgs( repository: string, ): ReadonlyArray { return repositoryContext - ? repositoryDetectionArgs(repositoryContext) + ? [ + "--organization", + `https://dev.azure.com/${repositoryContext.organization}`, + "--project", + repositoryContext.project, + "--repository", + repository, + ] : ["--detect", "true", "--repository", repository]; } @@ -232,7 +239,7 @@ export class AzureDevOpsGitCommandFailedError extends Schema.TaggedErrorClass; + /** 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 } : {}), }; } From 43cd1cdfd2eb3eae693f67637b7020e243ca1009 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Tue, 4 Aug 2026 14:29:46 +0200 Subject: [PATCH 8/8] fix(server): identify Azure checkout failure stage --- .../src/sourceControl/AzureDevOpsCli.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 2808cbdb622..3f73148c52a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -228,6 +228,7 @@ export class AzureDevOpsGitCommandFailedError extends Schema.TaggedErrorClass }) => + const runGit = (input: { + readonly cwd: string; + readonly stage: "fetch" | "checkout"; + readonly args: ReadonlyArray; + }) => process .run({ operation: "AzureDevOpsCli.checkoutPullRequest", @@ -516,6 +521,7 @@ export const make = Effect.gen(function* () { (cause) => new AzureDevOpsGitCommandFailedError({ operation: "checkoutPullRequest", + stage: input.stage, command: "git", cwd: input.cwd, argumentCount: input.args.length, @@ -702,13 +708,18 @@ export const make = Effect.gen(function* () { return Effect.gen(function* () { yield* runGit({ cwd: input.cwd, + stage: "fetch", args: [ "fetch", pullRequest.sourceRepositoryUrl ?? remoteName, `+refs/heads/${branch}`, ], }); - yield* runGit({ cwd: input.cwd, args: ["checkout", "-B", branch, "FETCH_HEAD"] }); + yield* runGit({ + cwd: input.cwd, + stage: "checkout", + args: ["checkout", "-B", branch, "FETCH_HEAD"], + }); }); }), );