diff --git a/src/main/azure-devops/azure-devops-api-request.test.ts b/src/main/azure-devops/azure-devops-api-request.test.ts new file mode 100644 index 00000000000..a804ebcadb0 --- /dev/null +++ b/src/main/azure-devops/azure-devops-api-request.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetAzureDevOpsPreviewApiVersionCache, + requestAzureDevOpsJson, + requestAzureDevOpsJsonAtBase +} from './azure-devops-api-request' +import type { AzureDevOpsRepoRef } from './repository-ref' + +const OLD_ENV = process.env +const OLD_FETCH = globalThis.fetch + +const SERVER_BASE = 'https://ado.example.com:8443/tfs/MyCollection' + +function previewRejection(): Response { + return new Response( + JSON.stringify({ + message: + 'The requested version "7.1" of the resource is under preview. The -preview flag must be supplied in the api-version for such requests. For example: "7.1-preview"', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) +} + +function serverRepoRef(): AzureDevOpsRepoRef { + return { + host: 'ado.example.com', + organization: null, + project: 'MyProject', + repository: 'my-repo', + apiBaseUrl: `${SERVER_BASE}/MyProject`, + webBaseUrl: `${SERVER_BASE}/MyProject/_git/my-repo` + } +} + +describe('Azure DevOps API request (STA-3494)', () => { + beforeEach(() => { + process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } + delete process.env.ORCA_AZURE_DEVOPS_API_BASE_URL + _resetAzureDevOpsPreviewApiVersionCache() + }) + + afterEach(() => { + process.env = OLD_ENV + globalThis.fetch = OLD_FETCH + }) + + it('retries with -preview when Azure DevOps Server rejects the api-version', async () => { + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return previewRejection() + } + return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } }) + }) as never + + await expect( + requestAzureDevOpsJsonAtBase(SERVER_BASE, '/_apis/connectionData') + ).resolves.toEqual({ authenticatedUser: { providerDisplayName: 'Server User' } }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + + it('remembers the -preview requirement per origin after the first rejection', async () => { + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return previewRejection() + } + return Response.json({ ok: true }) + }) as never + + const base = 'https://ado-sticky.example.com/tfs/MyCollection' + await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData') + await requestAzureDevOpsJsonAtBase(base, '/_apis/connectionData') + // First request learns the suffix; the second must not repeat the 400 round trip. + expect(versions).toEqual(['7.1', '7.1-preview', '7.1-preview']) + }) + + it('does not retry a 400 that is not a preview-version rejection', async () => { + const fetchMock = vi.fn(async () => + Response.json({ message: 'A project name is required.' }, { status: 400 }) + ) + globalThis.fetch = fetchMock as never + + await expect( + requestAzureDevOpsJsonAtBase( + 'https://ado-other.example.com/tfs/Coll', + '/_apis/connectionData' + ) + ).resolves.toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('uses the remote-derived project base for Git endpoints when the configured base shares its origin', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = SERVER_BASE + const paths: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + paths.push(new URL(String(input)).pathname) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + // Collection-level env base must not strip the project segment Git endpoints need. + expect(paths).toEqual(['/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo']) + }) + + it('keeps a cross-origin configured base URL as an override for Git endpoints', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'http://127.0.0.1:8123/acme/Project' + const origins: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + origins.push(new URL(String(input)).origin) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + expect(origins).toEqual(['http://127.0.0.1:8123']) + }) + + it('keeps a same-origin non-ancestor base URL as a Git endpoint override', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/rewrite/MyProject' + const paths: string[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + paths.push(new URL(String(input)).pathname) + return Response.json({ id: 'repo-guid' }) + }) as never + + await requestAzureDevOpsJson(serverRepoRef(), '/_apis/git/repositories/my-repo') + expect(paths).toEqual(['/rewrite/MyProject/_apis/git/repositories/my-repo']) + }) +}) diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 5fc8f256793..03c7faac3f4 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -3,6 +3,42 @@ import type { AzureDevOpsRepoRef } from './repository-ref' import { cancelUnreadResponseBody } from '../lib/unread-response-body' const REQUEST_TIMEOUT_MS = 5000 +const DEFAULT_API_VERSION = '7.1' + +// Why (STA-3494): on-prem Azure DevOps Server rejects versioned requests without +// the -preview suffix; remember which origins need it after the first rejection. +const previewApiVersionOrigins = new Set() + +/** @internal - exposed for tests only */ +export function _resetAzureDevOpsPreviewApiVersionCache(): void { + previewApiVersionOrigins.clear() +} + +export function markAzureDevOpsPreviewApiVersionOrigin(origin: string): void { + previewApiVersionOrigins.add(origin) +} + +export function azureDevOpsApiVersionForOrigin( + origin: string, + requested?: string | number +): string { + const version = String(requested ?? DEFAULT_API_VERSION) + return previewApiVersionOrigins.has(origin) && !version.endsWith('-preview') + ? `${version}-preview` + : version +} + +export function isAzureDevOpsPreviewVersionRejection(status: number | null, body: string): boolean { + if (status !== 400) { + return false + } + try { + const parsed = JSON.parse(body) as { typeKey?: unknown } | null + return parsed?.typeKey === 'VssInvalidPreviewVersionException' + } catch { + return false + } +} type AzureDevOpsAuthConfig = { apiBaseUrl: string | null @@ -52,9 +88,30 @@ function authHeaders(config: AzureDevOpsAuthConfig): Record { return {} } -function configuredApiBaseUrl(repo: AzureDevOpsRepoRef): string { +function isUrlPathAncestor(ancestor: string, descendant: string): boolean { + try { + const ancestorUrl = new URL(ancestor) + const descendantUrl = new URL(descendant) + const ancestorPath = ancestorUrl.pathname.replace(/\/+$/, '') + const descendantPath = descendantUrl.pathname.replace(/\/+$/, '') + return ( + ancestorUrl.origin === descendantUrl.origin && + (ancestorPath === descendantPath || descendantPath.startsWith(`${ancestorPath}/`)) + ) + } catch { + return false + } +} + +export function resolveAzureDevOpsGitApiBaseUrl(repo: AzureDevOpsRepoRef): string { const configured = getAzureDevOpsAuthConfig().apiBaseUrl - return configured ? normalizeAzureDevOpsApiBaseUrl(configured) : repo.apiBaseUrl + if (!configured) { + return repo.apiBaseUrl + } + const normalized = normalizeAzureDevOpsApiBaseUrl(configured) + // Why (STA-3494): a configured collection ancestor is the auth-probe URL; + // Git endpoints need the project-level base derived from the remote. + return isUrlPathAncestor(normalized, repo.apiBaseUrl) ? repo.apiBaseUrl : normalized } function apiUrl( @@ -63,13 +120,32 @@ function apiUrl( searchParams?: AzureDevOpsRequestOptions['searchParams'] ): URL { const url = new URL(`${baseUrl.replace(/\/+$/, '')}${path}`) - const params = { ...searchParams, 'api-version': searchParams?.['api-version'] ?? '7.1' } + const params = { + ...searchParams, + 'api-version': azureDevOpsApiVersionForOrigin(url.origin, searchParams?.['api-version']) + } for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, String(value)) } return url } +// Reads the body only for a 400 on a non-preview request; consumed either way. +async function shouldRetryWithPreviewApiVersion(url: URL, response: Response): Promise { + if (response.ok || response.status !== 400) { + return false + } + if (url.searchParams.get('api-version')?.endsWith('-preview')) { + return false + } + try { + const body = (await response.json()) as { typeKey?: string | null } | null + return body?.typeKey === 'VssInvalidPreviewVersionException' + } catch { + return false + } +} + export async function requestAzureDevOpsJsonAtBase( baseUrl: string, path: string, @@ -80,14 +156,21 @@ export async function requestAzureDevOpsJsonAtBase( throwOnFailure = false ): Promise { const config = getAzureDevOpsAuthConfig() - try { - const response = await fetch(apiUrl(baseUrl, path, options.searchParams), { + const doFetch = (url: URL): Promise => + fetch(url, { headers: { Accept: 'application/json', ...authHeaders(config) }, signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS) }) + try { + const url = apiUrl(baseUrl, path, options.searchParams) + let response = await doFetch(url) + if (await shouldRetryWithPreviewApiVersion(url, response)) { + markAzureDevOpsPreviewApiVersionOrigin(url.origin) + response = await doFetch(apiUrl(baseUrl, path, options.searchParams)) + } if (!response.ok) { await cancelUnreadResponseBody(response) if (throwOnFailure) { @@ -110,5 +193,10 @@ export function requestAzureDevOpsJson( options: AzureDevOpsRequestOptions = {}, throwOnFailure = false ): Promise { - return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) + return requestAzureDevOpsJsonAtBase( + resolveAzureDevOpsGitApiBaseUrl(repo), + path, + options, + throwOnFailure + ) } diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index b1f857d1bb1..81761bba12f 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -6,6 +6,7 @@ import { getAzureDevOpsPullRequestForBranchOrThrow, normalizeAzureDevOpsApiBaseUrl } from './client' +import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' @@ -39,6 +40,7 @@ describe('Azure DevOps client', () => { process.env = { ...OLD_ENV, ORCA_AZURE_DEVOPS_TOKEN: 'pat-token' } gitExecFileAsyncMock.mockReset() _resetAzureDevOpsRepoRefCache() + _resetAzureDevOpsPreviewApiVersionCache() __resetRepoDefaultBranchCacheForTests() }) @@ -65,6 +67,35 @@ describe('Azure DevOps client', () => { }) }) + it('authenticates against an on-prem Server that requires -preview api-versions (STA-3494)', async () => { + process.env.ORCA_AZURE_DEVOPS_API_BASE_URL = 'https://ado.example.com:8443/tfs/MyCollection' + const versions: (string | null)[] = [] + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + expect(url.pathname).toBe('/tfs/MyCollection/_apis/connectionData') + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return new Response( + JSON.stringify({ + message: 'The requested version "7.1" of the resource is under preview.', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + return Response.json({ authenticatedUser: { providerDisplayName: 'Server User' } }) + }) as never + + await expect(getAzureDevOpsAuthStatus()).resolves.toEqual({ + configured: true, + authenticated: true, + account: 'Server User', + baseUrl: 'https://ado.example.com:8443/tfs/MyCollection', + tokenConfigured: true + }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + it('resolves a PR for a branch through repository, PR, and status REST calls', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' diff --git a/src/main/azure-devops/pull-request-creation.test.ts b/src/main/azure-devops/pull-request-creation.test.ts index 02a4b4fb74a..7cd401747ff 100644 --- a/src/main/azure-devops/pull-request-creation.test.ts +++ b/src/main/azure-devops/pull-request-creation.test.ts @@ -3,6 +3,7 @@ import { createAzureDevOpsPullRequest, isAzureDevOpsReviewCreationAuthenticated } from './pull-request-creation' +import { _resetAzureDevOpsPreviewApiVersionCache } from './azure-devops-api-request' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' import { REMOTE_URL_PROBE_TIMEOUT_MS } from '../git/remote-url-probe' @@ -37,6 +38,7 @@ describe('Azure DevOps pull request creation', () => { stderr: '' }) _resetAzureDevOpsRepoRefCache() + _resetAzureDevOpsPreviewApiVersionCache() }) afterEach(() => { @@ -98,6 +100,77 @@ describe('Azure DevOps pull request creation', () => { expect(fetchMock).toHaveBeenCalledOnce() }) + it('retries PR creation with -preview when the Server rejects the api-version (STA-3494)', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo\n', + stderr: '' + }) + const versions: (string | null)[] = [] + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + expect(url.pathname).toBe( + '/tfs/MyCollection/MyProject/_apis/git/repositories/my-repo/pullRequests' + ) + versions.push(url.searchParams.get('api-version')) + if (!url.searchParams.get('api-version')?.endsWith('-preview')) { + return new Response( + JSON.stringify({ + message: 'The requested version "7.1" of the resource is under preview.', + typeKey: 'VssInvalidPreviewVersionException' + }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ) + } + return Response.json({ + pullRequestId: 51, + title: 'Server create', + status: 'active', + creationDate: '2026-06-01T00:00:00Z', + _links: { + web: { + href: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51' + } + } + }) + }) + globalThis.fetch = fetchMock as never + + await expect( + createAzureDevOpsPullRequest('/repo', { + provider: 'azure-devops', + base: 'main', + head: 'feature/server', + title: 'Server create', + body: 'Body' + }) + ).resolves.toEqual({ + ok: true, + number: 51, + url: 'https://ado.example.com:8443/tfs/MyCollection/MyProject/_git/my-repo/pullrequest/51' + }) + expect(versions).toEqual(['7.1', '7.1-preview']) + }) + + it('does not retry PR creation when only the error message names the preview exception', async () => { + const fetchMock = vi.fn(async () => + Response.json( + { message: 'Validation failed near VssInvalidPreviewVersionException' }, + { status: 400 } + ) + ) + globalThis.fetch = fetchMock as never + + await expect( + createAzureDevOpsPullRequest('/repo', { + provider: 'azure-devops', + base: 'main', + head: 'feature/azure', + title: 'Do not retry' + }) + ).resolves.toMatchObject({ ok: false, code: 'validation' }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + it('resolves Azure DevOps remotes through the SSH git provider', async () => { const remoteGit = { exec: vi.fn(async () => ({ diff --git a/src/main/azure-devops/pull-request-creation.ts b/src/main/azure-devops/pull-request-creation.ts index 7a3f25c13a3..e190e65a462 100644 --- a/src/main/azure-devops/pull-request-creation.ts +++ b/src/main/azure-devops/pull-request-creation.ts @@ -9,6 +9,12 @@ import { requestHostedReviewJson } from '../source-control/hosted-review-api-request' import { readHostedPullRequestTemplate } from '../source-control/pull-request-template' +import { + azureDevOpsApiVersionForOrigin, + isAzureDevOpsPreviewVersionRejection, + markAzureDevOpsPreviewApiVersionOrigin, + resolveAzureDevOpsGitApiBaseUrl +} from './azure-devops-api-request' import { getAzureDevOpsPullRequestForBranch } from './client' import { mapAzureDevOpsPullRequest, type RawAzureDevOpsPullRequest } from './pull-request-mappers' import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref' @@ -16,7 +22,6 @@ import { getAzureDevOpsRepoRef, type AzureDevOpsRepoRef } from './repository-ref const CREATE_REQUEST_TIMEOUT_MS = 60_000 type AzureDevOpsCreateAuthConfig = { - apiBaseUrl: string | null pat: string | null accessToken: string | null username: string | null @@ -27,16 +32,8 @@ function envValue(name: string): string | null { return value.length > 0 ? value : null } -function normalizeApiBaseUrl(value: string): string { - return value - .trim() - .replace(/\/+$/, '') - .replace(/\/_apis$/i, '') -} - function getAuthConfig(): AzureDevOpsCreateAuthConfig { return { - apiBaseUrl: envValue('ORCA_AZURE_DEVOPS_API_BASE_URL'), pat: envValue('ORCA_AZURE_DEVOPS_TOKEN') ?? envValue('ORCA_AZURE_DEVOPS_PAT'), accessToken: envValue('ORCA_AZURE_DEVOPS_ACCESS_TOKEN'), username: envValue('ORCA_AZURE_DEVOPS_USERNAME') @@ -60,13 +57,43 @@ function authHeaders(config: AzureDevOpsCreateAuthConfig): Record +): Promise { + const url = apiUrl(repo, path) + try { + return await requestHostedReviewJson( + url, + init, + CREATE_REQUEST_TIMEOUT_MS + ) + } catch (error) { + if ( + url.searchParams.get('api-version')?.endsWith('-preview') || + !(error instanceof HostedReviewApiRequestError) || + !isAzureDevOpsPreviewVersionRejection(error.status, error.message) + ) { + throw error + } + markAzureDevOpsPreviewApiVersionOrigin(url.origin) + return requestHostedReviewJson( + apiUrl(repo, path), + init, + CREATE_REQUEST_TIMEOUT_MS + ) + } +} + function encodePathSegment(value: string): string { return encodeURIComponent(value) } @@ -192,8 +219,9 @@ export async function createAzureDevOpsPullRequest( } try { - const raw = await requestHostedReviewJson( - apiUrl(repo, `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`), + const raw = await requestCreatePullRequest( + repo, + `/_apis/git/repositories/${encodePathSegment(repo.repository)}/pullRequests`, { method: 'POST', headers: { @@ -202,8 +230,7 @@ export async function createAzureDevOpsPullRequest( ...authHeaders(getAuthConfig()) }, body: JSON.stringify(requestBody) - }, - CREATE_REQUEST_TIMEOUT_MS + } ) const created = mapAzureDevOpsPullRequest(raw, 'neutral', repo.webBaseUrl) if (created) {