Skip to content

Commit

Permalink
wip: retrieves Bitbucket PRs. #4046
Browse files Browse the repository at this point in the history
  • Loading branch information
sergeibbb committed Feb 20, 2025
1 parent 2050f43 commit 00c9bf5
Show file tree
Hide file tree
Showing 10 changed files with 399 additions and 18 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 DevOps',
};

export const OpenOnWebQuickInputButton: QuickInputButton = {
iconPath: new ThemeIcon('globe'),
tooltip: 'Open on gitkraken.dev',
Expand Down
26 changes: 26 additions & 0 deletions src/constants.storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ 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}:organizations`]: Stored<StoredBitbucketOrganization[] | undefined>;
} & {
[key in `bitbucket:${string}:projects`]: Stored<StoredBitbucketProject[] | undefined>;
};

export type StoredIntegrationConfigurations = Record<string, StoredConfiguredIntegrationDescriptor[] | undefined>;
Expand Down Expand Up @@ -245,6 +249,28 @@ export interface StoredAzureProject {
resourceName: string;
}

export interface StoredBitbucketAccount {
id: string;
name: string | undefined;
username: string | undefined;
email: string | undefined;
avatarUrl: string | undefined;
}

export interface StoredBitbucketOrganization {
key: string;
id: string;
name: string;
}

export interface StoredBitbucketProject {
key: string;
id: string;
name: string;
resourceId: string;
resourceName: string;
}

export interface StoredAvatar {
uri: string;
timestamp: number;
Expand Down
218 changes: 214 additions & 4 deletions src/plus/integrations/providers/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import type {
SearchedPullRequest,
} from '../../../git/models/pullRequest';
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata';
import { getSettledValue } from '../../../system/promise';
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 });
Expand All @@ -24,6 +27,24 @@ interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
name: string;
}

interface BitbucketWorkspaceDescriptor extends ResourceDescriptor {
id: string;
name: string;
slug: string;
}

interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
id: string;
// nodeId?: string;
resourceName: string;
owner: string;
name: string;
// projectName?: string;
// url?: string;
cloneUrlHttps?: string;
cloneUrlSsh?: string;
}

export class BitbucketIntegration extends HostingIntegration<
HostingIntegrationId.Bitbucket,
BitbucketRepositoryDescriptor
Expand Down Expand Up @@ -136,11 +157,165 @@ 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 _repositories: Map<string, BitbucketRemoteRepositoryDescriptor[] | undefined> | undefined;
private get repositoryCache() {
this._repositories ??= new Map<string, BitbucketRemoteRepositoryDescriptor[] | undefined>();
return this._repositories;
}
private async getProviderProjectsForResources(
session: AuthenticationSession,
resources: BitbucketWorkspaceDescriptor[],
force: boolean = false,
): Promise<BitbucketRemoteRepositoryDescriptor[] | undefined> {
let resourcesWithoutRepositories: BitbucketWorkspaceDescriptor[] = [];
if (force) {
resourcesWithoutRepositories = resources;
} else {
for (const resource of resources) {
const resourceKey = `${session.accessToken}:${resource.id}`;
const cachedRepositories = this.repositoryCache.get(resourceKey);
if (cachedRepositories == null) {
resourcesWithoutRepositories.push(resource);
}
}
}

if (resourcesWithoutRepositories.length > 0) {
await Promise.allSettled(
resourcesWithoutRepositories.map(async resource => {
const resourceRepos = await this.getRepositoriesForWorkspace(session, resource.slug);
if (resourceRepos == null) return undefined;
this.repositoryCache.set(
`${session.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<BitbucketRemoteRepositoryDescriptor[]>((resultRepos, resource) => {
const resourceRepos = this.repositoryCache.get(`${session.accessToken}:${resource.id}`);
if (resourceRepos != null) {
resultRepos.push(...resourceRepos);
}
return resultRepos;
}, []);
}

private async getRepositoriesForWorkspace(
session: AuthenticationSession,
workspaceSlug: string,
): Promise<RepositoryMetadata[] | undefined> {
const api = await this.container.bitbucket;
return api?.getRepositoriesForWorkspace(this, session.accessToken, workspaceSlug, {
baseUrl: this.apiBaseUrl,
});
}

protected override async searchProviderMyPullRequests(
_session: AuthenticationSession,
_repos?: BitbucketRepositoryDescriptor[],
session: ProviderAuthenticationSession,
requestedRepositories?: BitbucketRepositoryDescriptor[],
): Promise<SearchedPullRequest[] | undefined> {
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 wsPrs = await this.getPullRequestsForWorkspaces(workspaces, session.accessToken);
// const prsById = new Map<string, SearchedPullRequest>();

// for (const ws of workspaces) {
// if (wsPrs) {
// for (const pr of wsPrs) {
// prsById.set(pr.id, pr);
// }
// }
// }

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, repos),
reasons: [],
}));
// // api.getAzureProjectsForResource
// // const user = await this.getProviderCurrentAccount(session);
// // if (user?.username == null) return undefined;
// const prsById = new Map<string, SearchedPullRequest>();
// return Array.from(prsById.values());
}

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

private fromBitbucketProviderPullRequest(
remotePullRequest: ProviderPullRequest,
repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
): PullRequest {
const baseRepoDescriptor = repoDescriptors.find(r => r.name === remotePullRequest.repository.name);
const headRepoDescriptor =
remotePullRequest.headRepository != null
? repoDescriptors.find(r => r.name === remotePullRequest.headRepository!.name)
: undefined;
if (baseRepoDescriptor != null) {
remotePullRequest.repository.remoteInfo = {
...remotePullRequest.repository.remoteInfo,
cloneUrlHTTPS: baseRepoDescriptor.cloneUrlHttps ?? '',
cloneUrlSSH: baseRepoDescriptor.cloneUrlSsh ?? '',
};
}

if (headRepoDescriptor != null) {
remotePullRequest.headRepository = {
...remotePullRequest.headRepository,
id: remotePullRequest.headRepository?.id ?? headRepoDescriptor.id,
name: remotePullRequest.headRepository?.name ?? headRepoDescriptor.name,
owner: {
login: remotePullRequest.headRepository?.owner.login ?? headRepoDescriptor.resourceName,
},
remoteInfo: {
...remotePullRequest.headRepository?.remoteInfo,
cloneUrlHTTPS: headRepoDescriptor.cloneUrlHttps ?? '',
cloneUrlSSH: headRepoDescriptor.cloneUrlSsh ?? '',
},
};
}
return fromProviderPullRequest(remotePullRequest, this);
}
}

const bitbucketCloudDomainRegex = /^bitbucket\.org$/i;
Expand Down
62 changes: 59 additions & 3 deletions src/plus/integrations/providers/bitbucket/bitbucket-api.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 @@ -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',
},
Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -167,6 +168,61 @@ export class BitbucketApi implements Disposable {
return undefined;
}

@debug<BitbucketApi['getRepositoriesForWorkspace']>({ args: { 0: p => p.name, 1: '<token>' } })
public 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`,
{
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
Loading

0 comments on commit 00c9bf5

Please sign in to comment.