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);
}
Loading