Skip to content

Commit cae3faa

Browse files
committed
Retrieves Bitbucket PRs and shows on the Launchpad
(#4046)
1 parent c41e488 commit cae3faa

File tree

9 files changed

+314
-16
lines changed

9 files changed

+314
-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

+157-4
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import type {
1212
} from '../../../git/models/pullRequest';
1313
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata';
1414
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider';
15+
import type { ProviderAuthenticationSession } from '../authentication/models';
1516
import type { ResourceDescriptor } from '../integration';
1617
import { HostingIntegration } from '../integration';
17-
import { providersMetadata } from './models';
18+
import type { ProviderPullRequest } from './models';
19+
import { fromProviderPullRequest, providersMetadata } from './models';
1820

1921
const metadata = providersMetadata[HostingIntegrationId.Bitbucket];
2022
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes });
@@ -24,6 +26,19 @@ interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
2426
name: string;
2527
}
2628

29+
interface BitbucketWorkspaceDescriptor extends ResourceDescriptor {
30+
id: string;
31+
name: string;
32+
slug: string;
33+
}
34+
35+
interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
36+
owner: string;
37+
name: string;
38+
cloneUrlHttps?: string;
39+
cloneUrlSsh?: string;
40+
}
41+
2742
export class BitbucketIntegration extends HostingIntegration<
2843
HostingIntegrationId.Bitbucket,
2944
BitbucketRepositoryDescriptor
@@ -141,11 +156,141 @@ export class BitbucketIntegration extends HostingIntegration<
141156
return Promise.resolve(undefined);
142157
}
143158

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

151296
protected override async searchProviderMyIssues(
@@ -154,6 +299,14 @@ export class BitbucketIntegration extends HostingIntegration<
154299
): Promise<SearchedIssue[] | undefined> {
155300
return Promise.resolve(undefined);
156301
}
302+
303+
private fromBitbucketProviderPullRequest(
304+
remotePullRequest: ProviderPullRequest,
305+
// repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
306+
): PullRequest {
307+
remotePullRequest.graphQLId = remotePullRequest.id;
308+
return fromProviderPullRequest(remotePullRequest, this);
309+
}
157310
}
158311

159312
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)