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 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/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/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/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/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..b68e648ba9d53 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'; @@ -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); @@ -135,7 +153,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 +199,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: '' } }) @@ -365,6 +391,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 46cea2f259f48..222be52897a8e 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 { flatSettled } 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; @@ -48,26 +50,28 @@ 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 }); - 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, @@ -82,10 +86,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, @@ -118,19 +119,13 @@ export class AzureDevOpsIntegration extends GitHostIntegration< if (resourcesWithoutProjects.length > 0) { const api = await this.getProvidersApi(); - const azureProjects = ( - await Promise.allSettled( - resourcesWithoutProjects.map(resource => - api.getAzureProjectsForResource(resource.name, { - accessToken: convertTokentoPAT(accessToken), - isPAT: true, - }), - ), - ) - ) - .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); @@ -170,10 +165,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 +207,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 +216,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 +263,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 +330,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 +368,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)); @@ -416,33 +409,28 @@ export class AzureDevOpsIntegration extends GitHostIntegration< 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(p.resourceName, p.name, { - accessToken: convertTokentoPAT(session.accessToken), - isPAT: true, - assigneeLogins: [user.username!], - }) - ).values; - return issuesResponse.map(i => fromProviderIssue(i, this, { project: p })); - }), - ) - ).flat(); - const authoredIssues = ( - await Promise.all( - projects.map(async p => { - const issuesResponse = ( - await api.getIssuesForAzureProject(p.resourceName, p.name, { - accessToken: session.accessToken, - authorLogin: user.username!, - }) - ).values; - return issuesResponse.map(i => fromProviderIssue(i, this, { 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(); @@ -531,7 +519,7 @@ export class AzureDevOpsIntegration extends GitHostIntegration< this._accounts = undefined; } - private fromAzureProviderPullRequest( + protected fromAzureProviderPullRequest( azurePullRequest: ProviderPullRequest, repoDescriptors: AzureRemoteRepositoryDescriptor[], projectDescriptors: AzureProjectDescriptor[], @@ -573,11 +561,77 @@ 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; + } + + protected override async _requestForCurrentUser(accessToken: string): Promise { + 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; 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.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/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..b0739e9960b37 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 }, + options?: GetIssuesOptions & { accessToken?: string; isPAT?: boolean; baseUrl?: string }, ): Promise> { const { provider, token } = await this.ensureProviderTokenAndFunction( - GitCloudHostIntegrationId.AzureDevOps, + providerId, 'getIssuesForAzureProjectFn', options?.accessToken, ); @@ -1001,6 +1050,7 @@ export class ProvidersApi { token, options?.cursor, options?.isPAT, + options?.baseUrl, ); } diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 91eca9549251e..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: @@ -140,6 +151,8 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier case 'azureDevOps': case 'azure-devops': return EntityIdentifierProviderType.Azure; + case GitSelfManagedHostIntegrationId.AzureDevOpsServer: + return EntityIdentifierProviderType.AzureDevOpsServer; case 'bitbucket': return EntityIdentifierProviderType.Bitbucket; case 'bitbucket-server': @@ -245,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; diff --git a/src/plus/integrations/utils/-webview/integration.utils.ts b/src/plus/integrations/utils/-webview/integration.utils.ts index 7ee87b8a09037..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'; @@ -17,6 +16,7 @@ const selfHostedIntegrationIds: GitSelfManagedHostIntegrationId[] = [ GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted, GitSelfManagedHostIntegrationId.GitLabSelfHosted, GitSelfManagedHostIntegrationId.BitbucketServer, + GitSelfManagedHostIntegrationId.AzureDevOpsServer, ] as const; export const supportedIntegrationIds: IntegrationIds[] = [ @@ -63,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 undefined; + 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; } @@ -103,6 +103,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/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 57114a8aeabb0..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 @@ -1596,6 +1599,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 +1626,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/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; 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, 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: