Skip to content

Commit 875545a

Browse files
committed
Retrieves Bitbucket PRs and shows on the Launchpad
(#4046)
1 parent ddf4e4f commit 875545a

File tree

9 files changed

+311
-16
lines changed

9 files changed

+311
-16
lines changed

src/commands/quickCommand.buttons.ts

+5
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ export const OpenOnAzureDevOpsQuickInputButton: QuickInputButton = {
152152
tooltip: 'Open on Azure DevOps',
153153
};
154154

155+
export const OpenOnBitbucketQuickInputButton: QuickInputButton = {
156+
iconPath: new ThemeIcon('globe'),
157+
tooltip: 'Open on Bitbucket',
158+
};
159+
155160
export const OpenOnWebQuickInputButton: QuickInputButton = {
156161
iconPath: new ThemeIcon('globe'),
157162
tooltip: 'Open on gitkraken.dev',

src/plus/integrations/providers/bitbucket.ts

+154-4
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/mo
77
import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest';
88
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata';
99
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider';
10+
import type { ProviderAuthenticationSession } from '../authentication/models';
1011
import type { ResourceDescriptor } from '../integration';
1112
import { HostingIntegration } from '../integration';
12-
import { providersMetadata } from './models';
13+
import type { ProviderPullRequest } from './models';
14+
import { fromProviderPullRequest, providersMetadata } from './models';
1315

1416
const metadata = providersMetadata[HostingIntegrationId.Bitbucket];
1517
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes });
@@ -19,6 +21,19 @@ interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
1921
name: string;
2022
}
2123

24+
interface BitbucketWorkspaceDescriptor extends ResourceDescriptor {
25+
id: string;
26+
name: string;
27+
slug: string;
28+
}
29+
30+
interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
31+
owner: string;
32+
name: string;
33+
cloneUrlHttps?: string;
34+
cloneUrlSsh?: string;
35+
}
36+
2237
export class BitbucketIntegration extends HostingIntegration<
2338
HostingIntegrationId.Bitbucket,
2439
BitbucketRepositoryDescriptor
@@ -136,11 +151,138 @@ export class BitbucketIntegration extends HostingIntegration<
136151
return Promise.resolve(undefined);
137152
}
138153

