diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index 84d4c33dc1b50..258dbcb6e4681 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -147,6 +147,11 @@ export const OpenOnGitLabQuickInputButton: QuickInputButton = { tooltip: 'Open on GitLab', }; +export const OpenOnAzureDevOpsQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on Azure DevOps', +}; + export const OpenOnWebQuickInputButton: QuickInputButton = { iconPath: new ThemeIcon('globe'), tooltip: 'Open on gitkraken.dev', diff --git a/src/constants.storage.ts b/src/constants.storage.ts index bf18402de97d4..49e1f3a60deaa 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -87,6 +87,10 @@ export type GlobalStorage = { [key in `gk:${string}:organizations`]: Stored; } & { [key in `jira:${string}:organizations`]: Stored } & { [key in `jira:${string}:projects`]: Stored; +} & { [key in `azure:${string}:account`]: Stored } & { + [key in `azure:${string}:organizations`]: Stored; +} & { + [key in `azure:${string}:projects`]: Stored; }; export type StoredIntegrationConfigurations = Record; @@ -203,6 +207,28 @@ export interface StoredJiraProject { resourceId: string; } +export interface StoredAzureAccount { + id: string; + name: string | undefined; + username: string | undefined; + email: string | undefined; + avatarUrl: string | undefined; +} + +export interface StoredAzureOrganization { + key: string; + id: string; + name: string; +} + +export interface StoredAzureProject { + key: string; + id: string; + name: string; + resourceId: string; + resourceName: string; +} + export interface StoredAvatar { uri: string; timestamp: number; diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index a0413b1f000ce..ec9a16d6546f7 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -1,6 +1,5 @@ import type { AuthenticationSession, CancellationToken } from 'vscode'; import { HostingIntegrationId } from '../../../constants.integrations'; -import type { PagedResult } from '../../../git/gitProvider'; import type { Account } from '../../../git/models/author'; import type { DefaultBranch } from '../../../git/models/defaultBranch'; import type { Issue, SearchedIssue } from '../../../git/models/issue'; @@ -12,12 +11,12 @@ import type { SearchedPullRequest, } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; -import { Logger } from '../../../system/logger'; +import { getSettledValue } from '../../../system/promise'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; import type { ResourceDescriptor } from '../integration'; import { HostingIntegration } from '../integration'; -import type { ProviderRepository } from './models'; -import { providersMetadata } from './models'; +import type { ProviderPullRequest } from './models'; +import { fromProviderPullRequest, providersMetadata } from './models'; const metadata = providersMetadata[HostingIntegrationId.AzureDevOps]; const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); @@ -27,6 +26,29 @@ interface AzureRepositoryDescriptor extends ResourceDescriptor { name: string; } +interface AzureOrganizationDescriptor extends ResourceDescriptor { + id: string; + name: string; +} + +interface AzureProjectDescriptor extends ResourceDescriptor { + id: string; + name: string; + resourceId: string; + resourceName: string; +} + +interface AzureRemoteRepositoryDescriptor extends ResourceDescriptor { + id: string; + nodeId?: string; + resourceName: string; + name: string; + projectName?: string; + url?: string; + cloneUrlHttps?: string; + cloneUrlSsh?: string; +} + export class AzureDevOpsIntegration extends HostingIntegration< HostingIntegrationId.AzureDevOps, AzureRepositoryDescriptor @@ -43,22 +65,154 @@ export class AzureDevOpsIntegration extends HostingIntegration< return 'https://dev.azure.com'; } - async getReposForAzureProject( - namespace: string, - project: string, - options?: { cursor?: string }, - ): Promise | undefined> { - const connected = this.maybeConnected ?? (await this.isConnected()); - if (!connected) return undefined; - - try { - return await ( - await this.getProvidersApi() - ).getReposForAzureProject(namespace, project, { cursor: options?.cursor }); - } catch (ex) { - Logger.error(ex, 'getReposForAzureProject'); - return undefined; + private _accounts: Map | undefined; + protected override async getProviderCurrentAccount({ + accessToken, + }: AuthenticationSession): Promise { + this._accounts ??= new Map(); + + 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, + ); } + + return this._accounts.get(accessToken); + } + + private _organizations: Map | undefined; + private async getProviderResourcesForUser( + session: AuthenticationSession, + force: boolean = false, + ): Promise { + this._organizations ??= new Map(); + const { accessToken } = session; + const cachedResources = this._organizations.get(accessToken); + + if (cachedResources == null || force) { + const api = await this.getProvidersApi(); + const account = await this.getProviderCurrentAccount(session); + if (account?.id == null) return undefined; + + const resources = await api.getAzureResourcesForUser(account.id, { accessToken: accessToken }); + this._organizations.set( + accessToken, + resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined, + ); + } + + return this._organizations.get(accessToken); + } + + private _projects: Map | undefined; + private async getProviderProjectsForResources( + { accessToken }: AuthenticationSession, + resources: AzureOrganizationDescriptor[], + force: boolean = false, + ): Promise { + this._projects ??= new Map(); + + let resourcesWithoutProjects = []; + if (force) { + resourcesWithoutProjects = resources; + } else { + for (const resource of resources) { + const resourceKey = `${accessToken}:${resource.id}`; + const cachedProjects = this._projects.get(resourceKey); + if (cachedProjects == null) { + resourcesWithoutProjects.push(resource); + } + } + } + + if (resourcesWithoutProjects.length > 0) { + const api = await this.getProvidersApi(); + const azureProjects = ( + await Promise.allSettled( + resourcesWithoutProjects.map(resource => + api.getAzureProjectsForResource(resource.name, { accessToken: accessToken }), + ), + ) + ) + .map(r => getSettledValue(r)?.values) + .flat() + .filter(p => p != null); + + for (const resource of resourcesWithoutProjects) { + const projects = azureProjects?.filter(p => p.namespace === resource.name); + if (projects != null) { + this._projects.set( + `${accessToken}:${resource.id}`, + projects.map(p => ({ + id: p.id, + name: p.name, + resourceId: resource.id, + resourceName: resource.name, + key: p.id, + })), + ); + } + } + } + + return resources.reduce((projects, resource) => { + const resourceProjects = this._projects!.get(`${accessToken}:${resource.id}`); + if (resourceProjects != null) { + projects.push(...resourceProjects); + } + return projects; + }, []); + } + + private async getRepoDescriptorsForProjects( + session: AuthenticationSession, + projects: AzureProjectDescriptor[], + ): Promise> { + const descriptors = new Map(); + if (projects.length === 0) return descriptors; + + const api = await this.getProvidersApi(); + const { accessToken } = session; + await Promise.all( + projects.map(async project => { + const repos = ( + await api.getReposForAzureProject(project.resourceName, project.name, { + accessToken: accessToken, + }) + )?.values; + if (repos != null && repos.length > 0) { + descriptors.set( + project.id, + repos.map(r => ({ + id: r.id, + nodeId: r.graphQLId ?? undefined, + resourceName: project.resourceName, + name: r.name, + projectName: project.name, + url: r.webUrl ?? undefined, + cloneUrlHttps: r.httpsUrl ?? undefined, + cloneUrlSsh: r.sshUrl ?? undefined, + key: r.id, + })), + ); + } + }), + ); + + return descriptors; } protected override async mergeProviderPullRequest( @@ -145,10 +299,58 @@ export class AzureDevOpsIntegration extends HostingIntegration< } protected override async searchProviderMyPullRequests( - _session: AuthenticationSession, - _repos?: AzureRepositoryDescriptor[], + session: AuthenticationSession, + repos?: AzureRepositoryDescriptor[], ): Promise { - return Promise.resolve(undefined); + const api = await this.getProvidersApi(); + if (repos != null) { + // TODO: implement repos version + return undefined; + } + + const user = await this.getProviderCurrentAccount(session); + if (user?.username == null) return undefined; + + const orgs = await this.getProviderResourcesForUser(session); + if (orgs == null || orgs.length === 0) return undefined; + + const projects = await this.getProviderProjectsForResources(session, orgs); + if (projects == null || projects.length === 0) return undefined; + + const repoDescriptors = Array.from( + ((await this.getRepoDescriptorsForProjects(session, projects)) ?? new Map()).values(), + ) + .filter(r => r != null) + .flat(); + + const projectInputs = projects.map(p => ({ namespace: p.resourceName, project: p.name })); + const assignedPrs = ( + await api.getPullRequestsForAzureProjects(projectInputs, { + accessToken: session.accessToken, + assigneeLogins: [user.username], + }) + )?.map(pr => this.fromAzureProviderPullRequest(pr, repoDescriptors, projects)); + const authoredPrs = ( + await api.getPullRequestsForAzureProjects(projectInputs, { + accessToken: session.accessToken, + authorLogin: user.username, + }) + )?.map(pr => this.fromAzureProviderPullRequest(pr, repoDescriptors, projects)); + const prsById = new Map(); + for (const pr of authoredPrs ?? []) { + prsById.set(pr.id, { pullRequest: pr, reasons: ['authored'] }); + } + + for (const pr of assignedPrs ?? []) { + const existing = prsById.get(pr.id); + if (existing != null) { + existing.reasons.push('assigned'); + } else { + prsById.set(pr.id, { pullRequest: pr, reasons: ['assigned'] }); + } + } + + return Array.from(prsById.values()); } protected override async searchProviderMyIssues( @@ -157,6 +359,118 @@ export class AzureDevOpsIntegration extends HostingIntegration< ): Promise { return Promise.resolve(undefined); } + + protected override async providerOnConnect(): Promise { + if (this._session == null) return; + + const storedAccount = this.container.storage.get(`azure:${this._session.accessToken}:account`); + const storedOrganizations = this.container.storage.get(`azure:${this._session.accessToken}:organizations`); + const storedProjects = this.container.storage.get(`azure:${this._session.accessToken}:projects`); + let account: Account | undefined = storedAccount?.data ? { ...storedAccount.data, provider: this } : undefined; + let organizations = storedOrganizations?.data?.map(o => ({ ...o })); + let projects = storedProjects?.data?.map(p => ({ ...p })); + + if (storedAccount == null) { + account = await this.getProviderCurrentAccount(this._session); + if (account != null) { + // Clear all other stored organizations and projects and accounts when our session changes + await this.container.storage.deleteWithPrefix('azure'); + await this.container.storage.store(`azure:${this._session.accessToken}:account`, { + v: 1, + timestamp: Date.now(), + data: { + id: account.id, + name: account.name, + email: account.email, + avatarUrl: account.avatarUrl, + username: account.username, + }, + }); + } + } + + this._accounts ??= new Map(); + this._accounts.set(this._session.accessToken, account); + + if (storedOrganizations == null) { + organizations = await this.getProviderResourcesForUser(this._session, true); + await this.container.storage.store(`azure:${this._session.accessToken}:organizations`, { + v: 1, + timestamp: Date.now(), + data: organizations, + }); + } + + this._organizations ??= new Map(); + this._organizations.set(this._session.accessToken, organizations); + + if (storedProjects == null && organizations?.length) { + projects = await this.getProviderProjectsForResources(this._session, organizations); + await this.container.storage.store(`azure:${this._session.accessToken}:projects`, { + v: 1, + timestamp: Date.now(), + data: projects, + }); + } + + this._projects ??= new Map(); + for (const project of projects ?? []) { + const projectKey = `${this._session.accessToken}:${project.resourceId}`; + const projects = this._projects.get(projectKey); + if (projects == null) { + this._projects.set(projectKey, [project]); + } else if (!projects.some(p => p.id === project.id)) { + projects.push(project); + } + } + } + + protected override providerOnDisconnect(): void { + this._organizations = undefined; + this._projects = undefined; + this._accounts = undefined; + } + + private fromAzureProviderPullRequest( + azurePullRequest: ProviderPullRequest, + repoDescriptors: AzureRemoteRepositoryDescriptor[], + projectDescriptors: AzureProjectDescriptor[], + ): PullRequest { + const baseRepoDescriptor = repoDescriptors.find(r => r.name === azurePullRequest.repository.name); + const headRepoDescriptor = + azurePullRequest.headRepository != null + ? repoDescriptors.find(r => r.name === azurePullRequest.headRepository!.name) + : undefined; + let project: AzureProjectDescriptor | undefined; + if (baseRepoDescriptor != null) { + azurePullRequest.repository.remoteInfo = { + ...azurePullRequest.repository.remoteInfo, + cloneUrlHTTPS: baseRepoDescriptor.cloneUrlHttps ?? '', + cloneUrlSSH: baseRepoDescriptor.cloneUrlSsh ?? '', + }; + } + + if (headRepoDescriptor != null) { + azurePullRequest.headRepository = { + ...azurePullRequest.headRepository, + id: azurePullRequest.headRepository?.id ?? headRepoDescriptor.id, + name: azurePullRequest.headRepository?.name ?? headRepoDescriptor.name, + owner: { + login: azurePullRequest.headRepository?.owner.login ?? headRepoDescriptor.resourceName, + }, + remoteInfo: { + ...azurePullRequest.headRepository?.remoteInfo, + cloneUrlHTTPS: headRepoDescriptor.cloneUrlHttps ?? '', + cloneUrlSSH: headRepoDescriptor.cloneUrlSsh ?? '', + }, + }; + } + + if (baseRepoDescriptor?.projectName != null) { + project = projectDescriptors.find(p => p.name === baseRepoDescriptor.projectName); + } + return fromProviderPullRequest(azurePullRequest, this, { project: project }); + } } const azureCloudDomainRegex = /^dev\.azure\.com$|\bvisualstudio\.com$/i; diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 6d58484f18da6..3e209fd05bc81 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -30,7 +30,7 @@ function isIssue(item: IssueOrPullRequest | LaunchpadItem): item is Issue { return item.type === 'issue'; } -export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadItem): AnyEntityIdentifierInput { +export function getEntityIdentifierInput(entity: Issue | PullRequest | LaunchpadItem): AnyEntityIdentifierInput { let entityType = EntityType.Issue; if (entity.type === 'pullrequest') { entityType = EntityType.PullRequest; @@ -46,8 +46,11 @@ export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadI provider = EntityIdentifierProviderType.GitlabSelfHosted; domain = entity.provider.domain; } + let projectId = null; let resourceId = null; + let organizationName = null; + let repoId = null; if (provider === EntityIdentifierProviderType.Jira) { if (!isIssue(entity) || entity.project == null) { throw new Error('Jira issues must have a project'); @@ -55,19 +58,36 @@ export function getEntityIdentifierInput(entity: IssueOrPullRequest | LaunchpadI projectId = entity.project.id; resourceId = entity.project.resourceId; + } else if (provider === EntityIdentifierProviderType.Azure) { + 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'); + } + + projectId = project.id; + organizationName = project.resourceName; + repoId = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.repository.id : entity.repository?.id; + if (entityType === EntityType.PullRequest && repoId == null) { + throw new Error('Azure PRs must have a repository ID to be encoded'); + } + } + + let entityId = isLaunchpadItem(entity) ? entity.graphQLId! : entity.nodeId!; + if (provider === EntityIdentifierProviderType.Azure) { + entityId = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.id : entity.id; } return { accountOrOrgId: null, // needed for Trello issues, once supported - organizationName: null, // needed for Azure issues and PRs, once supported + organizationName: organizationName, // needed for Azure issues and PRs, once supported projectId: projectId, // needed for Jira issues, Trello issues, and Azure issues and PRs, once supported - repoId: null, // needed for Azure and BitBucket PRs, once supported + repoId: repoId ?? null, // needed for Azure and BitBucket PRs, once supported resourceId: resourceId, // needed for Jira issues provider: provider, entityType: entityType, version: EntityVersion.One, domain: domain, - entityId: isLaunchpadItem(entity) ? entity.graphQLId! : entity.nodeId!, + entityId: entityId, }; } @@ -106,6 +126,10 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier return EntityIdentifierProviderType.Gitlab; case 'jira': return EntityIdentifierProviderType.Jira; + case 'azure': + case 'azureDevOps': + case 'azure-devops': + return EntityIdentifierProviderType.Azure; default: throw new Error(`Unknown provider type '${str}'`); } diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts index 5d9571f556332..8a7408f69533f 100644 --- a/src/plus/launchpad/enrichmentService.ts +++ b/src/plus/launchpad/enrichmentService.ts @@ -1,4 +1,6 @@ import type { CancellationToken, Disposable } from 'vscode'; +import type { IntegrationId } from '../../constants.integrations'; +import { HostingIntegrationId, IssueIntegrationId, SelfHostedIntegrationId } from '../../constants.integrations'; import type { Container } from '../../container'; import { AuthenticationRequiredError, CancellationError } from '../../errors'; import type { RemoteProvider } from '../../git/remotes/remoteProvider'; @@ -186,6 +188,19 @@ const supportedRemoteProvidersToEnrich: Record = { + [HostingIntegrationId.AzureDevOps]: 'azure', + [HostingIntegrationId.GitLab]: 'gitlab', + [HostingIntegrationId.GitHub]: 'github', + [HostingIntegrationId.Bitbucket]: 'bitbucket', + [SelfHostedIntegrationId.CloudGitHubEnterprise]: 'github', + [SelfHostedIntegrationId.GitHubEnterprise]: 'github', + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: 'gitlab', + [SelfHostedIntegrationId.GitLabSelfHosted]: 'gitlab', + [IssueIntegrationId.Jira]: 'jira', + [IssueIntegrationId.Trello]: 'trello', +}; + export function convertRemoteProviderToEnrichProvider(provider: RemoteProvider): EnrichedItemResponse['provider'] { return convertRemoteProviderIdToEnrichProvider(provider.id); } @@ -199,3 +214,13 @@ export function convertRemoteProviderIdToEnrichProvider(id: RemoteProvider['id'] export function isEnrichableRemoteProviderId(id: string): id is RemoteProvider['id'] { return supportedRemoteProvidersToEnrich[id as RemoteProvider['id']] != null; } + +export function isEnrichableIntegrationId(id: IntegrationId): boolean { + return supportedIntegrationIdsToEnrich[id] != null; +} + +export function convertIntegrationIdToEnrichProvider(id: IntegrationId): EnrichedItemResponse['provider'] { + const enrichProvider = supportedIntegrationIdsToEnrich[id]; + if (enrichProvider == null) throw new Error(`Unknown integration id '${id}'`); + return enrichProvider; +} diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 85c7614a886bf..d98cb827644e9 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -22,6 +22,7 @@ import { LaunchpadSettingsQuickInputButton, LearnAboutProQuickInputButton, MergeQuickInputButton, + OpenOnAzureDevOpsQuickInputButton, OpenOnGitHubQuickInputButton, OpenOnGitLabQuickInputButton, OpenOnWebQuickInputButton, @@ -507,7 +508,12 @@ export class LaunchpadCommand extends QuickCommand { alwaysShow: alwaysShow, buttons: buttons, - iconPath: i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) : undefined, + iconPath: + i.provider.id === HostingIntegrationId.AzureDevOps + ? new ThemeIcon('account') + : i.author?.avatarUrl != null + ? Uri.parse(i.author.avatarUrl) + : undefined, item: i, picked: i.graphQLId === picked || i.graphQLId === topItem?.graphQLId, group: ui, @@ -824,6 +830,7 @@ export class LaunchpadCommand extends QuickCommand { switch (button) { case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: + case OpenOnAzureDevOpsQuickInputButton: this.sendItemActionTelemetry('soft-open', item, group, context); this.container.launchpad.open(item); break; @@ -927,7 +934,11 @@ export class LaunchpadCommand extends QuickCommand { createdDateRelative: fromNow(state.item.createdDate), }), iconPath: - state.item.author?.avatarUrl != null ? Uri.parse(state.item.author.avatarUrl) : undefined, + state.item.provider.id === HostingIntegrationId.AzureDevOps + ? new ThemeIcon('account') + : state.item.author?.avatarUrl != null + ? Uri.parse(state.item.author.avatarUrl) + : undefined, buttons: [ ...gitProviderWebButtons, ...(state.item.isSearched @@ -1086,6 +1097,7 @@ export class LaunchpadCommand extends QuickCommand { switch (button) { case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: + case OpenOnAzureDevOpsQuickInputButton: this.sendItemActionTelemetry('soft-open', state.item, state.item.group, context); this.container.launchpad.open(state.item); break; @@ -1475,7 +1487,12 @@ function getLaunchpadItemReviewInformation(item: LaunchpadItem): QuickPickItemOf for (const review of item.reviews) { const isCurrentUser = review.reviewer.username === item.currentViewer.username; let reviewLabel: string | undefined; - const iconPath = review.reviewer.avatarUrl != null ? Uri.parse(review.reviewer.avatarUrl) : undefined; + const iconPath = + item.provider.id === HostingIntegrationId.AzureDevOps + ? new ThemeIcon('account') + : review.reviewer.avatarUrl != null + ? Uri.parse(review.reviewer.avatarUrl) + : undefined; switch (review.state) { case ProviderPullRequestReviewState.Approved: reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} approved these changes`; @@ -1572,6 +1589,8 @@ function getOpenOnGitProviderQuickInputButton(integrationId: string): QuickInput case SelfHostedIntegrationId.GitHubEnterprise: case SelfHostedIntegrationId.CloudGitHubEnterprise: return OpenOnGitHubQuickInputButton; + case HostingIntegrationId.AzureDevOps: + return OpenOnAzureDevOpsQuickInputButton; default: return undefined; } @@ -1592,6 +1611,8 @@ function getIntegrationTitle(integrationId: string): string { case SelfHostedIntegrationId.GitHubEnterprise: case SelfHostedIntegrationId.CloudGitHubEnterprise: return 'GitHub'; + case HostingIntegrationId.AzureDevOps: + return 'Azure DevOps'; default: return integrationId; } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index a12e4b52558d8..0d7fa483d881a 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -51,7 +51,12 @@ import { getActionablePullRequests, toProviderPullRequestWithUniqueId, } from '../integrations/providers/models'; -import { convertRemoteProviderIdToEnrichProvider, isEnrichableRemoteProviderId } from './enrichmentService'; +import { + convertIntegrationIdToEnrichProvider, + convertRemoteProviderIdToEnrichProvider, + isEnrichableIntegrationId, + isEnrichableRemoteProviderId, +} from './enrichmentService'; import type { EnrichableItem, EnrichedItem } from './models/enrichedItem'; import type { LaunchpadAction, LaunchpadActionCategory, LaunchpadGroup } from './models/launchpad'; import { @@ -112,6 +117,7 @@ export const supportedLaunchpadIntegrations: (HostingIntegrationId | CloudSelfHo SelfHostedIntegrationId.CloudGitHubEnterprise, HostingIntegrationId.GitLab, SelfHostedIntegrationId.CloudGitLabSelfHosted, + HostingIntegrationId.AzureDevOps, ]; type SupportedLaunchpadIntegrationIds = (typeof supportedLaunchpadIntegrations)[number]; function isSupportedLaunchpadIntegrationId(id: string): id is SupportedLaunchpadIntegrationIds { @@ -725,7 +731,10 @@ export class LaunchpadProvider implements Disposable { const providerId = pr.pullRequest.provider.id; - if (!isSupportedLaunchpadIntegrationId(providerId) || !isEnrichableRemoteProviderId(providerId)) { + if ( + !isSupportedLaunchpadIntegrationId(providerId) || + (!isEnrichableRemoteProviderId(providerId) && !isEnrichableIntegrationId(providerId)) + ) { Logger.warn(`Unsupported provider ${providerId}`); return undefined; } @@ -734,7 +743,10 @@ export class LaunchpadProvider implements Disposable { type: 'pr', id: providerPr.uuid, url: pr.pullRequest.url, - provider: convertRemoteProviderIdToEnrichProvider(providerId), + provider: + providerId === HostingIntegrationId.AzureDevOps + ? convertIntegrationIdToEnrichProvider(providerId) + : convertRemoteProviderIdToEnrichProvider(providerId), } satisfies EnrichableItem; const repoIdentity = getRepositoryIdentityForPullRequest(pr.pullRequest); @@ -1070,7 +1082,7 @@ export function getPullRequestBranchDeepLink( const scheme = typeof schemeOverride === 'string' ? schemeOverride : env.uriScheme; const searchParams = new URLSearchParams({ - url: ensureRemoteUrl(remoteUrl), + url: pr.provider.id !== HostingIntegrationId.AzureDevOps ? ensureRemoteUrl(remoteUrl) : remoteUrl, }); if (action) { searchParams.set('action', action);