From b346c7a646f9fdfa0c870f345f788c95db3f5e71 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 17 Jul 2025 15:38:22 +0200 Subject: [PATCH 1/9] Adds Azure DevOps Server as a supported integration (#4478, #4516) --- docs/telemetry-events.md | 14 +- package.json | 2 +- pnpm-lock.yaml | 10 +- src/autolinks/autolinksProvider.ts | 10 +- src/constants.integrations.ts | 10 ++ src/git/remotes/remoteProviders.ts | 2 + .../authentication/azureDevOps.ts | 8 +- .../integrationAuthenticationService.ts | 6 + .../integrations/authentication/models.ts | 3 + src/plus/integrations/integrationService.ts | 40 +++++ .../integrations/models/gitHostIntegration.ts | 1 + .../integrations/providers/azure/azure.ts | 148 +++++++++-------- .../integrations/providers/azureDevOps.ts | 156 +++++++++++------- .../providers/bitbucket/bitbucket.ts | 4 +- src/plus/integrations/providers/models.ts | 14 ++ .../integrations/providers/providersApi.ts | 71 ++++++-- src/plus/integrations/providers/utils.ts | 1 + .../utils/-webview/integration.utils.ts | 4 +- src/plus/launchpad/enrichmentService.ts | 1 + src/plus/startWork/startWork.ts | 2 + 20 files changed, 350 insertions(+), 157 deletions(-) diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 85cd10d9a9dca..a9947a9a21270 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -590,7 +590,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' } ``` @@ -601,7 +601,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' } ``` @@ -612,7 +612,7 @@ void ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' } ``` @@ -623,7 +623,7 @@ void ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' } ``` @@ -657,7 +657,7 @@ or when connection refresh is skipped due to being a non-cloud session ```typescript { - 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello' + 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello' } ``` @@ -1800,7 +1800,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello', // @deprecated: true 'remoteProviders.key': string } @@ -1813,7 +1813,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello', // @deprecated: true 'remoteProviders.key': string } diff --git a/package.json b/package.json index e5fb67b164457..0a92468280b85 100644 --- a/package.json +++ b/package.json @@ -24596,7 +24596,7 @@ }, "dependencies": { "@gitkraken/gitkraken-components": "13.0.0-vnext.8", - "@gitkraken/provider-apis": "0.28.5", + "@gitkraken/provider-apis": "0.29.6", "@gitkraken/shared-web-components": "0.1.1-rc.15", "@gk-nzaytsev/fast-string-truncated-width": "1.1.0", "@lit-labs/signals": "0.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b637d1c8be2fa..055d5934ab3a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: 13.0.0-vnext.8 version: 13.0.0-vnext.8(@types/react@19.0.12)(react@19.0.0) '@gitkraken/provider-apis': - specifier: 0.28.5 - version: 0.28.5(encoding@0.1.13) + specifier: 0.29.6 + version: 0.29.6(encoding@0.1.13) '@gitkraken/shared-web-components': specifier: 0.1.1-rc.15 version: 0.1.1-rc.15 @@ -648,8 +648,8 @@ packages: peerDependencies: react: 19.0.0 - '@gitkraken/provider-apis@0.28.5': - resolution: {integrity: sha512-6tNeqaFGf0u1Xvj8W8oP3dX6kEWQgf6zIghDyRPzR6P/FvTdpU8TfiGy0FVFh5AB1puMf59hMJswasIOkauxCA==} + '@gitkraken/provider-apis@0.29.6': + resolution: {integrity: sha512-aRgR7lL6MxnCFtbbNB5AJuQVRGBy6nJlZE+Yn0FZ/G8QrqgxhBkexVtG0ji/wiq2HmQ4DxNk6eo3xMfYqoTq6w==} engines: {node: '>= 14'} '@gitkraken/shared-web-components@0.1.1-rc.15': @@ -6510,7 +6510,7 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@gitkraken/provider-apis@0.28.5(encoding@0.1.13)': + '@gitkraken/provider-apis@0.29.6(encoding@0.1.13)': dependencies: js-base64: 3.7.5 node-fetch: 2.7.0(encoding@0.1.13) diff --git a/src/autolinks/autolinksProvider.ts b/src/autolinks/autolinksProvider.ts index 64f3ce0b818d9..0e8f4a9527b1d 100644 --- a/src/autolinks/autolinksProvider.ts +++ b/src/autolinks/autolinksProvider.ts @@ -5,14 +5,17 @@ import type { IntegrationIds } from '../constants.integrations'; import { IssuesCloudHostIntegrationId } from '../constants.integrations'; import type { Container } from '../container'; import type { GitRemote } from '../git/models/remote'; -import type { RemoteProviderId } from '../git/remotes/remoteProvider'; +import type { RemoteProvider, RemoteProviderId } from '../git/remotes/remoteProvider'; import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/-webview/icons'; import type { ConfiguredIntegrationsChangeEvent } from '../plus/integrations/authentication/configuredIntegrationService'; import type { GitHostIntegration } from '../plus/integrations/models/gitHostIntegration'; import type { Integration } from '../plus/integrations/models/integration'; import { IntegrationBase } from '../plus/integrations/models/integration'; import type { IssuesIntegration } from '../plus/integrations/models/issuesIntegration'; -import { convertRemoteProviderIdToIntegrationId } from '../plus/integrations/utils/-webview/integration.utils'; +import { + convertRemoteProviderIdToIntegrationId, + getIntegrationIdForRemote, +} from '../plus/integrations/utils/-webview/integration.utils'; import { configuration } from '../system/-webview/configuration'; import { fromNow } from '../system/date'; import { debug } from '../system/decorators/log'; @@ -227,7 +230,8 @@ export class AutolinksProvider implements Disposable { : // TODO: Tighten the typing on ProviderReference to be specific to a remote provider, and then have a separate "integration" property (on autolinks and elsewhere) // that is of a new type IntegrationReference specific to integrations. Otherwise, make remote provider ids line up directly with integration ids. // Either way, this converting/casting hackery needs to go away. - convertRemoteProviderIdToIntegrationId(link.provider.id as RemoteProviderId); + (getIntegrationIdForRemote(link.provider as RemoteProvider) ?? + convertRemoteProviderIdToIntegrationId(link.provider.id as RemoteProviderId)); if (integrationId == null) { // Fall back to the old logic assuming that integration id might be saved as provider id. // TODO: it should be removed when we put providers and integrations in order. Conversation: https://github.com/gitkraken/vscode-gitlens/pull/3996#discussion_r1936422826 diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 2ba8648bd77a0..06a1d31fdbd26 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -11,6 +11,7 @@ export enum GitSelfManagedHostIntegrationId { CloudGitHubEnterprise = 'cloud-github-enterprise', GitLabSelfHosted = 'gitlab-self-hosted', CloudGitLabSelfHosted = 'cloud-gitlab-self-hosted', + AzureDevOpsServer = 'azure-devops-server', } export enum IssuesCloudHostIntegrationId { @@ -21,6 +22,7 @@ export enum IssuesCloudHostIntegrationId { export type CloudGitSelfManagedHostIntegrationIds = | GitSelfManagedHostIntegrationId.CloudGitHubEnterprise | GitSelfManagedHostIntegrationId.BitbucketServer + | GitSelfManagedHostIntegrationId.AzureDevOpsServer | GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted; export type GitHostIntegrationIds = GitCloudHostIntegrationId | GitSelfManagedHostIntegrationId; @@ -35,6 +37,7 @@ export const supportedOrderedCloudIntegrationIds = [ GitCloudHostIntegrationId.GitLab, GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted, GitCloudHostIntegrationId.AzureDevOps, + GitSelfManagedHostIntegrationId.AzureDevOpsServer, GitCloudHostIntegrationId.Bitbucket, GitSelfManagedHostIntegrationId.BitbucketServer, IssuesCloudHostIntegrationId.Jira, @@ -92,6 +95,13 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ supports: ['prs', 'issues'], requiresPro: true, }, + { + id: GitSelfManagedHostIntegrationId.AzureDevOpsServer, + name: 'Azure DevOps Server', + icon: 'gl-provider-azdo', + supports: ['prs', 'issues'], + requiresPro: true, + }, { id: GitCloudHostIntegrationId.Bitbucket, name: 'Bitbucket', diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index b72b6bc4276f1..3e850608156ca 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -92,6 +92,8 @@ const cloudProviderCreatorsMap: Record< path: string, scheme: string | undefined, ) => new BitbucketServerRemote(container, domain, path, cleanProtocol(scheme)), + [GitSelfManagedHostIntegrationId.AzureDevOpsServer]: (container: Container, domain: string, path: string) => + new AzureDevOpsRemote(container, domain, path), }; const dirtyProtocolPattern = /(\w+)\W*/; diff --git a/src/plus/integrations/authentication/azureDevOps.ts b/src/plus/integrations/authentication/azureDevOps.ts index 43b6322bf6126..aed7adabd5201 100644 --- a/src/plus/integrations/authentication/azureDevOps.ts +++ b/src/plus/integrations/authentication/azureDevOps.ts @@ -1,4 +1,4 @@ -import { GitCloudHostIntegrationId } from '../../../constants.integrations'; +import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId } from '../../../constants.integrations'; import { CloudIntegrationAuthenticationProvider } from './integrationAuthenticationProvider'; export class AzureDevOpsAuthenticationProvider extends CloudIntegrationAuthenticationProvider { @@ -6,3 +6,9 @@ export class AzureDevOpsAuthenticationProvider extends CloudIntegrationAuthentic return GitCloudHostIntegrationId.AzureDevOps; } } + +export class AzureDevOpsServerAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): GitSelfManagedHostIntegrationId.AzureDevOpsServer { + return GitSelfManagedHostIntegrationId.AzureDevOpsServer; + } +} diff --git a/src/plus/integrations/authentication/integrationAuthenticationService.ts b/src/plus/integrations/authentication/integrationAuthenticationService.ts index d5fdf30451bdb..a5ca2de650bea 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationService.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationService.ts @@ -44,6 +44,7 @@ export class IntegrationAuthenticationService implements Disposable { supports(providerId: string): boolean { switch (providerId) { case GitCloudHostIntegrationId.AzureDevOps: + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: case GitCloudHostIntegrationId.Bitbucket: case GitSelfManagedHostIntegrationId.GitHubEnterprise: case GitCloudHostIntegrationId.GitLab: @@ -67,6 +68,11 @@ export class IntegrationAuthenticationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './azureDevOps') ).AzureDevOpsAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './azureDevOps') + ).AzureDevOpsServerAuthenticationProvider(this.container, this, this.configuredIntegrationService); + break; case GitCloudHostIntegrationId.Bitbucket: provider = new ( await import(/* webpackChunkName: "integrations" */ './bitbucket') diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index c8cfd462b140f..d7f10b099d9de 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -50,6 +50,7 @@ export type CloudIntegrationType = | 'bitbucket' | 'bitbucketServer' | 'azure' + | 'azureDevopsServer' | 'githubEnterprise' | 'gitlabSelfHosted'; @@ -77,6 +78,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationIds } bitbucket: GitCloudHostIntegrationId.Bitbucket, bitbucketServer: GitSelfManagedHostIntegrationId.BitbucketServer, azure: GitCloudHostIntegrationId.AzureDevOps, + azureDevopsServer: GitSelfManagedHostIntegrationId.AzureDevOpsServer, }; export const toCloudIntegrationType: { [key in IntegrationIds]: CloudIntegrationType | undefined } = { @@ -86,6 +88,7 @@ export const toCloudIntegrationType: { [key in IntegrationIds]: CloudIntegration [GitCloudHostIntegrationId.GitHub]: 'github', [GitCloudHostIntegrationId.Bitbucket]: 'bitbucket', [GitCloudHostIntegrationId.AzureDevOps]: 'azure', + [GitSelfManagedHostIntegrationId.AzureDevOpsServer]: 'azureDevopsServer', [GitSelfManagedHostIntegrationId.CloudGitHubEnterprise]: 'githubEnterprise', [GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted]: 'gitlabSelfHosted', [GitSelfManagedHostIntegrationId.BitbucketServer]: 'bitbucketServer', diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index af48c5c77fa6d..52cdcd578f090 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -486,6 +486,46 @@ export class IntegrationService implements Disposable { ) as GitHostIntegration as IntegrationById; break; + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: + if (domain == null) { + integration = this.findCachedById(id); + // return immediately in order to not to cache it after the "switch" block: + if (integration != null) return integration; + + const configured = this.getConfiguredLite(GitSelfManagedHostIntegrationId.AzureDevOpsServer); + if (configured.length) { + const { domain: configuredDomain } = configured[0]; + if (configuredDomain == null) throw new Error(`Domain is required for '${id}' integration`); + + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps') + ).AzureDevOpsServerIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + this._onDidChangeIntegrationConnection, + configuredDomain, + ) as GitHostIntegration as IntegrationById; + + // assign domain because it's part of caching key: + domain = configuredDomain; + break; + } + + return undefined; + } + + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps') + ).AzureDevOpsServerIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + this._onDidChangeIntegrationConnection, + domain, + ) as GitHostIntegration as IntegrationById; + break; + case IssuesCloudHostIntegrationId.Jira: integration = new ( await import(/* webpackChunkName: "integrations" */ './providers/jira') diff --git a/src/plus/integrations/models/gitHostIntegration.ts b/src/plus/integrations/models/gitHostIntegration.ts index 211e7255835ed..0b58fbbeaf97c 100644 --- a/src/plus/integrations/models/gitHostIntegration.ts +++ b/src/plus/integrations/models/gitHostIntegration.ts @@ -381,6 +381,7 @@ export abstract class GitHostIntegration< await Promise.all( projectInputs.map(async projectInput => { const results = await api.getIssuesForAzureProject( + providerId, projectInput.namespace, projectInput.project, { diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts index 61cf2fe6d791e..bbc38572debab 100644 --- a/src/plus/integrations/providers/azure/azure.ts +++ b/src/plus/integrations/providers/azure/azure.ts @@ -15,7 +15,7 @@ import { } from '../../../../errors'; import type { UnidentifiedAuthor } from '../../../../git/models/author'; import type { Issue } from '../../../../git/models/issue'; -import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; @@ -135,7 +135,7 @@ export class AzureDevOpsApi implements Disposable { provider, token, baseUrl, - `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullrequestquery?api-version=7.1`, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullrequestquery?api-version=4.1`, { method: 'POST', body: JSON.stringify({ @@ -181,89 +181,97 @@ export class AzureDevOpsApi implements Disposable { id: string, options: { baseUrl: string; + type?: IssueOrPullRequestType; }, ): Promise { const scope = getLogScope(); const [projectName, _, repoName] = repo.split('/'); - try { - // Try to get the Work item (wit) first with specific fields - const issueResult = await this.request( - provider, - token, - options?.baseUrl, - `${owner}/${projectName}/_apis/wit/workItems/${id}`, - { - method: 'GET', - }, - scope, - ); - - if (issueResult != null) { - const issueType = issueResult.fields['System.WorkItemType']; - const state = issueResult.fields['System.State']; - const stateCategory = await this.getWorkItemStateCategory( - issueType, - state, + if (options?.type === undefined || options?.type === 'issue') { + try { + // Try to get the Work item (wit) first with specific fields + const issueResult = await this.request( provider, token, - owner, - projectName, - options, + options?.baseUrl, + `${owner}/${projectName}/_apis/wit/workItems/${id}`, + { + method: 'GET', + }, + scope, ); - return { - id: issueResult.id.toString(), - type: 'issue', - nodeId: issueResult.id.toString(), - provider: provider, - createdDate: new Date(issueResult.fields['System.CreatedDate']), - updatedDate: new Date(issueResult.fields['System.ChangedDate']), - state: azureWorkItemsStateCategoryToState(stateCategory), - closed: isClosedAzureWorkItemStateCategory(stateCategory), - title: issueResult.fields['System.Title'], - url: issueResult._links.html.href, - }; - } - } catch (ex) { - if (ex.original?.status !== 404) { - Logger.error(ex, scope); - return undefined; + if (issueResult != null) { + const issueType = issueResult.fields['System.WorkItemType']; + const state = issueResult.fields['System.State']; + const stateCategory = await this.getWorkItemStateCategory( + issueType, + state, + provider, + token, + owner, + projectName, + options, + ); + + return { + id: issueResult.id.toString(), + type: 'issue', + nodeId: issueResult.id.toString(), + provider: provider, + createdDate: new Date(issueResult.fields['System.CreatedDate']), + updatedDate: new Date(issueResult.fields['System.ChangedDate']), + state: azureWorkItemsStateCategoryToState(stateCategory), + closed: isClosedAzureWorkItemStateCategory(stateCategory), + title: issueResult.fields['System.Title'], + url: issueResult._links.html.href, + }; + } + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } } } - try { - const prResult = await this.request( - provider, - token, - options?.baseUrl, - `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests/${id}`, - { - method: 'GET', - }, - scope, - ); + if (options?.type === undefined || options?.type === 'pullrequest') { + try { + const prResult = await this.request( + provider, + token, + options?.baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests/${id}`, + { + method: 'GET', + }, + scope, + ); - if (prResult != null) { - return { - id: prResult.pullRequestId.toString(), - type: 'pullrequest', - nodeId: prResult.pullRequestId.toString(), // prResult.artifactId maybe? - provider: provider, - createdDate: new Date(prResult.creationDate), - updatedDate: new Date(prResult.creationDate), - state: azurePullRequestStatusToState(prResult.status), - closed: isClosedAzurePullRequestStatus(prResult.status), - title: prResult.title, - url: getAzurePullRequestWebUrl(prResult), - }; - } + if (prResult != null) { + return { + id: prResult.pullRequestId.toString(), + type: 'pullrequest', + nodeId: prResult.pullRequestId.toString(), // prResult.artifactId maybe? + provider: provider, + createdDate: new Date(prResult.creationDate), + updatedDate: new Date(prResult.creationDate), + state: azurePullRequestStatusToState(prResult.status), + closed: isClosedAzurePullRequestStatus(prResult.status), + title: prResult.title, + url: getAzurePullRequestWebUrl(prResult), + }; + } - return undefined; - } catch (ex) { - Logger.error(ex, scope); - return undefined; + return undefined; + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } } + return undefined; } @debug({ args: { 0: p => p.name, 1: '' } }) diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index 46cea2f259f48..b50478e2a914a 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -1,16 +1,20 @@ -import type { AuthenticationSession, CancellationToken } from 'vscode'; +import type { AuthenticationSession, CancellationToken, EventEmitter } from 'vscode'; import { window } from 'vscode'; -import { GitCloudHostIntegrationId } from '../../../constants.integrations'; +import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId } from '../../../constants.integrations'; +import type { Container } from '../../../container'; import type { Account, UnidentifiedAuthor } from '../../../git/models/author'; import type { DefaultBranch } from '../../../git/models/defaultBranch'; import type { Issue, IssueShape } from '../../../git/models/issue'; -import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; import { getSettledValue } from '../../../system/promise'; import { base64 } from '../../../system/string'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; +import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService'; +import type { IntegrationConnectionChangeEvent } from '../integrationService'; import { GitHostIntegration } from '../models/gitHostIntegration'; +import type { IntegrationKey } from '../models/integration'; import type { AzureOrganizationDescriptor, AzureProjectDescriptor, @@ -20,24 +24,22 @@ import type { } from './azure/models'; import type { ProviderPullRequest, ProviderRepository } from './models'; import { fromProviderIssue, fromProviderPullRequest, providersMetadata } from './models'; - -const metadata = providersMetadata[GitCloudHostIntegrationId.AzureDevOps]; -const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); - -export class AzureDevOpsIntegration extends GitHostIntegration< - GitCloudHostIntegrationId.AzureDevOps, - AzureRepositoryDescriptor -> { - readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; - readonly id = GitCloudHostIntegrationId.AzureDevOps; - protected readonly key = this.id; - readonly name: string = 'Azure DevOps'; - get domain(): string { - return metadata.domain; - } - - protected get apiBaseUrl(): string { - return 'https://dev.azure.com'; +import type { ProvidersApi } from './providersApi'; + +export abstract class AzureDevOpsIntegrationBase< + TIntegrationId extends GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, + TRepositoryDescriptor extends AzureRepositoryDescriptor = AzureRepositoryDescriptor, +> extends GitHostIntegration { + protected abstract get apiBaseUrl(): string; + protected getApiOptions( + accessToken: string, + doNotConvertToPat: boolean = false, + ): { accessToken: string; isPAT: boolean; baseUrl?: string } { + const usePat = !doNotConvertToPat; + return { + accessToken: usePat ? convertTokentoPAT(accessToken) : accessToken, + isPAT: usePat, + }; } private _accounts: Map | undefined; @@ -49,7 +51,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< const cachedAccount = this._accounts.get(accessToken); if (cachedAccount == null) { const api = await this.getProvidersApi(); - const user = await api.getCurrentUser(this.id, { accessToken: accessToken }); + const user = await api.getCurrentUser(this.id, this.getApiOptions(accessToken, true)); this._accounts.set( accessToken, user @@ -82,10 +84,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< const account = await this.getProviderCurrentAccount(session); if (account?.id == null) return undefined; - const resources = await api.getAzureResourcesForUser(account.id, { - accessToken: convertTokentoPAT(accessToken), - isPAT: true, - }); + const resources = await api.getAzureResourcesForUser(account.id, this.id, this.getApiOptions(accessToken)); this._organizations.set( accessToken, resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined, @@ -121,10 +120,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< const azureProjects = ( await Promise.allSettled( resourcesWithoutProjects.map(resource => - api.getAzureProjectsForResource(resource.name, { - accessToken: convertTokentoPAT(accessToken), - isPAT: true, - }), + api.getAzureProjectsForResource(resource.name, this.id, this.getApiOptions(accessToken)), ), ) ) @@ -170,10 +166,12 @@ export class AzureDevOpsIntegration extends GitHostIntegration< await Promise.all( projects.map(async project => { const repos = ( - await api.getReposForAzureProject(project.resourceName, project.name, { - accessToken: convertTokentoPAT(accessToken), - isPAT: true, - }) + await api.getReposForAzureProject( + project.resourceName, + project.name, + this.id, + this.getApiOptions(accessToken), + ) )?.values; if (repos != null && repos.length > 0) { descriptors.set( @@ -210,8 +208,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< try { const merged = await api.mergePullRequest(this.id, pr, { ...options, - accessToken: convertTokentoPAT(accessToken), - isPAT: true, + ...this.getApiOptions(accessToken), }); return merged; } catch (ex) { @@ -220,7 +217,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< } } - private async showMergeErrorMessage(ex: Error) { + protected async showMergeErrorMessage(ex: Error): Promise { await window.showErrorMessage( `${ex.message}. Check branch policies, and ensure you have the necessary permissions to merge the pull request.`, ); @@ -267,9 +264,11 @@ export class AzureDevOpsIntegration extends GitHostIntegration< { accessToken }: AuthenticationSession, repo: AzureRepositoryDescriptor, id: string, + type: undefined | IssueOrPullRequestType, ): Promise { return (await this.container.azure)?.getIssueOrPullRequest(this, accessToken, repo.owner, repo.name, id, { baseUrl: this.apiBaseUrl, + type: type, }); } @@ -332,10 +331,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< const api = await this.getProvidersApi(); if (this._session == null) return undefined; - return api.getRepo(this.id, repo.owner, repo.name, repo.project, { - accessToken: convertTokentoPAT(this._session.accessToken), - isPAT: true, - }); + return api.getRepo(this.id, repo.owner, repo.name, repo.project, this.getApiOptions(this._session.accessToken)); } protected override async getProviderRepositoryMetadata( @@ -373,16 +369,14 @@ export class AzureDevOpsIntegration extends GitHostIntegration< const projectInputs = projects.map(p => ({ namespace: p.resourceName, project: p.name })); const assignedPrs = ( - await api.getPullRequestsForAzureProjects(projectInputs, { - accessToken: convertTokentoPAT(session.accessToken), - isPAT: true, + await api.getPullRequestsForAzureProjects(projectInputs, this.id, { + ...this.getApiOptions(session.accessToken), assigneeLogins: [user.username], }) )?.map(pr => this.fromAzureProviderPullRequest(pr, repoDescriptors, projects)); const authoredPrs = ( - await api.getPullRequestsForAzureProjects(projectInputs, { - accessToken: convertTokentoPAT(session.accessToken), - isPAT: true, + await api.getPullRequestsForAzureProjects(projectInputs, this.id, { + ...this.getApiOptions(session.accessToken), authorLogin: user.username, }) )?.map(pr => this.fromAzureProviderPullRequest(pr, repoDescriptors, projects)); @@ -420,13 +414,12 @@ export class AzureDevOpsIntegration extends GitHostIntegration< await Promise.all( projects.map(async p => { const issuesResponse = ( - await api.getIssuesForAzureProject(p.resourceName, p.name, { - accessToken: convertTokentoPAT(session.accessToken), - isPAT: true, + await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { + ...this.getApiOptions(session.accessToken), assigneeLogins: [user.username!], }) ).values; - return issuesResponse.map(i => fromProviderIssue(i, this, { project: p })); + return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); }), ) ).flat(); @@ -434,12 +427,12 @@ export class AzureDevOpsIntegration extends GitHostIntegration< await Promise.all( projects.map(async p => { const issuesResponse = ( - await api.getIssuesForAzureProject(p.resourceName, p.name, { - accessToken: session.accessToken, + await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { + ...this.getApiOptions(session.accessToken), authorLogin: user.username!, }) ).values; - return issuesResponse.map(i => fromProviderIssue(i, this, { project: p })); + return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); }), ) ).flat(); @@ -531,7 +524,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< this._accounts = undefined; } - private fromAzureProviderPullRequest( + protected fromAzureProviderPullRequest( azurePullRequest: ProviderPullRequest, repoDescriptors: AzureRemoteRepositoryDescriptor[], projectDescriptors: AzureProjectDescriptor[], @@ -573,11 +566,62 @@ export class AzureDevOpsIntegration extends GitHostIntegration< } } +const cloudMetadata = providersMetadata[GitCloudHostIntegrationId.AzureDevOps]; +const cloudAuthProvider = Object.freeze({ id: cloudMetadata.id, scopes: cloudMetadata.scopes }); + +export class AzureDevOpsIntegration extends AzureDevOpsIntegrationBase { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = cloudAuthProvider; + readonly id = GitCloudHostIntegrationId.AzureDevOps; + protected readonly key = this.id; + readonly name: string = 'Azure DevOps'; + get domain(): string { + return cloudMetadata.domain; + } + protected override get apiBaseUrl(): string { + return 'https://dev.azure.com'; + } +} + +const serverMetadata = providersMetadata[GitSelfManagedHostIntegrationId.AzureDevOpsServer]; +const serverAuthProvider = Object.freeze({ id: serverMetadata.id, scopes: serverMetadata.scopes }); + +export class AzureDevOpsServerIntegration extends AzureDevOpsIntegrationBase { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = serverAuthProvider; + readonly id = GitSelfManagedHostIntegrationId.AzureDevOpsServer; + protected readonly key: IntegrationKey; + readonly name: string = 'Azure DevOps Server'; + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + didChangeConnection: EventEmitter, + readonly domain: string, + ) { + super(container, authenticationService, getProvidersApi, didChangeConnection); + this.key = `${this.id}:${this.domain}`; + } + + protected override get apiBaseUrl(): string { + const protocol = this._session?.protocol ?? 'https:'; + return `${protocol}//${this.domain}`; + } + + protected override getApiOptions( + accessToken: string, + doNotConvertToPat: boolean = false, + ): { accessToken: string; isPAT: boolean; baseUrl?: string } { + const options = super.getApiOptions(accessToken, doNotConvertToPat); + options.baseUrl = this.apiBaseUrl; + return options; + } +} + const azureCloudDomainRegex = /^dev\.azure\.com$|\bvisualstudio\.com$/i; export function isAzureCloudDomain(domain: string | undefined): boolean { return domain != null && azureCloudDomainRegex.test(domain); } -function convertTokentoPAT(accessToken: string): string { +export function convertTokentoPAT(accessToken: string): string { return base64(`PAT:${accessToken}`); } diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index 9c4b79cc9944a..f07cf6413f53b 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -222,7 +222,7 @@ export class BitbucketApi implements Disposable { ): Promise { const scope = getLogScope(); - if (options?.type !== 'issue') { + if (options?.type === undefined || options?.type === 'pullrequest') { try { const prResponse = await this.request( provider, @@ -246,7 +246,7 @@ export class BitbucketApi implements Disposable { } } - if (options?.type !== 'pullrequest') { + if (options?.type === undefined || options?.type === 'issue') { try { const issueResponse = await this.request( provider, diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 56559e1c434fd..973db318f6e78 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -544,6 +544,20 @@ export const providersMetadata: ProvidersMetadata = { supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], }, + [GitSelfManagedHostIntegrationId.AzureDevOpsServer]: { + domain: '', + id: GitSelfManagedHostIntegrationId.AzureDevOpsServer, + name: 'Azure DevOps Server', + type: 'git', + iconKey: GitCloudHostIntegrationId.AzureDevOps, + issuesPagingMode: PagingMode.Project, + pullRequestsPagingMode: PagingMode.Repo, + // Use 'id' property on account for PR filters + supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.Assignee], + // Use 'name' property on account for issue filters + supportedIssueFilters: [IssueFilter.Author, IssueFilter.Assignee, IssueFilter.Mention], + scopes: ['vso.code', 'vso.identity', 'vso.project', 'vso.profile', 'vso.work'], + }, [IssuesCloudHostIntegrationId.Jira]: { domain: 'atlassian.net', id: IssuesCloudHostIntegrationId.Jira, diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index d882119a75fbf..f1979417ca66e 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -275,6 +275,39 @@ export class ProvidersApi { ) as GetReposForAzureProjectFn, mergePullRequestFn: providerApis.azureDevOps.mergePullRequest.bind(providerApis.azureDevOps), }, + [GitSelfManagedHostIntegrationId.AzureDevOpsServer]: { + ...providersMetadata[GitSelfManagedHostIntegrationId.AzureDevOpsServer], + provider: providerApis.azureDevOps, + getRepoOfProjectFn: providerApis.azureDevOps.getRepo.bind(providerApis.azureDevOps), + getCurrentUserFn: providerApis.azureDevOps.getCurrentUser.bind( + providerApis.azureDevOps, + ) as GetCurrentUserFn, + getCurrentUserForInstanceFn: providerApis.azureDevOps.getCurrentUserForInstance.bind( + providerApis.azureDevOps, + ) as GetCurrentUserForInstanceFn, + getAzureResourcesForUserFn: providerApis.azureDevOps.getCollectionsForUser.bind( + providerApis.azureDevOps, + ) as GetAzureResourcesForUserFn, + getAzureProjectsForResourceFn: providerApis.azureDevOps.getAzureProjects.bind( + providerApis.azureDevOps, + ) as GetAzureProjectsForResourceFn, + getPullRequestsForReposFn: providerApis.azureDevOps.getPullRequestsForRepos.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.azureDevOps.getPullRequestsForRepo.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForRepoFn, + getPullRequestsForAzureProjectsFn: providerApis.azureDevOps.getPullRequestsForProjects.bind( + providerApis.azureDevOps, + ) as GetPullRequestsForAzureProjectsFn, + getIssuesForAzureProjectFn: providerApis.azureDevOps.getIssuesForAzureProject.bind( + providerApis.azureDevOps, + ) as GetIssuesForAzureProjectFn, + getReposForAzureProjectFn: providerApis.azureDevOps.getReposForAzureProject.bind( + providerApis.azureDevOps, + ) as GetReposForAzureProjectFn, + mergePullRequestFn: providerApis.azureDevOps.mergePullRequest.bind(providerApis.azureDevOps), + }, [IssuesCloudHostIntegrationId.Jira]: { ...providersMetadata[IssuesCloudHostIntegrationId.Jira], provider: providerApis.jira, @@ -619,17 +652,21 @@ export class ProvidersApi { async getAzureResourcesForUser( userId: string, - options?: { accessToken?: string; isPAT?: boolean }, + integrationId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, + options?: { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + integrationId, 'getAzureResourcesForUserFn', options?.accessToken, ); try { return ( - await provider.getAzureResourcesForUserFn?.({ userId: userId }, { token: token, isPAT: options?.isPAT }) + await provider.getAzureResourcesForUserFn?.( + { userId: userId }, + { token: token, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, + ) )?.data; } catch (e) { return this.handleProviderError( @@ -728,10 +765,11 @@ export class ProvidersApi { async getAzureProjectsForResource( namespace: string, - options?: { accessToken?: string; cursor?: string; isPAT?: boolean }, + integrationId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, + options?: { accessToken?: string; cursor?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + integrationId, 'getAzureProjectsForResourceFn', options?.accessToken, ); @@ -747,6 +785,7 @@ export class ProvidersApi { azureToken, options?.cursor, options?.isPAT, + options?.baseUrl, ); } catch (e) { return this.handleProviderError>( @@ -760,10 +799,11 @@ export class ProvidersApi { async getReposForAzureProject( namespace: string, project: string, - options?: GetReposOptions & { accessToken?: string; isPAT?: boolean }, + integrationId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, + options?: GetReposOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + integrationId, 'getReposForAzureProjectFn', options?.accessToken, ); @@ -775,6 +815,7 @@ export class ProvidersApi { token, options?.cursor, options?.isPAT, + options?.baseUrl, ); } @@ -864,10 +905,17 @@ export class ProvidersApi { async getPullRequestsForAzureProjects( projects: { namespace: string; project: string }[], - options?: { accessToken?: string; authorLogin?: string; assigneeLogins?: string[]; isPAT?: boolean }, + integrationId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, + options?: { + accessToken?: string; + authorLogin?: string; + assigneeLogins?: string[]; + isPAT?: boolean; + baseUrl?: string; + }, ): Promise { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + integrationId, 'getPullRequestsForAzureProjectsFn', options?.accessToken, ); @@ -879,7 +927,7 @@ export class ProvidersApi { return ( await provider.getPullRequestsForAzureProjectsFn?.( { projects: projects, ...options }, - { token: azureToken, isPAT: options?.isPAT }, + { token: azureToken, isPAT: options?.isPAT, baseUrl: options?.baseUrl }, ) )?.data; } catch (e) { @@ -984,12 +1032,13 @@ export class ProvidersApi { } async getIssuesForAzureProject( + providerId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, namespace: string, project: string, options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + providerId, 'getIssuesForAzureProjectFn', options?.accessToken, ); diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 91eca9549251e..6eadfa0a77f71 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -139,6 +139,7 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier case 'azure': case 'azureDevOps': case 'azure-devops': + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return EntityIdentifierProviderType.Azure; case 'bitbucket': return EntityIdentifierProviderType.Bitbucket; diff --git a/src/plus/integrations/utils/-webview/integration.utils.ts b/src/plus/integrations/utils/-webview/integration.utils.ts index 7ee87b8a09037..227b38a0dca8a 100644 --- a/src/plus/integrations/utils/-webview/integration.utils.ts +++ b/src/plus/integrations/utils/-webview/integration.utils.ts @@ -17,6 +17,7 @@ const selfHostedIntegrationIds: GitSelfManagedHostIntegrationId[] = [ GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted, GitSelfManagedHostIntegrationId.GitLabSelfHosted, GitSelfManagedHostIntegrationId.BitbucketServer, + GitSelfManagedHostIntegrationId.AzureDevOpsServer, ] as const; export const supportedIntegrationIds: IntegrationIds[] = [ @@ -70,7 +71,7 @@ export function getIntegrationIdForRemote( if (isAzureCloudDomain(remote.provider.domain)) { return GitCloudHostIntegrationId.AzureDevOps; } - return undefined; + return remote.provider.custom ? undefined : GitSelfManagedHostIntegrationId.AzureDevOpsServer; case 'bitbucket': case 'bitbucket-server': if (isBitbucketCloudDomain(remote.provider.domain)) { @@ -103,6 +104,7 @@ export function isCloudGitSelfManagedHostIntegrationId( case GitSelfManagedHostIntegrationId.CloudGitHubEnterprise: case GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted: case GitSelfManagedHostIntegrationId.BitbucketServer: + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return true; default: return false; diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts index bad6bd99be5bc..56dc6ca44f1cf 100644 --- a/src/plus/launchpad/enrichmentService.ts +++ b/src/plus/launchpad/enrichmentService.ts @@ -194,6 +194,7 @@ const supportedRemoteProvidersToEnrich: Record = { [GitCloudHostIntegrationId.AzureDevOps]: 'azure', + [GitSelfManagedHostIntegrationId.AzureDevOpsServer]: 'azure', [GitCloudHostIntegrationId.GitLab]: 'gitlab', [GitCloudHostIntegrationId.GitHub]: 'github', [GitCloudHostIntegrationId.Bitbucket]: 'bitbucket', diff --git a/src/plus/startWork/startWork.ts b/src/plus/startWork/startWork.ts index a1584fe5713b3..29951af85199a 100644 --- a/src/plus/startWork/startWork.ts +++ b/src/plus/startWork/startWork.ts @@ -103,6 +103,7 @@ export const supportedStartWorkIntegrations = [ GitCloudHostIntegrationId.GitLab, GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted, GitCloudHostIntegrationId.AzureDevOps, + GitSelfManagedHostIntegrationId.AzureDevOpsServer, GitCloudHostIntegrationId.Bitbucket, IssuesCloudHostIntegrationId.Jira, ]; @@ -736,6 +737,7 @@ function buildItemTelemetryData(item: StartWorkItem) { function getOpenOnWebQuickInputButton(integrationId: string): QuickInputButton | undefined { switch (integrationId) { case GitCloudHostIntegrationId.AzureDevOps: + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return OpenOnAzureDevOpsQuickInputButton; case GitCloudHostIntegrationId.Bitbucket: return OpenOnBitbucketQuickInputButton; From 86095a6372f456bb08ad428ed4e2d829e71b2f79 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 21 Jul 2025 12:17:54 +0200 Subject: [PATCH 2/9] Refactors integration ID lookup to use provider directly Replaces passing the entire remote object with passing only its provider when determining integration IDs. Simplifies function signatures and clarifies usage, reducing unnecessary coupling to the remote. (#4478, #4516) --- src/git/models/remote.ts | 6 +++--- .../utils/-webview/integration.utils.ts | 21 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts index 45baacbfca545..8265756a556f3 100644 --- a/src/git/models/remote.ts +++ b/src/git/models/remote.ts @@ -51,7 +51,7 @@ export class GitRemote { - const integrationId = getIntegrationIdForRemote(this); + const integrationId = getIntegrationIdForRemote(this.provider); return integrationId && this.container.integrations.get(integrationId, this.provider?.domain); } @@ -125,7 +125,7 @@ export class GitRemote { - return Boolean(getIntegrationIdForRemote(this)); + return Boolean(getIntegrationIdForRemote(this.provider)); } } diff --git a/src/plus/integrations/utils/-webview/integration.utils.ts b/src/plus/integrations/utils/-webview/integration.utils.ts index 227b38a0dca8a..7a309c761d0a2 100644 --- a/src/plus/integrations/utils/-webview/integration.utils.ts +++ b/src/plus/integrations/utils/-webview/integration.utils.ts @@ -4,8 +4,7 @@ import { GitSelfManagedHostIntegrationId, IssuesCloudHostIntegrationId, } from '../../../../constants.integrations'; -import type { GitRemote } from '../../../../git/models/remote'; -import type { RemoteProviderId } from '../../../../git/remotes/remoteProvider'; +import type { RemoteProvider, RemoteProviderId } from '../../../../git/remotes/remoteProvider'; import type { IntegrationConnectedKey } from '../../models/integration'; import { isAzureCloudDomain } from '../../providers/azureDevOps'; import { isBitbucketCloudDomain } from '../../providers/bitbucket'; @@ -64,30 +63,30 @@ export function getIntegrationConnectedKey( } export function getIntegrationIdForRemote( - remote: GitRemote, + provider: RemoteProvider | undefined, ): GitCloudHostIntegrationId | GitSelfManagedHostIntegrationId | undefined { - switch (remote.provider?.id) { + switch (provider?.id) { case 'azure-devops': - if (isAzureCloudDomain(remote.provider.domain)) { + if (isAzureCloudDomain(provider.domain)) { return GitCloudHostIntegrationId.AzureDevOps; } - return remote.provider.custom ? undefined : GitSelfManagedHostIntegrationId.AzureDevOpsServer; + return provider.custom ? undefined : GitSelfManagedHostIntegrationId.AzureDevOpsServer; case 'bitbucket': case 'bitbucket-server': - if (isBitbucketCloudDomain(remote.provider.domain)) { + if (isBitbucketCloudDomain(provider.domain)) { return GitCloudHostIntegrationId.Bitbucket; } return GitSelfManagedHostIntegrationId.BitbucketServer; case 'github': - if (remote.provider.domain != null && !isGitHubDotCom(remote.provider.domain)) { - return remote.provider.custom + if (provider.domain != null && !isGitHubDotCom(provider.domain)) { + return provider.custom ? GitSelfManagedHostIntegrationId.GitHubEnterprise : GitSelfManagedHostIntegrationId.CloudGitHubEnterprise; } return GitCloudHostIntegrationId.GitHub; case 'gitlab': - if (remote.provider.domain != null && !isGitLabDotCom(remote.provider.domain)) { - return remote.provider.custom + if (provider.domain != null && !isGitLabDotCom(provider.domain)) { + return provider.custom ? GitSelfManagedHostIntegrationId.GitLabSelfHosted : GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted; } From f48d89c53464263b409b10691fc499a0be7f40d1 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Thu, 17 Jul 2025 17:47:38 +0200 Subject: [PATCH 3/9] Add Azure DevOps Server support to launchpad Introduces Azure DevOps Server as a supported integration in the launchpad, enabling proper display, button actions, and title labeling for repositories connected to self-hosted Azure DevOps instances. Improves handling of integration IDs by prioritizing detection specific to the remote, ensuring accurate integration linkage for both cloud and self-managed scenarios. (#4478, #4516) --- src/git/utils/-webview/repository.utils.ts | 7 +++++-- src/plus/launchpad/launchpad.ts | 3 +++ src/plus/launchpad/launchpadProvider.ts | 1 + src/webviews/plus/graph/graphWebview.utils.ts | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/git/utils/-webview/repository.utils.ts b/src/git/utils/-webview/repository.utils.ts index 79eb16dd0a749..d16e6db1ecdc2 100644 --- a/src/git/utils/-webview/repository.utils.ts +++ b/src/git/utils/-webview/repository.utils.ts @@ -1,4 +1,4 @@ -import { convertRemoteProviderIdToIntegrationId } from '../../../plus/integrations/utils/-webview/integration.utils'; +import { getIntegrationIdForRemote } from '../../../plus/integrations/utils/-webview/integration.utils'; import { configuration } from '../../../system/-webview/configuration'; import { formatDate, fromNow } from '../../../system/date'; import { map } from '../../../system/iterable'; @@ -80,13 +80,16 @@ export async function toRepositoryShapeWithProvider( icon: remote.provider.icon === 'remote' ? 'cloud' : remote.provider.icon, integration: remote.supportsIntegration() ? { - id: convertRemoteProviderIdToIntegrationId(remote.provider.id)!, + id: getIntegrationIdForRemote(remote.provider)!, connected: remote.maybeIntegrationConnected ?? false, } : undefined, supportedFeatures: remote.provider.supportedFeatures, url: await remote.provider.url({ type: RemoteResourceType.Repo }), }; + if (provider.integration?.id == null) { + provider.integration = undefined; + } } return { ...toRepositoryShape(repo), provider: provider }; diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 57114a8aeabb0..574195754ac7f 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -1596,6 +1596,7 @@ function getOpenOnGitProviderQuickInputButton(integrationId: string): QuickInput case GitSelfManagedHostIntegrationId.CloudGitHubEnterprise: return OpenOnGitHubQuickInputButton; case GitCloudHostIntegrationId.AzureDevOps: + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return OpenOnAzureDevOpsQuickInputButton; case GitCloudHostIntegrationId.Bitbucket: case GitSelfManagedHostIntegrationId.BitbucketServer: @@ -1622,6 +1623,8 @@ function getIntegrationTitle(integrationId: string): string { return 'GitHub'; case GitCloudHostIntegrationId.AzureDevOps: return 'Azure DevOps'; + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: + return 'Azure DevOps Server'; case GitCloudHostIntegrationId.Bitbucket: return 'Bitbucket'; case GitSelfManagedHostIntegrationId.BitbucketServer: diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index bfe27990749eb..772331adfee2b 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -136,6 +136,7 @@ export const supportedLaunchpadIntegrations: (GitCloudHostIntegrationId | CloudG GitCloudHostIntegrationId.GitLab, GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted, GitCloudHostIntegrationId.AzureDevOps, + GitSelfManagedHostIntegrationId.AzureDevOpsServer, GitCloudHostIntegrationId.Bitbucket, GitSelfManagedHostIntegrationId.BitbucketServer, ]; diff --git a/src/webviews/plus/graph/graphWebview.utils.ts b/src/webviews/plus/graph/graphWebview.utils.ts index 6ab1448eb98c0..7e2ebb095c1a4 100644 --- a/src/webviews/plus/graph/graphWebview.utils.ts +++ b/src/webviews/plus/graph/graphWebview.utils.ts @@ -153,6 +153,7 @@ export function toGraphHostingServiceType(id: string): GraphHostingServiceType | case 'azureDevops' satisfies Unbrand: case 'azure': case GitCloudHostIntegrationId.AzureDevOps: + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return 'azureDevops'; case 'bitbucket' satisfies RemoteProviderId: From 9beb650d3c27108b70a023bd8fa99a43335bea12 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 28 Jul 2025 15:05:51 +0200 Subject: [PATCH 4/9] Adds current user retrieval for Azure DevOps Server integration (#4478, #4516) --- .../integrations/providers/azure/azure.ts | 55 +++++++++++++++++++ .../integrations/providers/azureDevOps.ts | 47 +++++++++++----- 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts index bbc38572debab..f558d1f8156c9 100644 --- a/src/plus/integrations/providers/azure/azure.ts +++ b/src/plus/integrations/providers/azure/azure.ts @@ -373,6 +373,61 @@ export class AzureDevOpsApi implements Disposable { return undefined; } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getCurrentUserOnServer( + provider: Provider, + token: string, + baseUrl: string, + ): Promise<{ id: string; name?: string; email?: string; username?: string; avatarUrl?: string } | undefined> { + const scope = getLogScope(); + + try { + const connectionData = await this.request<{ + authenticatedUser?: { + id: string; + descriptor: string; + isActive: boolean; + metTypeId: number; + providerDisplayName?: string; + emailAddress?: string; + resourceVersion: 2; + subjectDescriptor: string; + properties?: { + Account?: { + $type: string; + $value: string; + }; + }; + }; + }>( + provider, + token, + baseUrl, + '_apis/connectionData', + { + method: 'GET', + }, + scope, + ); + + const user = connectionData?.authenticatedUser; + const username = user?.properties?.Account?.$value; + if (!username) { + return undefined; + } + + return { + id: user.id, + name: user.providerDisplayName, + email: user.emailAddress, + username: username, + }; + } catch (ex) { + Logger.error(ex, scope, `Failed to get current user from ${baseUrl}`); + return undefined; + } + } + async getWorkItemStateCategory( issueType: string, state: string, diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index b50478e2a914a..b11ee6ed57d06 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -50,26 +50,28 @@ export abstract class AzureDevOpsIntegrationBase< const cachedAccount = this._accounts.get(accessToken); if (cachedAccount == null) { - const api = await this.getProvidersApi(); - const user = await api.getCurrentUser(this.id, this.getApiOptions(accessToken, true)); - this._accounts.set( - accessToken, - user - ? { - provider: this, - id: user.id, - name: user.name ?? undefined, - email: user.email ?? undefined, - avatarUrl: user.avatarUrl ?? undefined, - username: user.username ?? undefined, - } - : undefined, - ); + const user = await this._requestForCurrentUser(accessToken); + this._accounts.set(accessToken, user); } return this._accounts.get(accessToken); } + protected async _requestForCurrentUser(accessToken: string): Promise { + const api = await this.getProvidersApi(); + const user = await api.getCurrentUser(this.id, this.getApiOptions(accessToken, true)); + return user + ? { + provider: this, + id: user.id, + name: user.name ?? undefined, + email: user.email ?? undefined, + avatarUrl: user.avatarUrl ?? undefined, + username: user.username ?? undefined, + } + : undefined; + } + private _organizations: Map | undefined; private async getProviderResourcesForUser( session: AuthenticationSession, @@ -615,6 +617,21 @@ export class AzureDevOpsServerIntegration extends AzureDevOpsIntegrationBase { + const azure = await this.container.azure; + const user = azure ? await azure.getCurrentUserOnServer(this, accessToken, this.apiBaseUrl) : undefined; + return user + ? { + provider: this, + id: user.id, + name: user.name ?? undefined, + email: user.email ?? undefined, + avatarUrl: user.avatarUrl ?? undefined, + username: user.username ?? undefined, + } + : undefined; + } } const azureCloudDomainRegex = /^dev\.azure\.com$|\bvisualstudio\.com$/i; From 40ceccc6b3f3a76034422c4d3b9533f3d979ec34 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 29 Jul 2025 20:34:26 +0200 Subject: [PATCH 5/9] Implements getting issues for Azure DevOps Server (#4478, #4516) --- .../integrations/providers/azureDevOps.ts | 67 +++++++++---------- src/plus/integrations/providers/bitbucket.ts | 14 +--- .../integrations/providers/providersApi.ts | 3 +- src/system/promise.ts | 12 ++++ 4 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index b11ee6ed57d06..222be52897a8e 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -8,7 +8,7 @@ import type { Issue, IssueShape } from '../../../git/models/issue'; import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; -import { getSettledValue } from '../../../system/promise'; +import { flatSettled } from '../../../system/promise'; import { base64 } from '../../../system/string'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService'; @@ -119,16 +119,13 @@ export abstract class AzureDevOpsIntegrationBase< if (resourcesWithoutProjects.length > 0) { const api = await this.getProvidersApi(); - const azureProjects = ( - await Promise.allSettled( - resourcesWithoutProjects.map(resource => - api.getAzureProjectsForResource(resource.name, this.id, this.getApiOptions(accessToken)), - ), - ) - ) - .map(r => getSettledValue(r)?.values) - .flat() - .filter(p => p != null); + const azureProjects = await flatSettled( + resourcesWithoutProjects.map( + async resource => + (await api.getAzureProjectsForResource(resource.name, this.id, this.getApiOptions(accessToken))) + .values, + ), + ); for (const resource of resourcesWithoutProjects) { const projects = azureProjects?.filter(p => p.namespace === resource.name); @@ -412,32 +409,28 @@ export abstract class AzureDevOpsIntegrationBase< const projects = await this.getProviderProjectsForResources(session, orgs); if (projects == null || projects.length === 0) return undefined; - const assignedIssues = ( - await Promise.all( - projects.map(async p => { - const issuesResponse = ( - await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { - ...this.getApiOptions(session.accessToken), - assigneeLogins: [user.username!], - }) - ).values; - return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); - }), - ) - ).flat(); - const authoredIssues = ( - await Promise.all( - projects.map(async p => { - const issuesResponse = ( - await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { - ...this.getApiOptions(session.accessToken), - authorLogin: user.username!, - }) - ).values; - return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); - }), - ) - ).flat(); + const assignedIssues = await flatSettled( + projects.map(async p => { + const issuesResponse = ( + await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { + ...this.getApiOptions(session.accessToken), + assigneeLogins: [user.username!], + }) + ).values; + return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); + }), + ); + const authoredIssues = await flatSettled( + projects.map(async p => { + const issuesResponse = ( + await api.getIssuesForAzureProject(this.id, p.resourceName, p.name, { + ...this.getApiOptions(session.accessToken), + authorLogin: user.username!, + }) + ).values; + return issuesResponse.map(i => fromProviderIssue(i, this as any, { project: p })); + }), + ); // TODO: Add mentioned issues const issuesById = new Map(); diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index b382ee8a75de8..809a7779acab2 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -8,7 +8,7 @@ import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/mo import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; import { uniqueBy } from '../../../system/iterable'; -import { getSettledValue } from '../../../system/promise'; +import { flatSettled, nonnullSettled } from '../../../system/promise'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { ProviderAuthenticationSession } from '../authentication/models'; import { GitHostIntegration } from '../models/gitHostIntegration'; @@ -351,15 +351,3 @@ const bitbucketCloudDomainRegex = /^bitbucket\.org$/i; export function isBitbucketCloudDomain(domain: string | undefined): boolean { return domain != null && bitbucketCloudDomainRegex.test(domain); } - -type MaybePromiseArr = (Promise | T | undefined)[]; - -async function nonnullSettled(arr: MaybePromiseArr): Promise { - const all = await Promise.allSettled(arr); - return all.map(r => getSettledValue(r)).filter(v => v != null); -} - -async function flatSettled(arr: MaybePromiseArr<(T | undefined)[]>): Promise { - const all = await nonnullSettled(arr); - return all.flat().filter(v => v != null); -} diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index f1979417ca66e..b0739e9960b37 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -1035,7 +1035,7 @@ export class ProvidersApi { providerId: GitCloudHostIntegrationId.AzureDevOps | GitSelfManagedHostIntegrationId.AzureDevOpsServer, namespace: string, project: string, - options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean }, + options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( providerId, @@ -1050,6 +1050,7 @@ export class ProvidersApi { token, options?.cursor, options?.isPAT, + options?.baseUrl, ); } diff --git a/src/system/promise.ts b/src/system/promise.ts index 5f997ff06405f..7737a3018e2a3 100644 --- a/src/system/promise.ts +++ b/src/system/promise.ts @@ -197,6 +197,18 @@ export function getDeferredPromiseIfPending(deferred: Deferred | undefined return deferred?.pending ? deferred.promise : undefined; } +export type MaybePromiseArr = (Promise | T | undefined)[]; + +export async function nonnullSettled(arr: MaybePromiseArr): Promise { + const all = await Promise.allSettled(arr); + return all.map(r => getSettledValue(r)).filter(v => v != null); +} + +export async function flatSettled(arr: MaybePromiseArr<(T | undefined)[]>): Promise { + const all = await nonnullSettled(arr); + return all.flat().filter(v => v != null); +} + export function getSettledValue(promise: PromiseSettledResult | undefined): T | undefined; export function getSettledValue( promise: PromiseSettledResult | undefined, From 40fc47cce7e8c41e05bffb7ac5907659eff10f31 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 30 Jul 2025 13:50:39 +0200 Subject: [PATCH 6/9] Lets associate an Azure DevOps Server issue with a branch (#4478, #4516) --- src/plus/integrations/providers/utils.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 6eadfa0a77f71..b89ce5de01acc 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -43,6 +43,9 @@ export function getEntityIdentifierInput(entity: Issue | PullRequest | Launchpad provider = EntityIdentifierProviderType.GitlabSelfHosted; domain = entity.provider.domain; } + if (provider === EntityIdentifierProviderType.AzureDevOpsServer) { + domain = entity.provider.domain; + } let projectId = null; let resourceId = null; @@ -55,7 +58,10 @@ export function getEntityIdentifierInput(entity: Issue | PullRequest | Launchpad projectId = entity.project.id; resourceId = entity.project.resourceId; - } else if (provider === EntityIdentifierProviderType.Azure) { + } else if ( + provider === EntityIdentifierProviderType.Azure || + provider === EntityIdentifierProviderType.AzureDevOpsServer + ) { const project = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.project : entity.project; if (project == null) { throw new Error('Azure issues and PRs must have a project to be encoded'); @@ -75,7 +81,10 @@ export function getEntityIdentifierInput(entity: Issue | PullRequest | Launchpad } let entityId = isLaunchpadItem(entity) ? entity.graphQLId! : entity.nodeId!; - if (provider === EntityIdentifierProviderType.Azure) { + if ( + provider === EntityIdentifierProviderType.Azure || + provider === EntityIdentifierProviderType.AzureDevOpsServer + ) { entityId = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.id : entity.id; } @@ -113,6 +122,8 @@ export function getProviderIdFromEntityIdentifier( return IssuesCloudHostIntegrationId.Jira; case EntityIdentifierProviderType.Azure: return GitCloudHostIntegrationId.AzureDevOps; + case EntityIdentifierProviderType.AzureDevOpsServer: + return GitSelfManagedHostIntegrationId.AzureDevOpsServer; case EntityIdentifierProviderType.Bitbucket: return GitCloudHostIntegrationId.Bitbucket; case EntityIdentifierProviderType.BitbucketServer: @@ -139,8 +150,9 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier case 'azure': case 'azureDevOps': case 'azure-devops': - case GitSelfManagedHostIntegrationId.AzureDevOpsServer: return EntityIdentifierProviderType.Azure; + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: + return EntityIdentifierProviderType.AzureDevOpsServer; case 'bitbucket': return EntityIdentifierProviderType.Bitbucket; case 'bitbucket-server': @@ -246,6 +258,7 @@ export async function getIssueFromGitConfigEntityIdentifier( identifier.provider !== EntityIdentifierProviderType.GitlabSelfHosted && identifier.provider !== EntityIdentifierProviderType.Bitbucket && identifier.provider !== EntityIdentifierProviderType.BitbucketServer && + identifier.provider !== EntityIdentifierProviderType.AzureDevOpsServer && identifier.provider !== EntityIdentifierProviderType.Azure ) { return undefined; From df7d02847b799aeb2969d4af9df92bc1246ce155 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 30 Jul 2025 15:15:54 +0200 Subject: [PATCH 7/9] Improves Azure DevOps PR search for a branch 1. users search criteria to list PRs related to the branch instead of listing all 2. if there are no open PRs, then select a closed one (#4478, #4516) --- .../integrations/providers/azure/azure.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts index f558d1f8156c9..b68e648ba9d53 100644 --- a/src/plus/integrations/providers/azure/azure.ts +++ b/src/plus/integrations/providers/azure/azure.ts @@ -98,14 +98,32 @@ export class AzureDevOpsApi implements Disposable { provider, token, options?.baseUrl, - `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests`, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullRequests?searchCriteria.status=all&searchCriteria.sourceRefName=refs/heads/${branch}`, { method: 'GET', }, scope, ); - const pr = prResult?.value.find(pr => pr.sourceRefName.endsWith(branch)); + // Sort PRs: open PRs first, then by most recent activity (creation or closure date) + const sortedPRs = prResult?.value.sort((a, b) => { + // First, prioritize open PRs (active/notSet) over closed ones (abandoned/completed) + const aIsOpen = a.status === 'active' || a.status === 'notSet'; + const bIsOpen = b.status === 'active' || b.status === 'notSet'; + + if (aIsOpen !== bIsOpen) { + return aIsOpen ? -1 : 1; // Open PRs come first + } + + // Among PRs with the same status, sort by most recent activity + // Use closedDate if available, otherwise use creationDate + const aDate = new Date(a.closedDate || a.creationDate); + const bDate = new Date(b.closedDate || b.creationDate); + + return bDate.getTime() - aDate.getTime(); // Most recent first + }); + + const pr = sortedPRs?.[0]; if (pr == null) return undefined; return fromAzurePullRequest(pr, provider, owner); From ff8ae788905716fc3d758dfb6f1f376b6907840b Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 30 Jul 2025 15:47:59 +0200 Subject: [PATCH 8/9] Mentions Azure DevOps Server changes in the CHANGELOG (#4478, #4516) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb4d8e654546..dd37b86e2a073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds ability to set or change the upstream branch for branches in the _Commit Graph_ and other GitLens views ([#4498](https://github.com/gitkraken/vscode-gitlens/issues/4498)) - Adds new _Set Upstream..._ and _Change Upstream..._ context menu items to branches in the _Commit Graph_ and other GitLens views - Adds a new _upstream_ sub-command to the _branch_ Git Command Palette +- Add Azure DevOps Server integration support ([#4478](https://github.com/gitkraken/vscode-gitlens/issues/4478)) ### Changed From ec487c3bc375a1d9a3a0877449063e2ac4b5c81e Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Wed, 6 Aug 2025 16:49:11 +0200 Subject: [PATCH 9/9] Shows user-icons on Azure DevOps Server PRs (#4478, #4516) --- src/plus/launchpad/launchpad.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 574195754ac7f..47e369158b359 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -509,6 +509,7 @@ export class LaunchpadCommand extends QuickCommand { alwaysShow: alwaysShow, buttons: buttons, iconPath: + i.provider.id === GitSelfManagedHostIntegrationId.AzureDevOpsServer || i.provider.id === GitCloudHostIntegrationId.AzureDevOps ? new ThemeIcon('account') : i.author?.avatarUrl != null @@ -940,6 +941,7 @@ export class LaunchpadCommand extends QuickCommand { createdDateRelative: fromNow(state.item.createdDate), }), iconPath: + state.item.provider.id === GitSelfManagedHostIntegrationId.AzureDevOpsServer || state.item.provider.id === GitCloudHostIntegrationId.AzureDevOps ? new ThemeIcon('account') : state.item.author?.avatarUrl != null @@ -1494,6 +1496,7 @@ function getLaunchpadItemReviewInformation(item: LaunchpadItem): QuickPickItemOf const isCurrentUser = review.reviewer.username === item.currentViewer.username; let reviewLabel: string | undefined; const iconPath = + item.provider.id === GitSelfManagedHostIntegrationId.AzureDevOpsServer || item.provider.id === GitCloudHostIntegrationId.AzureDevOps ? new ThemeIcon('account') : review.reviewer.avatarUrl != null