154+
private _accounts: Map<string, Account | undefined> | undefined;
155+
protected override async getProviderCurrentAccount({
156+
accessToken,
157+
}: AuthenticationSession): Promise<Account | undefined> {
158+
this._accounts ??= new Map<string, Account | undefined>();
159+
160+
const cachedAccount = this._accounts.get(accessToken);
161+
if (cachedAccount == null) {
162+
const api = await this.getProvidersApi();
163+
const user = await api.getCurrentUser(this.id, { accessToken: accessToken });
164+
this._accounts.set(
165+
accessToken,
166+
user
167+
? {
168+
provider: this,
169+
id: user.id,
170+
name: user.name ?? undefined,
171+
email: user.email ?? undefined,
172+
avatarUrl: user.avatarUrl ?? undefined,
173+
username: user.username ?? undefined,
174+
}
175+
: undefined,
176+
);
177+
}
178+
179+
return this._accounts.get(accessToken);
180+
}
181+
182+
private _workspaces: Map<string, BitbucketWorkspaceDescriptor[] | undefined> | undefined;
183+
private async getProviderResourcesForUser(
184+
session: AuthenticationSession,
185+
force: boolean = false,
186+
): Promise<BitbucketWorkspaceDescriptor[] | undefined> {
187+
this._workspaces ??= new Map<string, BitbucketWorkspaceDescriptor[] | undefined>();
188+
const { accessToken } = session;
189+
const cachedResources = this._workspaces.get(accessToken);
190+
191+
if (cachedResources == null || force) {
192+
const api = await this.getProvidersApi();
193+
const account = await this.getProviderCurrentAccount(session);
194+
if (account?.id == null) return undefined;
195+
196+
const resources = await api.getBitbucketResourcesForUser(account.id, { accessToken: accessToken });
197+
this._workspaces.set(
198+
accessToken,
199+
resources != null ? resources.map(r => ({ ...r, key: r.id })) : undefined,
200+
);
201+
}
202+
203+
return this._workspaces.get(accessToken);
204+
}
205+
206+
private _repositories: Map<string, BitbucketRemoteRepositoryDescriptor[] | undefined> | undefined;
207+
private async getProviderProjectsForResources(
208+
{ accessToken }: AuthenticationSession,
209+
resources: BitbucketWorkspaceDescriptor[],
210+
force: boolean = false,
211+
): Promise<BitbucketRemoteRepositoryDescriptor[] | undefined> {
212+
this._repositories ??= new Map<string, BitbucketRemoteRepositoryDescriptor[] | undefined>();
213+
let resourcesWithoutRepositories: BitbucketWorkspaceDescriptor[] = [];
214+
if (force) {
215+
resourcesWithoutRepositories = resources;
216+
} else {
217+
for (const resource of resources) {
218+
const resourceKey = `${accessToken}:${resource.id}`;
219+
const cachedRepositories = this._repositories.get(resourceKey);
220+
if (cachedRepositories == null) {
221+
resourcesWithoutRepositories.push(resource);
222+
}
223+
}
224+
}
225+
226+
const cachedRepos = this._repositories;
227+
if (resourcesWithoutRepositories.length > 0) {
228+
const api = await this.container.bitbucket;
229+
if (api == null) return undefined;
230+
await Promise.allSettled(
231+
resourcesWithoutRepositories.map(async resource => {
232+
const resourceRepos = await api.getRepositoriesForWorkspace(this, accessToken, resource.slug, {
233+
baseUrl: this.apiBaseUrl,
234+
});
235+
236+
if (resourceRepos == null) return undefined;
237+
cachedRepos.set(
238+
`${accessToken}:${resource.id}`,
239+
resourceRepos.map(r => ({
240+
id: `${r.owner}/${r.name}`,
241+
owner: r.owner,
242+
name: r.name,
243+
key: `${r.owner}/${r.name}`,
244+
})),
245+
);
246+
}),
247+
);
248+
}
249+
250+
return resources.reduce<BitbucketRemoteRepositoryDescriptor[]>((resultRepos, resource) => {
251+
const resourceRepos = cachedRepos.get(`${accessToken}:${resource.id}`);
252+
if (resourceRepos != null) {
253+
resultRepos.push(...resourceRepos);
254+
}
255+
return resultRepos;
256+
}, []);
257+
}
258+
139259
protected override async searchProviderMyPullRequests(
140-
_session: AuthenticationSession,
141-
_repos?: BitbucketRepositoryDescriptor[],
260+
session: ProviderAuthenticationSession,
261+
requestedRepositories?: BitbucketRepositoryDescriptor[],
142262
): Promise<PullRequest[] | undefined> {
143-
return Promise.resolve(undefined);
263+
const api = await this.getProvidersApi();
264+
if (requestedRepositories != null) {
265+
// TODO: implement repos version
266+
return undefined;
267+
}
268+
269+
const user = await this.getProviderCurrentAccount(session);
270+
if (user?.username == null) return undefined;
271+
272+
const workspaces = await this.getProviderResourcesForUser(session);
273+
if (workspaces == null || workspaces.length === 0) return undefined;
274+
275+
const repos = await this.getProviderProjectsForResources(session, workspaces);
276+
if (repos == null || repos.length === 0) return undefined;
277+
278+
const prs = await api.getPullRequestsForRepos(
279+
HostingIntegrationId.Bitbucket,
280+
repos.map(repo => ({ namespace: repo.owner, name: repo.name })),
281+
{
282+
accessToken: session.accessToken,
283+
},
284+
);
285+
return prs.values.map(pr => this.fromBitbucketProviderPullRequest(pr));
144286
}
145287

