diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dba548b03aa..031f15f13edef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ 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 by showing enriched links to PRs and issues [#4045](https://github.com/gitkraken/vscode-gitlens/issues/4045) +- Adds integration with Bitbucket Cloud ([#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) - 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/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts index 258dbcb6e4681..d69a0e2fe7d2c 100644 --- a/src/commands/quickCommand.buttons.ts +++ b/src/commands/quickCommand.buttons.ts @@ -152,6 +152,11 @@ export const OpenOnAzureDevOpsQuickInputButton: QuickInputButton = { tooltip: 'Open on Azure DevOps', }; +export const OpenOnBitbucketQuickInputButton: QuickInputButton = { + iconPath: new ThemeIcon('globe'), + tooltip: 'Open on Bitbucket', +}; + 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 5b825d69e8543..3684499889f86 100644 --- a/src/constants.storage.ts +++ b/src/constants.storage.ts @@ -93,6 +93,8 @@ export type GlobalStorage = { [key in `azure:${string}:organizations`]: Stored; } & { [key in `azure:${string}:projects`]: Stored; +} & { [key in `bitbucket:${string}:account`]: Stored } & { + [key in `bitbucket:${string}:workspaces`]: Stored; }; export type StoredIntegrationConfigurations = Record; @@ -245,6 +247,21 @@ export interface StoredAzureProject { resourceName: string; } +export interface StoredBitbucketAccount { + id: string; + name: string | undefined; + username: string | undefined; + email: string | undefined; + avatarUrl: string | undefined; +} + +export interface StoredBitbucketWorkspace { + key: string; + id: string; + name: string; + slug: string; +} + export interface StoredAvatar { uri: string; timestamp: number; diff --git a/src/plus/drafts/draftsService.ts b/src/plus/drafts/draftsService.ts index 5a5d69ffbabfd..1a05a2605982d 100644 --- a/src/plus/drafts/draftsService.ts +++ b/src/plus/drafts/draftsService.ts @@ -856,6 +856,10 @@ export class DraftService implements Disposable { return EntityIdentifierUtils.encode(getEntityIdentifierInput(pr)); }); + if (prEntityIds.length === 0) { + return {}; + } + const body = JSON.stringify({ prEntityIds: prEntityIds, }); diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 601de2af18220..5490dbb583479 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -1,4 +1,5 @@ import type { AuthenticationSession, CancellationToken } from 'vscode'; +import { md5 } from '@env/crypto'; import { HostingIntegrationId } from '../../../constants.integrations'; import type { Account } from '../../../git/models/author'; import type { DefaultBranch } from '../../../git/models/defaultBranch'; @@ -6,19 +7,17 @@ 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 { uniqueBy } from '../../../system/iterable'; +import { getSettledValue } from '../../../system/promise'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; -import type { ResourceDescriptor } from '../integration'; +import type { ProviderAuthenticationSession } from '../authentication/models'; import { HostingIntegration } from '../integration'; +import type { BitbucketRepositoryDescriptor, BitbucketWorkspaceDescriptor } from './bitbucket/models'; import { providersMetadata } from './models'; const metadata = providersMetadata[HostingIntegrationId.Bitbucket]; const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); -interface BitbucketRepositoryDescriptor extends ResourceDescriptor { - owner: string; - name: string; -} - export class BitbucketIntegration extends HostingIntegration< HostingIntegrationId.Bitbucket, BitbucketRepositoryDescriptor @@ -36,13 +35,17 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async mergeProviderPullRequest( - _session: AuthenticationSession, - _pr: PullRequest, - _options?: { + { accessToken }: AuthenticationSession, + pr: PullRequest, + options?: { mergeMethod?: PullRequestMergeMethod; }, ): Promise { - return Promise.resolve(false); + const api = await this.getProvidersApi(); + return api.mergePullRequest(this.id, pr, { + accessToken: accessToken, + mergeMethod: options?.mergeMethod, + }); } protected override async getProviderAccountForCommit( @@ -136,11 +139,104 @@ export class BitbucketIntegration extends HostingIntegration< 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 }); + 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 _workspaces: Map | undefined; + private async getProviderResourcesForUser( + session: AuthenticationSession, + force: boolean = false, + ): Promise { + this._workspaces ??= new Map(); + const { accessToken } = session; + const cachedResources = this._workspaces.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.getBitbucketResourcesForUser(account.id, { accessToken: accessToken }); + this._workspaces.set( + accessToken, + resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined, + ); + } + + return this._workspaces.get(accessToken); + } + protected override async searchProviderMyPullRequests( - _session: AuthenticationSession, - _repos?: BitbucketRepositoryDescriptor[], + session: ProviderAuthenticationSession, + repos?: BitbucketRepositoryDescriptor[], ): Promise { - return Promise.resolve(undefined); + if (repos != null) { + // TODO: implement repos version + return undefined; + } + + const remotes = await flatSettled(this.container.git.openRepositories.map(r => r.git.remotes().getRemotes())); + const workspaceRepos = await nonnullSettled( + remotes.map(async r => ((await r.getIntegration())?.id === this.id ? r.path : undefined)), + ); + + const user = await this.getProviderCurrentAccount(session); + if (user?.username == null) return undefined; + + const workspaces = await this.getProviderResourcesForUser(session); + if (workspaces == null || workspaces.length === 0) return undefined; + + const api = await this.container.bitbucket; + if (!api) return undefined; + + const authoredPrs = workspaces.map(ws => + api.getPullRequestsForWorkspaceAuthoredByUser(this, session.accessToken, user.id, ws.slug, this.apiBaseUrl), + ); + + const reviewingPrs = workspaceRepos.map(repo => { + const [owner, name] = repo.split('/'); + return api.getUsersReviewingPullRequestsForRepo( + this, + session.accessToken, + user.id, + owner, + name, + this.apiBaseUrl, + ); + }); + + return [ + ...uniqueBy( + await flatSettled([...authoredPrs, ...reviewingPrs]), + pr => pr.url, + (orig, _cur) => orig, + ), + ]; } protected override async searchProviderMyIssues( @@ -149,9 +245,70 @@ export class BitbucketIntegration extends HostingIntegration< ): Promise { return Promise.resolve(undefined); } + + 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`); + + let account: Account | undefined = storedAccount?.data ? { ...storedAccount.data, provider: this } : undefined; + let workspaces = storedWorkspaces?.data?.map(o => ({ ...o })); + + 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('bitbucket'); + await this.container.storage.store(`bitbucket:${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); + + if (storedWorkspaces == null) { + workspaces = await this.getProviderResourcesForUser(this._session, true); + await this.container.storage.store(`bitbucket:${accountStorageKey}:workspaces`, { + v: 1, + timestamp: Date.now(), + data: workspaces, + }); + } + this._workspaces ??= new Map(); + this._workspaces.set(this._session.accessToken, workspaces); + } + + protected override providerOnDisconnect(): void { + this._accounts = undefined; + this._workspaces = undefined; + } } 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 a3a3e700b773d..776bc7d6507a3 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -16,6 +16,7 @@ import { import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; +import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; import { configuration } from '../../../../system/-webview/configuration'; import { debug } from '../../../../system/decorators/log'; @@ -23,7 +24,7 @@ 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 { BitbucketIssue, BitbucketPullRequest } from './models'; +import type { BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models'; import { bitbucketIssueStateToState, fromBitbucketPullRequest } from './models'; export class BitbucketApi implements Disposable { @@ -165,6 +166,125 @@ export class BitbucketApi implements Disposable { return undefined; } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getRepositoriesForWorkspace( + provider: Provider, + token: string, + workspace: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + + try { + interface BitbucketRepositoriesResponse { + size: number; + page: number; + pagelen: number; + next?: string; + previous?: string; + values: BitbucketRepository[]; + } + + const response = await this.request( + provider, + token, + options.baseUrl, + `repositories/${workspace}?role=contributor&fields=%2Bvalues.parent.workspace`, // field=+ must be encoded as field=%2B + { + method: 'GET', + }, + scope, + ); + + if (response) { + return response.values.map(repo => { + return { + provider: provider, + owner: repo.workspace.slug, + name: repo.slug, + isFork: Boolean(repo.parent), + parent: repo.parent + ? { + owner: repo.parent.workspace.slug, + name: repo.parent.slug, + } + : undefined, + }; + }); + } + return undefined; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + async getPullRequestsForWorkspaceAuthoredByUser( + provider: Provider, + token: string, + userUuid: string, + workspace: string, + baseUrl: string, + ): Promise { + const scope = getLogScope(); + + const response = await this.request<{ + values: BitbucketPullRequest[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + baseUrl, + `workspaces/${workspace}/pullrequests/${userUuid}?state=OPEN&fields=%2Bvalues.reviewers,%2Bvalues.participants`, + { + method: 'GET', + }, + scope, + ); + + if (!response?.values?.length) { + return undefined; + } + return response.values.map(pr => fromBitbucketPullRequest(pr, provider)); + } + + async getUsersReviewingPullRequestsForRepo( + provider: Provider, + token: string, + userUuid: string, + owner: string, + repo: string, + baseUrl: string, + ): Promise { + const scope = getLogScope(); + + const query = encodeURIComponent(`state="OPEN" AND reviewers.uuid="${userUuid}"`); + const response = await this.request<{ + values: BitbucketPullRequest[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/pullrequests?q=${query}&state=OPEN&fields=%2Bvalues.reviewers,%2Bvalues.participants`, + { + method: 'GET', + }, + scope, + ); + + if (!response?.values?.length) { + return undefined; + } + return response.values.map(pr => fromBitbucketPullRequest(pr, provider)); + } + private async request( provider: Provider, token: string, diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts index b64359ac1fbc7..061dfcb6a1abf 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -1,8 +1,25 @@ import { RepositoryAccessLevel } from '../../../../git/models/issue'; import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPullRequest'; import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest'; -import { PullRequest, PullRequestReviewDecision, PullRequestReviewState } from '../../../../git/models/pullRequest'; +import { + PullRequest, + PullRequestMergeableState, + PullRequestReviewDecision, + PullRequestReviewState, +} from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; +import type { ResourceDescriptor } from '../../integration'; + +export interface BitbucketRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; +} + +export interface BitbucketWorkspaceDescriptor extends ResourceDescriptor { + id: string; + name: string; + slug: string; +} export type BitbucketPullRequestState = 'OPEN' | 'DECLINED' | 'MERGED' | 'SUPERSEDED'; @@ -24,6 +41,30 @@ interface BitbucketUser { }; } +interface BitbucketWorkspace { + type: 'workspace'; + uuid: string; + name: string; + slug: string; + links: { + self: BitbucketLink; + html: BitbucketLink; + avatar: BitbucketLink; + }; +} + +interface BitbucketProject { + type: 'project'; + key: string; + uuid: string; + name: string; + links: { + self: BitbucketLink; + html: BitbucketLink; + avatar: BitbucketLink; + }; +} + interface BitbucketPullRequestParticipant { type: 'participant'; user: BitbucketUser; @@ -33,15 +74,28 @@ interface BitbucketPullRequestParticipant { participated_on: null | string; } -interface BitbucketRepository { +export interface BitbucketRepository { type: 'repository'; uuid: string; full_name: string; name: string; + slug: string; description?: string; + is_private: boolean; + parent: null | BitbucketRepository; + scm: 'git'; + owner: BitbucketUser; + workspace: BitbucketWorkspace; + project: BitbucketProject; + created_on: string; + updated_on: string; + size: number; + language: string; + has_issues: boolean; + has_wiki: boolean; + fork_policy: 'allow_forks' | 'no_public_forks' | 'no_forks'; + website: string; mainbranch?: BitbucketBranch; - parent?: BitbucketRepository; - owner?: BitbucketUser; links: { self: BitbucketLink; html: BitbucketLink; @@ -210,7 +264,7 @@ export function fromBitbucketParticipantToReviewer( ? PullRequestReviewState.Commented : prt.user.uuid === closedBy?.uuid && prState === 'DECLINED' ? PullRequestReviewState.Dismissed - : PullRequestReviewState.Pending, + : PullRequestReviewState.ReviewRequested, }; } @@ -261,7 +315,8 @@ export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Pro new Date(pr.updated_on), pr.closed_by ? new Date(pr.updated_on) : undefined, pr.state === 'MERGED' ? new Date(pr.updated_on) : undefined, - undefined, // mergeableState + // TODO: Remove this assumption once actual mergeable state is available + PullRequestMergeableState.Mergeable, // mergeableState undefined, // viewerCanUpdate { base: { @@ -291,7 +346,7 @@ export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Pro pr.participants // reviewRequests:PullRequestReviewer[] ?.filter(prt => prt.role === 'REVIEWER') .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)) - .filter(rv => rv.state === PullRequestReviewState.Pending), + .filter(rv => rv.state === PullRequestReviewState.ReviewRequested), pr.participants // latestReviews:PullRequestReviewer[] ?.filter(prt => prt.participated_on != null) .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)), diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index dde5b82adc0ad..2b5d342053530 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -7,6 +7,7 @@ import type { AzureProject, AzureSetPullRequestInput, Bitbucket, + BitbucketWorkspaceStub, EnterpriseOptions, GetRepoInput, GitHub, @@ -69,6 +70,7 @@ export type ProviderJiraProject = JiraProject; export type ProviderJiraResource = JiraResource; export type ProviderAzureProject = AzureProject; export type ProviderAzureResource = AzureOrganization; +export type ProviderBitbucketResource = BitbucketWorkspaceStub; export const ProviderPullRequestReviewState = GitPullRequestReviewState; export const ProviderBuildStatusState = GitBuildStatusState; export type ProviderRequestFunction = RequestFunction; @@ -336,6 +338,10 @@ export type GetAzureProjectsForResourceFn = ( input: { namespace: string; cursor?: string }, options?: EnterpriseOptions, ) => Promise<{ data: AzureProject[]; pageInfo?: PageInfo }>; +export type GetBitbucketResourcesForUserFn = ( + input: { userId: string }, + options?: EnterpriseOptions, +) => Promise<{ data: BitbucketWorkspaceStub[] }>; export type GetIssuesForProjectFn = Jira['getIssuesForProject']; export type GetIssuesForResourceForCurrentUserFn = ( input: { resourceId: string }, @@ -357,6 +363,7 @@ export interface ProviderInfo extends ProviderMetadata { getCurrentUserForResourceFn?: GetCurrentUserForResourceFn; getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn; getAzureResourcesForUserFn?: GetAzureResourcesForUserFn; + getBitbucketResourcesForUserFn?: GetBitbucketResourcesForUserFn; getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn; getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn; getIssuesForProjectFn?: GetIssuesForProjectFn; diff --git a/src/plus/integrations/providers/providersApi.ts b/src/plus/integrations/providers/providersApi.ts index 5f8d2f8001fcf..36f92accfb6aa 100644 --- a/src/plus/integrations/providers/providersApi.ts +++ b/src/plus/integrations/providers/providersApi.ts @@ -19,6 +19,7 @@ import type { IntegrationAuthenticationService } from '../authentication/integra import type { GetAzureProjectsForResourceFn, GetAzureResourcesForUserFn, + GetBitbucketResourcesForUserFn, GetCurrentUserFn, GetCurrentUserForInstanceFn, GetIssueFn, @@ -35,11 +36,13 @@ import type { GetReposForAzureProjectFn, GetReposOptions, IssueFilter, + MergePullRequestFn, PageInfo, PagingMode, ProviderAccount, ProviderAzureProject, ProviderAzureResource, + ProviderBitbucketResource, ProviderInfo, ProviderIssue, ProviderJiraProject, @@ -196,12 +199,18 @@ export class ProvidersApi { getCurrentUserFn: providerApis.bitbucket.getCurrentUser.bind( providerApis.bitbucket, ) as GetCurrentUserFn, + getBitbucketResourcesForUserFn: providerApis.bitbucket.getWorkspacesForUser.bind( + providerApis.bitbucket, + ) as GetBitbucketResourcesForUserFn, getPullRequestsForReposFn: providerApis.bitbucket.getPullRequestsForRepos.bind( providerApis.bitbucket, ) as GetPullRequestsForReposFn, getPullRequestsForRepoFn: providerApis.bitbucket.getPullRequestsForRepo.bind( providerApis.bitbucket, ) as GetPullRequestsForRepoFn, + mergePullRequestFn: providerApis.bitbucket.mergePullRequest.bind( + providerApis.bitbucket, + ) as MergePullRequestFn, }, [HostingIntegrationId.AzureDevOps]: { ...providersMetadata[HostingIntegrationId.AzureDevOps], @@ -534,6 +543,27 @@ export class ProvidersApi { } } + async getBitbucketResourcesForUser( + userId: string, + options?: { accessToken?: string }, + ): Promise { + const { provider, token } = await this.ensureProviderTokenAndFunction( + HostingIntegrationId.Bitbucket, + 'getBitbucketResourcesForUserFn', + options?.accessToken, + ); + + try { + return (await provider.getBitbucketResourcesForUserFn?.({ userId: userId }, { token: token }))?.data; + } catch (e) { + return this.handleProviderError( + HostingIntegrationId.Bitbucket, + token, + e, + ); + } + } + async getJiraProjectsForResources( resourceIds: string[], options?: { accessToken?: string }, diff --git a/src/plus/integrations/providers/utils.ts b/src/plus/integrations/providers/utils.ts index b872fcc0f16b3..e756267598cdd 100644 --- a/src/plus/integrations/providers/utils.ts +++ b/src/plus/integrations/providers/utils.ts @@ -62,6 +62,8 @@ 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) { + repoId = isLaunchpadItem(entity) ? entity.underlyingPullRequest?.repository.id : entity.repository?.id; } let entityId = isLaunchpadItem(entity) ? entity.graphQLId! : entity.nodeId!; @@ -124,6 +126,8 @@ function fromStringToEntityIdentifierProviderType(str: string): EntityIdentifier case 'azureDevOps': case 'azure-devops': return EntityIdentifierProviderType.Azure; + case 'bitbucket': + return EntityIdentifierProviderType.Bitbucket; default: throw new Error(`Unknown provider type '${str}'`); } diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 81cbaa6e97e45..58af70ff2b233 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -23,6 +23,7 @@ import { LearnAboutProQuickInputButton, MergeQuickInputButton, OpenOnAzureDevOpsQuickInputButton, + OpenOnBitbucketQuickInputButton, OpenOnGitHubQuickInputButton, OpenOnGitLabQuickInputButton, OpenOnWebQuickInputButton, @@ -835,6 +836,7 @@ export class LaunchpadCommand extends QuickCommand { case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: case OpenOnAzureDevOpsQuickInputButton: + case OpenOnBitbucketQuickInputButton: this.sendItemActionTelemetry('soft-open', item, group, context); this.container.launchpad.open(item); break; @@ -1102,6 +1104,7 @@ export class LaunchpadCommand extends QuickCommand { case OpenOnGitHubQuickInputButton: case OpenOnGitLabQuickInputButton: case OpenOnAzureDevOpsQuickInputButton: + case OpenOnBitbucketQuickInputButton: this.sendItemActionTelemetry('soft-open', state.item, state.item.group, context); this.container.launchpad.open(state.item); break; @@ -1593,6 +1596,8 @@ function getOpenOnGitProviderQuickInputButton(integrationId: string): QuickInput return OpenOnGitHubQuickInputButton; case HostingIntegrationId.AzureDevOps: return OpenOnAzureDevOpsQuickInputButton; + case HostingIntegrationId.Bitbucket: + return OpenOnBitbucketQuickInputButton; default: return undefined; } @@ -1615,6 +1620,8 @@ function getIntegrationTitle(integrationId: string): string { return 'GitHub'; case HostingIntegrationId.AzureDevOps: return 'Azure DevOps'; + case HostingIntegrationId.Bitbucket: + return 'Bitbucket'; default: return integrationId; } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 6b44e65ced8bb..3dd752cec9846 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -133,6 +133,7 @@ export const supportedLaunchpadIntegrations: (HostingIntegrationId | CloudSelfHo HostingIntegrationId.GitLab, SelfHostedIntegrationId.CloudGitLabSelfHosted, HostingIntegrationId.AzureDevOps, + HostingIntegrationId.Bitbucket, ]; type SupportedLaunchpadIntegrationIds = (typeof supportedLaunchpadIntegrations)[number]; function isSupportedLaunchpadIntegrationId(id: string): id is SupportedLaunchpadIntegrationIds { @@ -749,10 +750,15 @@ export class LaunchpadProvider implements Disposable { const providerId = pr.provider.id; - if ( - !isSupportedLaunchpadIntegrationId(providerId) || - (!isEnrichableRemoteProviderId(providerId) && !isEnrichableIntegrationId(providerId)) - ) { + const enrichProviderId = !isSupportedLaunchpadIntegrationId(providerId) + ? undefined + : isEnrichableIntegrationId(providerId) + ? convertIntegrationIdToEnrichProvider(providerId) + : isEnrichableRemoteProviderId(providerId) + ? convertRemoteProviderIdToEnrichProvider(providerId) + : undefined; + + if (!enrichProviderId) { Logger.warn(`Unsupported provider ${providerId}`); return undefined; } @@ -761,10 +767,7 @@ export class LaunchpadProvider implements Disposable { type: 'pr', id: providerPr.uuid, url: pr.url, - provider: - providerId === HostingIntegrationId.AzureDevOps - ? convertIntegrationIdToEnrichProvider(providerId) - : convertRemoteProviderIdToEnrichProvider(providerId), + provider: enrichProviderId, } satisfies EnrichableItem; const repoIdentity = getRepositoryIdentityForPullRequest(pr);