diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0fed3af0292..ac1f7e6a5b732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds AI model status and model switcher to the _Home_ view ([#4064](https://github.com/gitkraken/vscode-gitlens/issues/4064)) - Adds Anthropic Claude 3.7 Sonnet model for GitLens' AI features ([#4101](https://github.com/gitkraken/vscode-gitlens/issues/4101)) - Adds Google Gemini 2.0 Flash-Lite model for GitLens' AI features ([#4104](https://github.com/gitkraken/vscode-gitlens/issues/4104)) -- Adds integration with Bitbucket Cloud ([#3916](https://github.com/gitkraken/vscode-gitlens/issues/3916)) +- Adds integration with Bitbucket Cloud and Data Center ([#3916](https://github.com/gitkraken/vscode-gitlens/issues/3916)) - shows enriched links to PRs and issues [#4045](https://github.com/gitkraken/vscode-gitlens/issues/4045) - - shows Bitbucket PRs in Launchpad [#4046](https://github.com/gitkraken/vscode-gitlens/issues/4046) - - supports Bitbucket issues in Start Work and lets associate issues with branches [#4047](https://github.com/gitkraken/vscode-gitlens/issues/4047) + - shows Bitbucket Cloud and Data Center PRs in Launchpad [#4046](https://github.com/gitkraken/vscode-gitlens/issues/4046) + - supports Bitbucket issues in Start Work and lets associate issues with branches [#4047](https://github.com/gitkraken/vscode-gitlens/issues/4047), [#4107](https://github.com/gitkraken/vscode-gitlens/issues/4107) - Adds ability to control how worktrees are displayed in the views - Adds a `gitlens.views.worktrees.worktrees.viewAs` setting to specify whether to show worktrees by name, path, or relative path - Adds a `gitlens.views.worktrees.branches.layout` setting to specify whether to show branch worktrees as a list or tree, similar to branches diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index 51d35815ca5d4..9a7b3b7e8b5ce 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -378,7 +378,7 @@ or ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -389,7 +389,7 @@ or ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -400,7 +400,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -411,7 +411,7 @@ or ```typescript { 'issueProvider.key': string, - 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' + 'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -432,7 +432,7 @@ or ```typescript { - 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' + 'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted' } ``` @@ -1553,7 +1553,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted', // @deprecated: true 'remoteProviders.key': string } @@ -1566,7 +1566,7 @@ void ```typescript { 'hostingProvider.key': string, - 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted', + 'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'jira' | 'trello' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'cloud-gitlab-self-hosted' | 'gitlab-self-hosted', // @deprecated: true 'remoteProviders.key': string } diff --git a/src/constants.integrations.ts b/src/constants.integrations.ts index 5b3703dc0de94..fdd47f57e96ca 100644 --- a/src/constants.integrations.ts +++ b/src/constants.integrations.ts @@ -6,6 +6,7 @@ export enum HostingIntegrationId { } export enum SelfHostedIntegrationId { + BitbucketServer = 'bitbucket-server', GitHubEnterprise = 'github-enterprise', CloudGitHubEnterprise = 'cloud-github-enterprise', CloudGitLabSelfHosted = 'cloud-gitlab-self-hosted', @@ -14,6 +15,7 @@ export enum SelfHostedIntegrationId { export type CloudSelfHostedIntegrationId = | SelfHostedIntegrationId.CloudGitHubEnterprise + | SelfHostedIntegrationId.BitbucketServer | SelfHostedIntegrationId.CloudGitLabSelfHosted; export enum IssueIntegrationId { @@ -31,6 +33,7 @@ export const supportedOrderedCloudIntegrationIds = [ SelfHostedIntegrationId.CloudGitLabSelfHosted, HostingIntegrationId.AzureDevOps, HostingIntegrationId.Bitbucket, + SelfHostedIntegrationId.BitbucketServer, IssueIntegrationId.Jira, ]; @@ -92,6 +95,13 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [ supports: ['prs', 'issues'], requiresPro: false, }, + { + id: SelfHostedIntegrationId.BitbucketServer, + name: 'Bitbucket Data Center', + icon: 'gl-provider-bitbucket', + supports: ['prs'], + requiresPro: true, + }, { id: IssueIntegrationId.Jira, name: 'Jira', diff --git a/src/constants.storage.ts b/src/constants.storage.ts index ee31f63d3fb70..b609485b0d5fe 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -95,7 +95,7 @@ export type GlobalStorage = { [key in `azure:${string}:projects`]: Stored; } & { [key in `bitbucket:${string}:account`]: Stored } & { [key in `bitbucket:${string}:workspaces`]: Stored; -}; +} & { [key in `bitbucket-server:${string}:account`]: Stored }; export type StoredIntegrationConfigurations = Record; diff --git a/src/git/models/pullRequest.ts b/src/git/models/pullRequest.ts index 733fe61567082..c54af4634194f 100644 --- a/src/git/models/pullRequest.ts +++ b/src/git/models/pullRequest.ts @@ -57,6 +57,7 @@ export class PullRequest implements PullRequestShape { public readonly assignees?: PullRequestMember[], public readonly statusCheckRollupState?: PullRequestStatusCheckRollupState, public readonly project?: IssueProject, + public readonly version?: number, ) {} get closed(): boolean { diff --git a/src/git/remotes/remoteProviders.ts b/src/git/remotes/remoteProviders.ts index 6067e9b2ff1c5..c1418e244f26c 100644 --- a/src/git/remotes/remoteProviders.ts +++ b/src/git/remotes/remoteProviders.ts @@ -1,4 +1,5 @@ import type { RemotesConfig } from '../../config'; +import type { CloudSelfHostedIntegrationId } from '../../constants.integrations'; import { SelfHostedIntegrationId } from '../../constants.integrations'; import type { Container } from '../../container'; import type { ConfiguredIntegrationDescriptor } from '../../plus/integrations/authentication/models'; @@ -76,6 +77,15 @@ const builtInProviders: RemoteProviders = [ }, ]; +const cloudRemotesMap: Record< + CloudSelfHostedIntegrationId, + typeof GitHubRemote | typeof GitLabRemote | typeof BitbucketServerRemote +> = { + [SelfHostedIntegrationId.CloudGitHubEnterprise]: GitHubRemote, + [SelfHostedIntegrationId.CloudGitLabSelfHosted]: GitLabRemote, + [SelfHostedIntegrationId.BitbucketServer]: BitbucketServerRemote, +}; + export function loadRemoteProviders( cfg: RemotesConfig[] | null | undefined, configuredIntegrations?: ConfiguredIntegrationDescriptor[], @@ -105,12 +115,11 @@ export function loadRemoteProviders( if (configuredIntegrations?.length) { for (const ci of configuredIntegrations) { - if (isCloudSelfHostedIntegrationId(ci.integrationId) && ci.domain) { + const integrationId = ci.integrationId; + if (isCloudSelfHostedIntegrationId(integrationId) && ci.domain) { const matcher = ci.domain.toLocaleLowerCase(); - const providerCreator = (_container: Container, domain: string, path: string) => - ci.integrationId === SelfHostedIntegrationId.CloudGitHubEnterprise - ? new GitHubRemote(domain, path) - : new GitLabRemote(domain, path); + const providerCreator = (_container: Container, domain: string, path: string): RemoteProvider => + new cloudRemotesMap[integrationId](domain, path); const provider = { custom: false, matcher: matcher, diff --git a/src/plus/integrations/authentication/bitbucket.ts b/src/plus/integrations/authentication/bitbucket.ts index 68d1de4741a3d..651664432700e 100644 --- a/src/plus/integrations/authentication/bitbucket.ts +++ b/src/plus/integrations/authentication/bitbucket.ts @@ -1,4 +1,4 @@ -import { HostingIntegrationId } from '../../../constants.integrations'; +import { HostingIntegrationId, SelfHostedIntegrationId } from '../../../constants.integrations'; import { CloudIntegrationAuthenticationProvider } from './integrationAuthenticationProvider'; export class BitbucketAuthenticationProvider extends CloudIntegrationAuthenticationProvider { @@ -6,3 +6,9 @@ export class BitbucketAuthenticationProvider extends CloudIntegrationAuthenticat return HostingIntegrationId.Bitbucket; } } + +export class BitbucketServerAuthenticationProvider extends CloudIntegrationAuthenticationProvider { + protected override get authProviderId(): SelfHostedIntegrationId.BitbucketServer { + return SelfHostedIntegrationId.BitbucketServer; + } +} diff --git a/src/plus/integrations/authentication/configuredIntegrationService.ts b/src/plus/integrations/authentication/configuredIntegrationService.ts index a0db84d9d5d79..a9d9817a01fa8 100644 --- a/src/plus/integrations/authentication/configuredIntegrationService.ts +++ b/src/plus/integrations/authentication/configuredIntegrationService.ts @@ -23,6 +23,7 @@ interface StoredSession { cloud?: boolean; expiresAt?: string; domain?: string; + protocol?: string; } export type ConfiguredIntegrationType = 'cloud' | 'local'; @@ -396,5 +397,6 @@ function convertStoredSessionToSession( cloud: storedSession.cloud ?? cloudIfMissing, expiresAt: storedSession.expiresAt ? new Date(storedSession.expiresAt) : undefined, domain: storedSession.domain ?? descriptor.domain, + protocol: storedSession.protocol, }; } diff --git a/src/plus/integrations/authentication/integrationAuthenticationProvider.ts b/src/plus/integrations/authentication/integrationAuthenticationProvider.ts index 66bd247382ee1..0749950e63a40 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationProvider.ts @@ -274,6 +274,8 @@ export abstract class CloudIntegrationAuthenticationProvider< if (!session) return undefined; + const sessionProtocol = new URL(session.domain).protocol; + // TODO: Once we care about domains, we should try to match the domain here against ours, and if it fails, return undefined return { id: this.configuredIntegrationService.getSessionId(descriptor), @@ -287,6 +289,7 @@ export abstract class CloudIntegrationAuthenticationProvider< expiresAt: new Date(session.expiresIn * 1000 + Date.now()), // Note: do not use the session's domain, because the format is different than in our model domain: descriptor.domain, + protocol: sessionProtocol ?? undefined, }; } diff --git a/src/plus/integrations/authentication/integrationAuthenticationService.ts b/src/plus/integrations/authentication/integrationAuthenticationService.ts index 369f425f8dd37..f580b00b933ed 100644 --- a/src/plus/integrations/authentication/integrationAuthenticationService.ts +++ b/src/plus/integrations/authentication/integrationAuthenticationService.ts @@ -68,6 +68,11 @@ export class IntegrationAuthenticationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './bitbucket') ).BitbucketAuthenticationProvider(this.container, this, this.configuredIntegrationService); break; + case SelfHostedIntegrationId.BitbucketServer: + provider = new ( + await import(/* webpackChunkName: "integrations" */ './bitbucket') + ).BitbucketServerAuthenticationProvider(this.container, this, this.configuredIntegrationService); + break; case HostingIntegrationId.GitHub: provider = isSupportedCloudIntegrationId(HostingIntegrationId.GitHub) ? new ( diff --git a/src/plus/integrations/authentication/models.ts b/src/plus/integrations/authentication/models.ts index 0341d83033ab1..08ca4821d1d11 100644 --- a/src/plus/integrations/authentication/models.ts +++ b/src/plus/integrations/authentication/models.ts @@ -13,6 +13,7 @@ export interface ProviderAuthenticationSession extends AuthenticationSession { readonly cloud: boolean; readonly expiresAt?: Date; readonly domain: string; + readonly protocol?: string; } export interface ConfiguredIntegrationDescriptor { @@ -47,6 +48,7 @@ export type CloudIntegrationType = | 'gitlab' | 'github' | 'bitbucket' + | 'bitbucketServer' | 'azure' | 'githubEnterprise' | 'gitlabSelfHosted'; @@ -73,6 +75,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationId } = githubEnterprise: SelfHostedIntegrationId.CloudGitHubEnterprise, gitlabSelfHosted: SelfHostedIntegrationId.CloudGitLabSelfHosted, bitbucket: HostingIntegrationId.Bitbucket, + bitbucketServer: SelfHostedIntegrationId.BitbucketServer, azure: HostingIntegrationId.AzureDevOps, }; @@ -85,6 +88,7 @@ export const toCloudIntegrationType: { [key in IntegrationId]: CloudIntegrationT [HostingIntegrationId.AzureDevOps]: 'azure', [SelfHostedIntegrationId.CloudGitHubEnterprise]: 'githubEnterprise', [SelfHostedIntegrationId.CloudGitLabSelfHosted]: 'gitlabSelfHosted', + [SelfHostedIntegrationId.BitbucketServer]: 'bitbucketServer', [SelfHostedIntegrationId.GitHubEnterprise]: undefined, [SelfHostedIntegrationId.GitLabSelfHosted]: undefined, }; diff --git a/src/plus/integrations/integrationService.ts b/src/plus/integrations/integrationService.ts index e28826ebf34c7..e37e530ab2f94 100644 --- a/src/plus/integrations/integrationService.ts +++ b/src/plus/integrations/integrationService.ts @@ -614,6 +614,47 @@ export class IntegrationService implements Disposable { await import(/* webpackChunkName: "integrations" */ './providers/bitbucket') ).BitbucketIntegration(this.container, this.authenticationService, this.getProvidersApi.bind(this)); break; + case SelfHostedIntegrationId.BitbucketServer: + if (domain == null) { + integration = this.findCachedById(id); + if (integration != null) { + // return immediately in order to not to cache it after the "switch" block: + return integration; + } + + const existingConfigured = await this.getConfigured({ + id: SelfHostedIntegrationId.BitbucketServer, + }); + if (existingConfigured.length) { + const { domain: configuredDomain } = existingConfigured[0]; + if (configuredDomain == null) { + throw new Error(`Domain is required for '${id}' integration`); + } + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/bitbucket-server') + ).BitbucketServerIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + configuredDomain, + ); + // assign domain because it's part of caching key: + domain = configuredDomain; + break; + } + + return undefined; + } + + integration = new ( + await import(/* webpackChunkName: "integrations" */ './providers/bitbucket-server') + ).BitbucketServerIntegration( + this.container, + this.authenticationService, + this.getProvidersApi.bind(this), + domain, + ); + break; case HostingIntegrationId.AzureDevOps: integration = new ( await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps') @@ -685,6 +726,11 @@ export class IntegrationService implements Disposable { return get(HostingIntegrationId.Bitbucket) as RT; } return (getOrGetCached === this.get ? Promise.resolve(undefined) : undefined) as RT; + case 'bitbucket-server': + if (!isBitbucketCloudDomain(remote.provider.domain)) { + return get(SelfHostedIntegrationId.BitbucketServer) as RT; + } + return (getOrGetCached === this.get ? Promise.resolve(undefined) : undefined) as RT; case 'github': if (remote.provider.domain != null && !isGitHubDotCom(remote.provider.domain)) { return get( @@ -1051,6 +1097,7 @@ export function remoteProviderIdToIntegrationId( case 'gitlab': return HostingIntegrationId.GitLab; case 'bitbucket-server': + return SelfHostedIntegrationId.BitbucketServer; default: return undefined; } diff --git a/src/plus/integrations/providers/bitbucket-server.ts b/src/plus/integrations/providers/bitbucket-server.ts new file mode 100644 index 0000000000000..792bc664c50fa --- /dev/null +++ b/src/plus/integrations/providers/bitbucket-server.ts @@ -0,0 +1,256 @@ +import type { AuthenticationSession, CancellationToken } from 'vscode'; +import { md5 } from '@env/crypto'; +import { SelfHostedIntegrationId } from '../../../constants.integrations'; +import type { Container } from '../../../container'; +import type { Account } from '../../../git/models/author'; +import type { DefaultBranch } from '../../../git/models/defaultBranch'; +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 type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; +import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService'; +import type { ProviderAuthenticationSession } from '../authentication/models'; +import { HostingIntegration } from '../integration'; +import type { BitbucketRepositoryDescriptor } from './bitbucket/models'; +import { fromProviderPullRequest, providersMetadata } from './models'; +import type { ProvidersApi } from './providersApi'; + +const metadata = providersMetadata[SelfHostedIntegrationId.BitbucketServer]; +const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); + +export class BitbucketServerIntegration extends HostingIntegration< + SelfHostedIntegrationId.BitbucketServer, + BitbucketRepositoryDescriptor +> { + readonly authProvider: IntegrationAuthenticationProviderDescriptor = authProvider; + readonly id = SelfHostedIntegrationId.BitbucketServer; + protected readonly key = + `${this.id}:${this.domain}` satisfies `${SelfHostedIntegrationId.BitbucketServer}:${string}`; + readonly name: string = 'Bitbucket Data Center'; + + constructor( + container: Container, + authenticationService: IntegrationAuthenticationService, + getProvidersApi: () => Promise, + private readonly _domain: string, + ) { + super(container, authenticationService, getProvidersApi); + } + + get domain(): string { + return this._domain; + } + + protected get apiBaseUrl(): string { + const protocol = this._session?.protocol ?? 'https:'; + return `${protocol}//${this.domain}/rest/api/1.0`; + } + + protected override async mergeProviderPullRequest( + { accessToken }: AuthenticationSession, + pr: PullRequest, + options?: { + mergeMethod?: PullRequestMergeMethod; + }, + ): Promise { + const api = await this.getProvidersApi(); + return api.mergePullRequest(this.id, pr, { + accessToken: accessToken, + mergeMethod: options?.mergeMethod, + baseUrl: this.apiBaseUrl, + }); + } + + protected override async getProviderAccountForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderAccountForEmail( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _email: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderDefaultBranch( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderIssueOrPullRequest( + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + id: string, + type: undefined | IssueOrPullRequestType, + ): Promise { + if (type === 'issue') { + return undefined; + } + const integration = await this.container.integrations.get(this.id); + if (!integration) { + return undefined; + } + return (await this.container.bitbucket)?.getServerPullRequestById( + this, + accessToken, + repo.owner, + repo.name, + id, + this.apiBaseUrl, + integration, + ); + } + + protected override async getProviderIssue( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _id: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderPullRequestForBranch( + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + branch: string, + _options?: { + avatarSize?: number; + include?: PullRequestState[]; + }, + ): Promise { + const integration = await this.container.integrations.get(this.id); + if (!integration) { + return undefined; + } + return (await this.container.bitbucket)?.getServerPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + this.apiBaseUrl, + integration, + ); + } + + protected override async getProviderPullRequestForCommit( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _ref: string, + ): Promise { + return Promise.resolve(undefined); + } + + protected override async getProviderRepositoryMetadata( + _session: AuthenticationSession, + _repo: BitbucketRepositoryDescriptor, + _cancellation?: CancellationToken, + ): Promise { + return Promise.resolve(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, baseUrl: this.apiBaseUrl }); + 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); + } + + protected override async searchProviderMyPullRequests( + session: ProviderAuthenticationSession, + repos?: BitbucketRepositoryDescriptor[], + ): Promise { + if (repos != null) { + // TODO: implement repos version + return undefined; + } + + const api = await this.getProvidersApi(); + const integration = await this.container.integrations.get(this.id); + if (!api || !integration) { + return undefined; + } + const prs = await api.getBitbucketServerPullRequestsForCurrentUser(this.apiBaseUrl, { + accessToken: session.accessToken, + }); + return prs?.map(pr => fromProviderPullRequest(pr, integration)); + } + + protected override async searchProviderMyIssues( + _session: AuthenticationSession, + _repos?: BitbucketRepositoryDescriptor[], + ): Promise { + return Promise.resolve(undefined); + } + + private readonly storagePrefix = 'bitbucket-server'; + protected override async providerOnConnect(): Promise { + if (this._session == null) return; + + const accountStorageKey = md5(this._session.accessToken); + + const storedAccount = this.container.storage.get(`${this.storagePrefix}:${accountStorageKey}:account`); + + let account: Account | undefined = storedAccount?.data ? { ...storedAccount.data, provider: this } : undefined; + + if (storedAccount == null) { + account = await this.getProviderCurrentAccount(this._session); + if (account != null) { + // Clear all other stored workspaces and repositories and accounts when our session changes + await this.container.storage.deleteWithPrefix(this.storagePrefix); + await this.container.storage.store(`${this.storagePrefix}:${accountStorageKey}: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); + } + + protected override providerOnDisconnect(): void { + this._accounts = undefined; + } +} diff --git a/src/plus/integrations/providers/bitbucket-server/models.ts b/src/plus/integrations/providers/bitbucket-server/models.ts new file mode 100644 index 0000000000000..a4aa5f41f6d3d --- /dev/null +++ b/src/plus/integrations/providers/bitbucket-server/models.ts @@ -0,0 +1,231 @@ +import { GitPullRequestMergeableState, GitPullRequestReviewState, GitPullRequestState } from '@gitkraken/provider-apis'; +import type { ProviderAccount, ProviderPullRequest } from '../models'; + +export interface BitbucketServerLink { + href: string; +} + +export interface NamedBitbucketServerLink extends BitbucketServerLink { + name: T; +} + +export interface BitbucketServerPagedResponse { + values: T[]; + size: number; + limit: number; + isLastPage: boolean; + nextPageStart: number; + start: number; +} + +export interface BitbucketServerPullRequestRef { + id: string; + displayId: string; + latestCommit: string; + type: string; + repository: { + slug: string; + id: number; + name: string; + hierarchyId: string; + scmId: string; + state: string; + statusMessage: string; + forkable: boolean; + project: { + key: string; + id: number; + name: string; + public: boolean; + type: string; + links: { + self: BitbucketServerLink[]; + }; + }; + public: boolean; + archived: boolean; + links: { + clone: NamedBitbucketServerLink[]; + self: BitbucketServerLink[]; + }; + }; +} + +export interface BitbucketServerUser { + name: string; + emailAddress: string; + active: boolean; + displayName: string; + id: number; + slug: string; + type: string; + links: { + self: BitbucketServerLink[]; + }; + avatarUrl?: string; +} + +export interface BitbucketServerPullRequestUser { + user: BitbucketServerUser; + lastReviewedCommit?: string; + role: 'REVIEWER' | 'AUTHOR' | 'PARTICIPANT'; + approved: boolean; + status: 'UNAPPROVED' | 'NEEDS_WORK' | 'APPROVED'; +} + +export interface BitbucketServerPullRequest { + id: number; + version: number; + title: string; + description: string; + state: 'OPEN' | 'MERGED' | 'DECLINED'; + open: boolean; + closed: boolean; + createdDate: number; + updatedDate: number; + closedDate: number | null; + fromRef: BitbucketServerPullRequestRef; + toRef: BitbucketServerPullRequestRef; + locked: boolean; + author: BitbucketServerPullRequestUser; + reviewers: BitbucketServerPullRequestUser[]; + participants: BitbucketServerPullRequestUser[]; + properties: { + mergeResult: { + outcome: string; + current: boolean; + }; + resolvedTaskCount: number; + commentCount: number; + openTaskCount: number; + }; + links: { + self: BitbucketServerLink[]; + }; +} + +const normalizeUser = (user: BitbucketServerUser): ProviderAccount => ({ + name: user.displayName, + email: user.emailAddress, + avatarUrl: user.avatarUrl ?? null, + id: user.id.toString(), + username: user.name, + url: user.links.self[0].href, +}); + +const reviewDecisionWeightByReviewState = { + [GitPullRequestReviewState.Approved]: 0, + [GitPullRequestReviewState.Commented]: 1, + [GitPullRequestReviewState.ReviewRequested]: 2, + [GitPullRequestReviewState.ChangesRequested]: 3, +}; + +export const summarizeReviewDecision = ( + reviews: { state: GitPullRequestReviewState }[] | null, +): GitPullRequestReviewState | null => { + if (!reviews || reviews.length === 0) { + return null; + } + + return reviews.reduce( + (prev: GitPullRequestReviewState, review) => + reviewDecisionWeightByReviewState[review.state] > reviewDecisionWeightByReviewState[prev] + ? review.state + : prev, + GitPullRequestReviewState.Approved, + ); +}; + +export const normalizeBitbucketServerPullRequest = (pr: BitbucketServerPullRequest): ProviderPullRequest => { + const bitbucketStateToGitState = { + OPEN: GitPullRequestState.Open, + MERGED: GitPullRequestState.Merged, + DECLINED: GitPullRequestState.Closed, + }; + + const reviewerStatusToGitState = { + UNAPPROVED: GitPullRequestReviewState.ReviewRequested, + NEEDS_WORK: GitPullRequestReviewState.ChangesRequested, + APPROVED: GitPullRequestReviewState.Approved, + }; + + const reviews = pr.reviewers.map(reviewer => ({ + reviewer: normalizeUser(reviewer.user), + state: reviewerStatusToGitState[reviewer.status], + })); + + const baseSSHUrl = pr.toRef.repository.links.clone.find(link => link.name === 'ssh')?.href ?? null; + let baseHTTPSUrl = pr.toRef.repository.links.clone.find(link => link.name === 'https')?.href ?? null; + if (!baseHTTPSUrl) { + baseHTTPSUrl = pr.toRef.repository.links.clone.find(link => link.name === 'http')?.href ?? null; + } + + const headSSHUrl = pr.fromRef.repository.links.clone.find(link => link.name === 'ssh')?.href ?? null; + let headHTTPSUrl = pr.fromRef.repository.links.clone.find(link => link.name === 'https')?.href ?? null; + if (!headHTTPSUrl) { + headHTTPSUrl = pr.fromRef.repository.links.clone.find(link => link.name === 'http')?.href ?? null; + } + + return { + id: pr.id.toString(), + number: pr.id, + title: pr.title, + url: pr.links.self[0].href, + state: bitbucketStateToGitState[pr.state], + isDraft: false, + createdDate: new Date(pr.createdDate), + updatedDate: new Date(pr.updatedDate), + closedDate: pr.closedDate ? new Date(pr.closedDate) : null, + mergedDate: pr.state === 'MERGED' && pr.closedDate ? new Date(pr.closedDate) : null, + baseRef: { + name: pr.toRef.displayId, + oid: pr.toRef.latestCommit, + }, + headRef: { + name: pr.fromRef.displayId, + oid: pr.fromRef.latestCommit, + }, + commentCount: pr.properties?.commentCount, + upvoteCount: null, + commitCount: null, + fileCount: null, + additions: null, + deletions: null, + author: normalizeUser(pr.author.user), + assignees: null, + reviews: reviews, + reviewDecision: summarizeReviewDecision(reviews), + repository: { + id: pr.toRef.repository.id.toString(), + name: pr.toRef.repository.name, + owner: { + login: pr.toRef.repository.project.key, + }, + remoteInfo: + baseHTTPSUrl && baseSSHUrl + ? { + cloneUrlHTTPS: baseHTTPSUrl, + cloneUrlSSH: baseSSHUrl, + } + : null, + }, + headRepository: { + id: pr.fromRef.repository.id.toString(), + name: pr.fromRef.repository.name, + owner: { + login: pr.fromRef.repository.project.key, + }, + remoteInfo: + headHTTPSUrl && headSSHUrl + ? { + cloneUrlHTTPS: headHTTPSUrl, + cloneUrlSSH: headSSHUrl, + } + : null, + }, + headCommit: null, + mergeableState: GitPullRequestMergeableState.Unknown, + permissions: null, + version: pr.version, + }; +}; diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index ddc76d88b2c35..742bed51fca9a 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -279,13 +279,14 @@ export class BitbucketIntegration extends HostingIntegration< return issueResult; } + private readonly storagePrefix = 'bitbucket'; protected override async providerOnConnect(): Promise { if (this._session == null) return; const accountStorageKey = md5(this._session.accessToken); - const storedAccount = this.container.storage.get(`bitbucket:${accountStorageKey}:account`); - const storedWorkspaces = this.container.storage.get(`bitbucket:${accountStorageKey}:workspaces`); + const storedAccount = this.container.storage.get(`${this.storagePrefix}:${accountStorageKey}:account`); + const storedWorkspaces = this.container.storage.get(`${this.storagePrefix}:${accountStorageKey}:workspaces`); let account: Account | undefined = storedAccount?.data ? { ...storedAccount.data, provider: this } : undefined; let workspaces = storedWorkspaces?.data?.map(o => ({ ...o })); @@ -294,8 +295,8 @@ export class BitbucketIntegration extends HostingIntegration< account = await this.getProviderCurrentAccount(this._session); if (account != null) { // Clear all other stored workspaces and repositories and accounts when our session changes - await this.container.storage.deleteWithPrefix('bitbucket'); - await this.container.storage.store(`bitbucket:${accountStorageKey}:account`, { + await this.container.storage.deleteWithPrefix(this.storagePrefix); + await this.container.storage.store(`${this.storagePrefix}:${accountStorageKey}:account`, { v: 1, timestamp: Date.now(), data: { @@ -313,7 +314,7 @@ export class BitbucketIntegration extends HostingIntegration< if (storedWorkspaces == null) { workspaces = await this.getProviderResourcesForUser(this._session, true); - await this.container.storage.store(`bitbucket:${accountStorageKey}:workspaces`, { + await this.container.storage.store(`${this.storagePrefix}:${accountStorageKey}:workspaces`, { v: 1, timestamp: Date.now(), data: workspaces, diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index 722c183204179..2c94e8338f667 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -25,6 +25,10 @@ import { Logger } from '../../../../system/logger'; import type { LogScope } from '../../../../system/logger.scope'; import { getLogScope } from '../../../../system/logger.scope'; import { maybeStopWatch } from '../../../../system/stopwatch'; +import type { Integration } from '../../integration'; +import type { BitbucketServerPullRequest } from '../bitbucket-server/models'; +import { normalizeBitbucketServerPullRequest } from '../bitbucket-server/models'; +import { fromProviderPullRequest } from '../models'; import type { BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models'; import { bitbucketIssueStateToState, fromBitbucketIssue, fromBitbucketPullRequest } from './models'; @@ -93,6 +97,43 @@ export class BitbucketApi implements Disposable { return fromBitbucketPullRequest(response.values[0], provider); } + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getServerPullRequestForBranch( + provider: Provider, + token: string, + owner: string, + repo: string, + branch: string, + baseUrl: string, + integration: Integration, + ): Promise { + const scope = getLogScope(); + + const response = await this.request<{ + values: BitbucketServerPullRequest[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + baseUrl, + `projects/${owner}/repos/${repo}/pull-requests?at=refs/heads/${branch}&direction=OUTGOING&state=ALL`, + { + method: 'GET', + }, + scope, + ); + + if (!response?.values?.length) { + return undefined; + } + + const providersPr = normalizeBitbucketServerPullRequest(response.values[0]); + const gitlensPr = fromProviderPullRequest(providersPr, integration); + return gitlensPr; + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getUsersIssuesForRepo( provider: Provider, @@ -234,6 +275,45 @@ export class BitbucketApi implements Disposable { return undefined; } + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getServerPullRequestById( + provider: Provider, + token: string, + owner: string, + repo: string, + id: string, + baseUrl: string, + integration: Integration, + ): Promise { + const scope = getLogScope(); + + try { + const prResponse = await this.request( + provider, + token, + baseUrl, + `projects/${owner}/repos/${repo}/pull-requests/${id}`, + { + method: 'GET', + }, + scope, + ); + + if (prResponse) { + const providersPr = normalizeBitbucketServerPullRequest(prResponse); + const gitlensPr = fromProviderPullRequest(providersPr, integration); + return gitlensPr; + } + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } + + return undefined; + } + @debug({ args: { 0: p => p.name, 1: '' } }) async getRepositoriesForWorkspace( provider: Provider, diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index b0508dfd70c6c..b82c9816d5dc6 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -7,6 +7,7 @@ import type { AzureProject, AzureSetPullRequestInput, Bitbucket, + BitbucketServer, BitbucketWorkspaceStub, EnterpriseOptions, GetRepoInput, @@ -83,6 +84,7 @@ const selfHostedIntegrationIds: SelfHostedIntegrationId[] = [ SelfHostedIntegrationId.GitHubEnterprise, SelfHostedIntegrationId.CloudGitLabSelfHosted, SelfHostedIntegrationId.GitLabSelfHosted, + SelfHostedIntegrationId.BitbucketServer, ] as const; export const supportedIntegrationIds: IntegrationId[] = [ @@ -109,7 +111,11 @@ export function isHostingIntegrationId(id: IntegrationId): id is HostingIntegrat } export function isCloudSelfHostedIntegrationId(id: IntegrationId): id is CloudSelfHostedIntegrationId { - return id === SelfHostedIntegrationId.CloudGitHubEnterprise || id === SelfHostedIntegrationId.CloudGitLabSelfHosted; + return ( + id === SelfHostedIntegrationId.CloudGitHubEnterprise || + id === SelfHostedIntegrationId.CloudGitLabSelfHosted || + id === SelfHostedIntegrationId.BitbucketServer + ); } export const enum PullRequestFilter { @@ -255,6 +261,7 @@ export type MergePullRequestFn = headRef: { oid: string | null; } | null; + version?: number; // Used by BitbucketServer } & SetPullRequestInput; mergeStrategy?: GitMergeStrategy; }, @@ -357,6 +364,16 @@ export type GetBitbucketPullRequestsAuthoredByUserForWorkspaceFn = ( }; data: GitPullRequest[]; }>; +export type GetBitbucketServerPullRequestsForCurrentUserFn = ( + input: NumberedPageInput, + options?: EnterpriseOptions, +) => Promise<{ + pageInfo: { + hasNextPage: boolean; + nextPage: number | null; + }; + data: GitPullRequest[]; +}>; export type GetIssuesForProjectFn = Jira['getIssuesForProject']; export type GetIssuesForResourceForCurrentUserFn = ( input: { resourceId: string }, @@ -364,7 +381,7 @@ export type GetIssuesForResourceForCurrentUserFn = ( ) => Promise<{ data: ProviderIssue[] }>; export interface ProviderInfo extends ProviderMetadata { - provider: GitHub | GitLab | Bitbucket | Jira | Trello | AzureDevOps; + provider: GitHub | GitLab | Bitbucket | BitbucketServer | Jira | Trello | AzureDevOps; getPullRequestsForReposFn?: GetPullRequestsForReposFn; getPullRequestsForRepoFn?: GetPullRequestsForRepoFn; getPullRequestsForUserFn?: GetPullRequestsForUserFn; @@ -380,6 +397,7 @@ export interface ProviderInfo extends ProviderMetadata { getAzureResourcesForUserFn?: GetAzureResourcesForUserFn; getBitbucketResourcesForUserFn?: GetBitbucketResourcesForUserFn; getBitbucketPullRequestsAuthoredByUserForWorkspaceFn?: GetBitbucketPullRequestsAuthoredByUserForWorkspaceFn; + getBitbucketServerPullRequestsForCurrentUserFn?: GetBitbucketServerPullRequestsForCurrentUserFn; getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn; getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn; getIssuesForProjectFn?: GetIssuesForProjectFn; @@ -527,6 +545,15 @@ export const providersMetadata: ProvidersMetadata = { supportedPullRequestFilters: [PullRequestFilter.Author], scopes: ['account:read', 'repository:read', 'pullrequest:read', 'issue:read'], }, + [SelfHostedIntegrationId.BitbucketServer]: { + domain: '', + id: SelfHostedIntegrationId.BitbucketServer, + name: 'Bitbucket Data Center', + type: 'hosting', + iconKey: SelfHostedIntegrationId.BitbucketServer, + supportedPullRequestFilters: [PullRequestFilter.Author, PullRequestFilter.ReviewRequested], + scopes: ['Project (Read)', 'Repository (Write)'], + }, [HostingIntegrationId.AzureDevOps]: { domain: 'dev.azure.com', id: HostingIntegrationId.AzureDevOps, @@ -981,6 +1008,7 @@ export function fromProviderPullRequest( ? fromProviderBuildStatusState[pr.headCommit.buildStatuses[0].state] : undefined, options?.project, + pr.version, ); } diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index af969b2c4079f..c3446d09bb749 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -217,6 +217,24 @@ export class ProvidersApi { providerApis.bitbucket, ) as MergePullRequestFn, }, + [SelfHostedIntegrationId.BitbucketServer]: { + ...providersMetadata[SelfHostedIntegrationId.BitbucketServer], + provider: providerApis.bitbucketServer, + getCurrentUserFn: providerApis.bitbucketServer.getCurrentUser.bind( + providerApis.bitbucketServer, + ) as GetCurrentUserFn, + getBitbucketServerPullRequestsForCurrentUserFn: + providerApis.bitbucketServer.getPullRequestsForCurrentUser.bind(providerApis.bitbucketServer), + getPullRequestsForReposFn: providerApis.bitbucketServer.getPullRequestsForRepos.bind( + providerApis.bitbucketServer, + ) as GetPullRequestsForReposFn, + getPullRequestsForRepoFn: providerApis.bitbucketServer.getPullRequestsForRepo.bind( + providerApis.bitbucketServer, + ) as GetPullRequestsForRepoFn, + mergePullRequestFn: providerApis.bitbucketServer.mergePullRequest.bind( + providerApis.bitbucketServer, + ) as MergePullRequestFn, + }, [HostingIntegrationId.AzureDevOps]: { ...providersMetadata[HostingIntegrationId.AzureDevOps], provider: providerApis.azureDevOps, @@ -592,6 +610,26 @@ export class ProvidersApi { } } + async getBitbucketServerPullRequestsForCurrentUser( + baseUrl: string, + options?: { + accessToken?: string; + }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + SelfHostedIntegrationId.BitbucketServer, + 'getBitbucketServerPullRequestsForCurrentUserFn', + options?.accessToken, + ); + try { + return ( + await provider.getBitbucketServerPullRequestsForCurrentUserFn?.({}, { token: token, baseUrl: baseUrl }) + )?.data; + } catch (e) { + return this.handleProviderError(SelfHostedIntegrationId.BitbucketServer, token, e); + } + } + async getJiraProjectsForResources( resourceIds: string[], options?: { accessToken?: string }, @@ -807,6 +845,7 @@ export class ProvidersApi { login: pr.repository.owner, }, }, + version: pr.version, }, ...options, }, diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index 168d61ba2bddf..0ed45a49ff63a 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -62,7 +62,10 @@ export function getEntityIdentifierInput(entity: Issue | PullRequest | Launchpad if (entityType === EntityType.PullRequest && repoId == null) { throw new Error('Azure PRs must have a repository ID to be encoded'); } - } else if (provider === EntityIdentifierProviderType.Bitbucket) { + } else if ( + provider === EntityIdentifierProviderType.Bitbucket || + provider === EntityIdentifierProviderType.BitbucketServer + ) { repoId = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.repository.id : entity.repository?.id; } @@ -107,6 +110,10 @@ export function getProviderIdFromEntityIdentifier( return HostingIntegrationId.AzureDevOps; case EntityIdentifierProviderType.Bitbucket: return HostingIntegrationId.Bitbucket; + case EntityIdentifierProviderType.BitbucketServer: + return isGitConfigEntityIdentifier(entityIdentifier) && entityIdentifier.metadata.isCloudEnterprise + ? SelfHostedIntegrationId.BitbucketServer + : undefined; default: return undefined; } @@ -130,6 +137,8 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier return EntityIdentifierProviderType.Azure; case 'bitbucket': return EntityIdentifierProviderType.Bitbucket; + case 'bitbucket-server': + return EntityIdentifierProviderType.BitbucketServer; default: throw new Error(`Unknown provider type '${str}'`); } @@ -231,6 +240,7 @@ export async function getIssueFromGitConfigEntityIdentifier( identifier.provider !== EntityIdentifierProviderType.GithubEnterprise && identifier.provider !== EntityIdentifierProviderType.GitlabSelfHosted && identifier.provider !== EntityIdentifierProviderType.Bitbucket && + identifier.provider !== EntityIdentifierProviderType.BitbucketServer && identifier.provider !== EntityIdentifierProviderType.Azure ) { return undefined; diff --git a/src/plus/launchpad/enrichmentService.ts b/src/plus/launchpad/enrichmentService.ts index 8a7408f69533f..967c11680ebec 100644 --- a/src/plus/launchpad/enrichmentService.ts +++ b/src/plus/launchpad/enrichmentService.ts @@ -197,6 +197,7 @@ const supportedIntegrationIdsToEnrich: Record { ? new ThemeIcon('account') : i.author?.avatarUrl != null ? Uri.parse(i.author.avatarUrl) - : undefined, + : new ThemeIcon('account'), item: i, picked: i.graphQLId != null @@ -1496,7 +1496,7 @@ function getLaunchpadItemReviewInformation(item: LaunchpadItem): QuickPickItemOf ? new ThemeIcon('account') : review.reviewer.avatarUrl != null ? Uri.parse(review.reviewer.avatarUrl) - : undefined; + : new ThemeIcon('account'); switch (review.state) { case ProviderPullRequestReviewState.Approved: reviewLabel = `${isCurrentUser ? 'You' : review.reviewer.username} approved these changes`; @@ -1596,6 +1596,7 @@ function getOpenOnGitProviderQuickInputButton(integrationId: string): QuickInput case HostingIntegrationId.AzureDevOps: return OpenOnAzureDevOpsQuickInputButton; case HostingIntegrationId.Bitbucket: + case SelfHostedIntegrationId.BitbucketServer: return OpenOnBitbucketQuickInputButton; default: return undefined; @@ -1621,6 +1622,8 @@ function getIntegrationTitle(integrationId: string): string { return 'Azure DevOps'; case HostingIntegrationId.Bitbucket: return 'Bitbucket'; + case SelfHostedIntegrationId.BitbucketServer: + return 'Bitbucket Data Center'; default: return integrationId; } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 00d9579959319..04714e9de7a86 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -134,6 +134,7 @@ export const supportedLaunchpadIntegrations: (HostingIntegrationId | CloudSelfHo SelfHostedIntegrationId.CloudGitLabSelfHosted, HostingIntegrationId.AzureDevOps, HostingIntegrationId.Bitbucket, + SelfHostedIntegrationId.BitbucketServer, ]; type SupportedLaunchpadIntegrationIds = (typeof supportedLaunchpadIntegrations)[number]; function isSupportedLaunchpadIntegrationId(id: string): id is SupportedLaunchpadIntegrationIds {