Skip to content

Bitbucket: Launchpad #4099

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 5, 2025
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/commands/quickCommand.buttons.ts
Original file line number Diff line number Diff line change
@@ -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',
17 changes: 17 additions & 0 deletions src/constants.storage.ts
Original file line number Diff line number Diff line change
@@ -93,6 +93,8 @@ export type GlobalStorage = {
[key in `azure:${string}:organizations`]: Stored<StoredAzureOrganization[] | undefined>;
} & {
[key in `azure:${string}:projects`]: Stored<StoredAzureProject[] | undefined>;
} & { [key in `bitbucket:${string}:account`]: Stored<StoredBitbucketAccount | undefined> } & {
[key in `bitbucket:${string}:workspaces`]: Stored<StoredBitbucketWorkspace[] | undefined>;
};

export type StoredIntegrationConfigurations = Record<string, StoredConfiguredIntegrationDescriptor[] | undefined>;
@@ -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;
4 changes: 4 additions & 0 deletions src/plus/drafts/draftsService.ts
Original file line number Diff line number Diff line change
@@ -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,
});
183 changes: 170 additions & 13 deletions src/plus/integrations/providers/bitbucket.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
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';
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<boolean> {
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<string, Account | undefined> | undefined;
protected override async getProviderCurrentAccount({
accessToken,
}: AuthenticationSession): Promise<Account | undefined> {
this._accounts ??= new Map<string, Account | undefined>();

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<string, BitbucketWorkspaceDescriptor[] | undefined> | undefined;
private async getProviderResourcesForUser(
session: AuthenticationSession,
force: boolean = false,
): Promise<BitbucketWorkspaceDescriptor[] | undefined> {
this._workspaces ??= new Map<string, BitbucketWorkspaceDescriptor[] | undefined>();
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<PullRequest[] | undefined> {
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<IssueShape[] | undefined> {
return Promise.resolve(undefined);
}

protected override async providerOnConnect(): Promise<void> {
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<string, Account | undefined>();
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<string, BitbucketWorkspaceDescriptor[] | undefined>();
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<T> = Promise<T | undefined>[] | (T | undefined)[];

async function nonnullSettled<T>(arr: MaybePromiseArr<T>): Promise<T[]> {
const all = await Promise.allSettled(arr);
return all.map(r => getSettledValue(r)).filter(v => v != null);
}

async function flatSettled<T>(arr: MaybePromiseArr<(T | undefined)[]>): Promise<T[]> {
const all = await nonnullSettled(arr);
return all.flat().filter(v => v != null);
}
122 changes: 121 additions & 1 deletion src/plus/integrations/providers/bitbucket/bitbucket.ts
Original file line number Diff line number Diff line change
@@ -16,14 +16,15 @@ 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';
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<BitbucketApi['getRepositoriesForWorkspace']>({ args: { 0: p => p.name, 1: '<token>' } })
async getRepositoriesForWorkspace(
provider: Provider,
token: string,
workspace: string,
options: {
baseUrl: string;
},
): Promise<RepositoryMetadata[] | undefined> {
const scope = getLogScope();

try {
interface BitbucketRepositoriesResponse {
size: number;
page: number;
pagelen: number;
next?: string;
previous?: string;
values: BitbucketRepository[];
}

const response = await this.request<BitbucketRepositoriesResponse>(
provider,
token,
options.baseUrl,
`repositories/${workspace}?role=contributor&fields=%2Bvalues.parent.workspace`, // field=+<field> must be encoded as field=%2B<field>
{
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<PullRequest[] | undefined> {
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<PullRequest[] | undefined> {
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<T>(
provider: Provider,
token: string,
69 changes: 62 additions & 7 deletions src/plus/integrations/providers/bitbucket/models.ts
Original file line number Diff line number Diff line change
@@ -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)),
7 changes: 7 additions & 0 deletions src/plus/integrations/providers/models.ts
Original file line number Diff line number Diff line change
@@ -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;
30 changes: 30 additions & 0 deletions src/plus/integrations/providers/providersApi.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderBitbucketResource[] | undefined> {
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<ProviderBitbucketResource[] | undefined>(
HostingIntegrationId.Bitbucket,
token,
e,
);
}
}

async getJiraProjectsForResources(
resourceIds: string[],
options?: { accessToken?: string },
4 changes: 4 additions & 0 deletions src/plus/integrations/providers/utils.ts
Original file line number Diff line number Diff line change
@@ -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}'`);
}
7 changes: 7 additions & 0 deletions src/plus/launchpad/launchpad.ts
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ import {
LearnAboutProQuickInputButton,
MergeQuickInputButton,
OpenOnAzureDevOpsQuickInputButton,
OpenOnBitbucketQuickInputButton,
OpenOnGitHubQuickInputButton,
OpenOnGitLabQuickInputButton,
OpenOnWebQuickInputButton,
@@ -835,6 +836,7 @@ export class LaunchpadCommand extends QuickCommand<State> {
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<State> {
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;
}
19 changes: 11 additions & 8 deletions src/plus/launchpad/launchpadProvider.ts
Original file line number Diff line number Diff line change
@@ -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);