From 5050467b90675a00b1c02bfeae1cf228e39ad373 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 21 Feb 2025 14:34:17 +0100 Subject: [PATCH] Retrieves Bitbucket PRs and shows on the Launchpad (#4046) --- src/commands/quickCommand.buttons.ts | 5 + src/plus/integrations/providers/bitbucket.ts | 165 +++++++++++++++++- .../providers/bitbucket/bitbucket-api.ts | 62 ++++++- .../providers/bitbucket/models.ts | 43 ++++- src/plus/integrations/providers/models.ts | 7 + .../integrations/providers/providersApi.ts | 26 +++ src/plus/integrations/providers/utils.ts | 4 + src/plus/launchpad/launchpad.ts | 7 + src/plus/launchpad/launchpadProvider.ts | 14 +- 9 files changed, 319 insertions(+), 14 deletions(-) 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/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 398e2fce42f1e..d48a76b08736e 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -12,9 +12,11 @@ import type { } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; +import type { ProviderAuthenticationSession } from '../authentication/models'; import type { ResourceDescriptor } from '../integration'; import { HostingIntegration } from '../integration'; -import { providersMetadata } from './models'; +import type { ProviderPullRequest } from './models'; +import { fromProviderPullRequest, providersMetadata } from './models'; const metadata = providersMetadata[HostingIntegrationId.Bitbucket]; const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes }); @@ -24,6 +26,19 @@ interface BitbucketRepositoryDescriptor extends ResourceDescriptor { name: string; } +interface BitbucketWorkspaceDescriptor extends ResourceDescriptor { + id: string; + name: string; + slug: string; +} + +interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor { + owner: string; + name: string; + cloneUrlHttps?: string; + cloneUrlSsh?: string; +} + export class BitbucketIntegration extends HostingIntegration< HostingIntegrationId.Bitbucket, BitbucketRepositoryDescriptor @@ -136,11 +151,145 @@ 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); + } + + private _repositories: Map | undefined; + private get repositoryCache() { + this._repositories ??= new Map(); + return this._repositories; + } + private async getProviderProjectsForResources( + { accessToken }: AuthenticationSession, + resources: BitbucketWorkspaceDescriptor[], + force: boolean = false, + ): Promise { + let resourcesWithoutRepositories: BitbucketWorkspaceDescriptor[] = []; + if (force) { + resourcesWithoutRepositories = resources; + } else { + for (const resource of resources) { + const resourceKey = `${accessToken}:${resource.id}`; + const cachedRepositories = this.repositoryCache.get(resourceKey); + if (cachedRepositories == null) { + resourcesWithoutRepositories.push(resource); + } + } + } + + if (resourcesWithoutRepositories.length > 0) { + const api = await this.container.bitbucket; + if (api == null) return undefined; + + await Promise.allSettled( + resourcesWithoutRepositories.map(async resource => { + const resourceRepos = await api.getRepositoriesForWorkspace(this, accessToken, resource.slug, { + baseUrl: this.apiBaseUrl, + }); + + if (resourceRepos == null) return undefined; + this.repositoryCache.set( + `${accessToken}:${resource.id}`, + resourceRepos.map(r => ({ + id: `${r.owner}/${r.name}`, + resourceName: `${r.owner}/${r.name}`, + owner: r.owner, + name: r.name, + key: `${r.owner}/${r.name}`, + })), + ); + }), + ); + } + + return resources.reduce((resultRepos, resource) => { + const resourceRepos = this.repositoryCache.get(`${accessToken}:${resource.id}`); + if (resourceRepos != null) { + resultRepos.push(...resourceRepos); + } + return resultRepos; + }, []); + } + protected override async searchProviderMyPullRequests( - _session: AuthenticationSession, - _repos?: BitbucketRepositoryDescriptor[], + session: ProviderAuthenticationSession, + requestedRepositories?: BitbucketRepositoryDescriptor[], ): Promise { - return Promise.resolve(undefined); + const api = await this.getProvidersApi(); + if (requestedRepositories != null) { + // TODO: implement repos version + return 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 repos = await this.getProviderProjectsForResources(session, workspaces); + if (repos == null || repos.length === 0) return undefined; + + const prs = await api.getPullRequestsForRepos( + HostingIntegrationId.Bitbucket, + repos.map(repo => ({ namespace: repo.owner, name: repo.name })), + { + accessToken: session.accessToken, + }, + ); + return prs.values.map(pr => ({ + pullRequest: this.fromBitbucketProviderPullRequest(pr), + reasons: [], + })); } protected override async searchProviderMyIssues( @@ -149,6 +298,14 @@ export class BitbucketIntegration extends HostingIntegration< ): Promise { return Promise.resolve(undefined); } + + private fromBitbucketProviderPullRequest( + remotePullRequest: ProviderPullRequest, + // repoDescriptors: BitbucketRemoteRepositoryDescriptor[], + ): PullRequest { + remotePullRequest.graphQLId = remotePullRequest.id; + return fromProviderPullRequest(remotePullRequest, this); + } } const bitbucketCloudDomainRegex = /^bitbucket\.org$/i; diff --git a/src/plus/integrations/providers/bitbucket/bitbucket-api.ts b/src/plus/integrations/providers/bitbucket/bitbucket-api.ts index 2841371058743..71619f5e2b0f6 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket-api.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket-api.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 { @@ -80,7 +81,7 @@ export class BitbucketApi implements Disposable { provider, token, options.baseUrl, - `repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=values.*`, + `repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=values.*`, // TODO: be more precise on additional fields. look at getRepositoriesForWorkspace { method: 'GET', }, @@ -113,7 +114,7 @@ export class BitbucketApi implements Disposable { provider, token, options.baseUrl, - `repositories/${owner}/${repo}/pullrequests/${id}?fields=*`, + `repositories/${owner}/${repo}/pullrequests/${id}?fields=*`, // TODO: be more precise on additional fields. look at getRepositoriesForWorkspace { method: 'GET', }, @@ -167,6 +168,61 @@ export class BitbucketApi implements Disposable { return undefined; } + @debug({ args: { 0: p => p.name, 1: '' } }) + public 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; + } + } + 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 bd65f891f95e0..7f0f02e7790e2 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -24,6 +24,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 +57,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; diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index d22f7eaefc02e..5df2fb3e4fb3d 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..69e6162111122 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, @@ -40,6 +41,7 @@ import type { ProviderAccount, ProviderAzureProject, ProviderAzureResource, + ProviderBitbucketResource, ProviderInfo, ProviderIssue, ProviderJiraProject, @@ -196,6 +198,9 @@ 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, @@ -534,6 +539,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 8ed0430557783..04077768e1ed2 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -747,10 +747,15 @@ export class LaunchpadProvider implements Disposable { const providerId = pr.pullRequest.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; } @@ -763,6 +768,7 @@ export class LaunchpadProvider implements Disposable { providerId === HostingIntegrationId.AzureDevOps ? convertIntegrationIdToEnrichProvider(providerId) : convertRemoteProviderIdToEnrichProvider(providerId), + provider: enrichProviderId, } satisfies EnrichableItem; const repoIdentity = getRepositoryIdentityForPullRequest(pr.pullRequest);