Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/commands/quickCommand.buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
17 changes: 17 additions & 0 deletions src/constants.storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/plus/drafts/draftsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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