Skip to content

Commit

Permalink
Retrieves Bitbucket PRs and shows on the Launchpad
Browse files Browse the repository at this point in the history
  • Loading branch information
sergeibbb committed Mar 5, 2025
1 parent 1a16632 commit e261b86
Show file tree
Hide file tree
Showing 9 changed files with 320 additions and 22 deletions.
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
154 changes: 144 additions & 10 deletions src/plus/integrations/providers/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,19 @@ import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/mo
import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest';
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata';
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider';
import type { ResourceDescriptor } from '../integration';
import type { ProviderAuthenticationSession } from '../authentication/models';
import { HostingIntegration } from '../integration';
import { providersMetadata } from './models';
import type {
BitbucketRemoteRepositoryDescriptor,
BitbucketRepositoryDescriptor,
BitbucketWorkspaceDescriptor,
} from './bitbucket/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 });

interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
owner: string;
name: string;
}

export class BitbucketIntegration extends HostingIntegration<
HostingIntegrationId.Bitbucket,
BitbucketRepositoryDescriptor
Expand Down Expand Up @@ -136,11 +137,136 @@ 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);
}

private async getProviderProjectsForResources(
{ accessToken }: AuthenticationSession,
resources: BitbucketWorkspaceDescriptor[],
force: boolean = false,
): Promise<BitbucketRemoteRepositoryDescriptor[] | undefined> {
const repositories = new Map<string, BitbucketRemoteRepositoryDescriptor[] | undefined>();
let resourcesWithoutRepositories: BitbucketWorkspaceDescriptor[] = [];
if (force) {
resourcesWithoutRepositories = resources;
} else {
for (const resource of resources) {
const resourceKey = `${accessToken}:${resource.id}`;
const cachedRepositories = repositories.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;
repositories.set(
`${accessToken}:${resource.id}`,
resourceRepos.map(r => ({
id: `${r.owner}/${r.name}`,
owner: r.owner,
name: r.name,
key: `${r.owner}/${r.name}`,
})),
);
}),
);
}

return resources.reduce<BitbucketRemoteRepositoryDescriptor[]>((resultRepos, resource) => {
const resourceRepos = repositories.get(`${accessToken}:${resource.id}`);
if (resourceRepos != null) {
resultRepos.push(...resourceRepos);
}
return resultRepos;
}, []);
}

protected override async searchProviderMyPullRequests(
_session: AuthenticationSession,
_repos?: BitbucketRepositoryDescriptor[],
session: ProviderAuthenticationSession,
repos?: BitbucketRepositoryDescriptor[],
): Promise<PullRequest[] | undefined> {
return Promise.resolve(undefined);
const api = await this.getProvidersApi();
if (repos != 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 allBitbucketRepos = await this.getProviderProjectsForResources(session, workspaces);
if (allBitbucketRepos == null || allBitbucketRepos.length === 0) return undefined;

const prs = await api.getPullRequestsForRepos(
HostingIntegrationId.Bitbucket,
allBitbucketRepos.map(repo => ({ namespace: repo.owner, name: repo.name })),
{
accessToken: session.accessToken,
},
);
return prs.values.map(pr => this.fromBitbucketProviderPullRequest(pr));
}

protected override async searchProviderMyIssues(
Expand All @@ -149,6 +275,14 @@ export class BitbucketIntegration extends HostingIntegration<
): Promise<IssueShape[] | undefined> {
return Promise.resolve(undefined);
}

private fromBitbucketProviderPullRequest(
remotePullRequest: ProviderPullRequest,
// repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
): PullRequest {
remotePullRequest.graphQLId = remotePullRequest.id;
return fromProviderPullRequest(remotePullRequest, this);
}
}

const bitbucketCloudDomainRegex = /^bitbucket\.org$/i;
Expand Down
58 changes: 57 additions & 1 deletion src/plus/integrations/providers/bitbucket/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -165,6 +166,61 @@ 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;
}
}

private async request<T>(
provider: Provider,
token: string,
Expand Down
62 changes: 59 additions & 3 deletions src/plus/integrations/providers/bitbucket/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@ import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPull
import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest';
import { PullRequest, 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 interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
owner: string;
name: string;
cloneUrlHttps?: string;
cloneUrlSsh?: string;
}

export type BitbucketPullRequestState = 'OPEN' | 'DECLINED' | 'MERGED' | 'SUPERSEDED';

Expand All @@ -24,6 +43,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;
Expand All @@ -33,15 +76,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;
Expand Down
7 changes: 7 additions & 0 deletions src/plus/integrations/providers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AzureProject,
AzureSetPullRequestInput,
Bitbucket,
BitbucketWorkspaceStub,
EnterpriseOptions,
GetRepoInput,
GitHub,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand All @@ -357,6 +363,7 @@ export interface ProviderInfo extends ProviderMetadata {
getCurrentUserForResourceFn?: GetCurrentUserForResourceFn;
getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn;
getAzureResourcesForUserFn?: GetAzureResourcesForUserFn;
getBitbucketResourcesForUserFn?: GetBitbucketResourcesForUserFn;
getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn;
getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn;
getIssuesForProjectFn?: GetIssuesForProjectFn;
Expand Down
Loading

0 comments on commit e261b86

Please sign in to comment.