146288
protected override async searchProviderMyIssues(
@@ -149,6 +291,14 @@ export class BitbucketIntegration extends HostingIntegration<
149291
): Promise<IssueShape[] | undefined> {
150292
return Promise.resolve(undefined);
151293
}
294+
295+
private fromBitbucketProviderPullRequest(
296+
remotePullRequest: ProviderPullRequest,
297+
// repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
298+
): PullRequest {
299+
remotePullRequest.graphQLId = remotePullRequest.id;
300+
return fromProviderPullRequest(remotePullRequest, this);
301+
}
152302
}
153303

154304
const bitbucketCloudDomainRegex = /^bitbucket\.org$/i;

src/plus/integrations/providers/bitbucket/bitbucket.ts

+57-1
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ import {
1616
import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest';
1717
import type { PullRequest } from '../../../../git/models/pullRequest';
1818
import type { Provider } from '../../../../git/models/remoteProvider';
19+
import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata';
1920
import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages';
2021
import { configuration } from '../../../../system/-webview/configuration';
2122
import { debug } from '../../../../system/decorators/log';
2223
import { Logger } from '../../../../system/logger';
2324
import type { LogScope } from '../../../../system/logger.scope';
2425
import { getLogScope } from '../../../../system/logger.scope';
2526
import { maybeStopWatch } from '../../../../system/stopwatch';
26-
import type { BitbucketIssue, BitbucketPullRequest } from './models';
27+
import type { BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models';
2728
import { bitbucketIssueStateToState, fromBitbucketPullRequest } from './models';
2829

2930
export class BitbucketApi implements Disposable {
@@ -165,6 +166,61 @@ export class BitbucketApi implements Disposable {
165166
return undefined;
166167
}
167168

169+
@debug<BitbucketApi['getRepositoriesForWorkspace']>({ args: { 0: p => p.name, 1: '<token>' } })
170+
public async getRepositoriesForWorkspace(
171+
provider: Provider,
172+
token: string,
173+
workspace: string,
174+
options: {
175+
baseUrl: string;
176+
},
177+
): Promise<RepositoryMetadata[] | undefined> {
178+
const scope = getLogScope();
179+
180+
try {
181+
interface BitbucketRepositoriesResponse {
182+
size: number;
183+
page: number;
184+
pagelen: number;
185+
next?: string;
186+
previous?: string;
187+
values: BitbucketRepository[];
188+
}
189+
190+
const response = await this.request<BitbucketRepositoriesResponse>(
191+
provider,
192+
token,
193+
options.baseUrl,
194+
`repositories/${workspace}?role=contributor&fields=%2Bvalues.parent.workspace`, // field=+<field> must be encoded as field=%2B<field>
195+
{
196+
method: 'GET',
197+
},
198+
scope,
199+
);
200+
201+
if (response) {
202+
return response.values.map(repo => {
203+
return {
204+
provider: provider,
205+
owner: repo.workspace.slug,
206+
name: repo.slug,
207+
isFork: Boolean(repo.parent),
208+
parent: repo.parent
209+
? {
210+
owner: repo.parent.workspace.slug,
211+
name: repo.parent.slug,
212+
}
213+
: undefined,
214+
};
215+
});
216+
}
217+
return undefined;
218+
} catch (ex) {
219+
Logger.error(ex, scope);
220+
return undefined;
221+
}
222+
}
223+
168224
private async request<T>(
169225
provider: Provider,
170226
token: string,

src/plus/integrations/providers/bitbucket/models.ts

+40-3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@ interface BitbucketUser {
2424
};
2525
}
2626

27+
interface BitbucketWorkspace {
28+
type: 'workspace';
29+
uuid: string;
30+
name: string;
31+
slug: string;
32+
links: {
33+
self: BitbucketLink;
34+
html: BitbucketLink;
35+
avatar: BitbucketLink;
36+
};
37+
}
38+
39+
interface BitbucketProject {
40+
type: 'project';
41+
key: string;
42+
uuid: string;
43+
name: string;
44+
links: {
45+
self: BitbucketLink;
46+
html: BitbucketLink;
47+
avatar: BitbucketLink;
48+
};
49+
}
50+
2751
interface BitbucketPullRequestParticipant {
2852
type: 'participant';
2953
user: BitbucketUser;
@@ -33,15 +57,28 @@ interface BitbucketPullRequestParticipant {
3357
participated_on: null | string;
3458
}
3559

36-
interface BitbucketRepository {
60+
export interface BitbucketRepository {
3761
type: 'repository';
3862
uuid: string;
3963
full_name: string;
4064
name: string;
65+
slug: string;
4166
description?: string;
67+
is_private: boolean;
68+
parent: null | BitbucketRepository;
69+
scm: 'git';
70+
owner: BitbucketUser;
71+
workspace: BitbucketWorkspace;
72+
project: BitbucketProject;
73+
created_on: string;
74+
updated_on: string;
75+
size: number;
76+
language: string;
77+
has_issues: boolean;
78+
has_wiki: boolean;
79+
fork_policy: 'allow_forks' | 'no_public_forks' | 'no_forks';
80+
website: string;
4281
mainbranch?: BitbucketBranch;
43-
parent?: BitbucketRepository;
44-
owner?: BitbucketUser;
4582
links: {
4683
self: BitbucketLink;
4784
html: BitbucketLink;

src/plus/integrations/providers/models.ts

+7
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
AzureProject,
88
AzureSetPullRequestInput,
99
Bitbucket,
10+
BitbucketWorkspaceStub,
1011
EnterpriseOptions,
1112
GetRepoInput,
1213
GitHub,
@@ -69,6 +70,7 @@ export type ProviderJiraProject = JiraProject;
6970
export type ProviderJiraResource = JiraResource;
7071
export type ProviderAzureProject = AzureProject;
7172
export type ProviderAzureResource = AzureOrganization;
73+
export type ProviderBitbucketResource = BitbucketWorkspaceStub;
7274
export const ProviderPullRequestReviewState = GitPullRequestReviewState;
7375
export const ProviderBuildStatusState = GitBuildStatusState;
7476
export type ProviderRequestFunction = RequestFunction;
@@ -336,6 +338,10 @@ export type GetAzureProjectsForResourceFn = (
336338
input: { namespace: string; cursor?: string },
337339
options?: EnterpriseOptions,
338340
) => Promise<{ data: AzureProject[]; pageInfo?: PageInfo }>;
341+
export type GetBitbucketResourcesForUserFn = (
342+
input: { userId: string },
343+
options?: EnterpriseOptions,
344+
) => Promise<{ data: BitbucketWorkspaceStub[] }>;
339345
export type GetIssuesForProjectFn = Jira['getIssuesForProject'];
340346
export type GetIssuesForResourceForCurrentUserFn = (
341347
input: { resourceId: string },
@@ -357,6 +363,7 @@ export interface ProviderInfo extends ProviderMetadata {
357363
getCurrentUserForResourceFn?: GetCurrentUserForResourceFn;
358364
getJiraResourcesForCurrentUserFn?: GetJiraResourcesForCurrentUserFn;
359365
getAzureResourcesForUserFn?: GetAzureResourcesForUserFn;
366+
getBitbucketResourcesForUserFn?: GetBitbucketResourcesForUserFn;
360367
getJiraProjectsForResourcesFn?: GetJiraProjectsForResourcesFn;
361368
getAzureProjectsForResourceFn?: GetAzureProjectsForResourceFn;
362369
getIssuesForProjectFn?: GetIssuesForProjectFn;

0 commit comments

Comments
 (0)