Skip to content

Commit 59b24ac

Browse files
committed
Retrieves Bitbucket PRs and shows on the Launchpad
(#4046)
1 parent 5941480 commit 59b24ac

File tree

9 files changed

+316
-18
lines changed

9 files changed

+316
-18
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
@@ -136,11 +151,141 @@ 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<SearchedPullRequest[] | 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 => ({
286+
pullRequest: this.fromBitbucketProviderPullRequest(pr),
287+
reasons: [],
288+
}));
144289
}
145290

146291
protected override async searchProviderMyIssues(
@@ -149,6 +294,14 @@ export class BitbucketIntegration extends HostingIntegration<
149294
): Promise<SearchedIssue[] | undefined> {
150295
return Promise.resolve(undefined);
151296
}
297+
298+
private fromBitbucketProviderPullRequest(
299+
remotePullRequest: ProviderPullRequest,
300+
// repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
301+
): PullRequest {
302+
remotePullRequest.graphQLId = remotePullRequest.id;
303+
return fromProviderPullRequest(remotePullRequest, this);
304+
}
152305
}
153306

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

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

+59-3
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 {
@@ -80,7 +81,7 @@ export class BitbucketApi implements Disposable {
8081
provider,
8182
token,
8283
options.baseUrl,
83-
`repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=values.*`,
84+
`repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=values.*`, // TODO: be more precise on additional fields. look at getRepositoriesForWorkspace
8485
{
8586
method: 'GET',
8687
},
@@ -113,7 +114,7 @@ export class BitbucketApi implements Disposable {
113114
provider,
114115
token,
115116
options.baseUrl,
116-
`repositories/${owner}/${repo}/pullrequests/${id}?fields=*`,
117+
`repositories/${owner}/${repo}/pullrequests/${id}?fields=*`, // TODO: be more precise on additional fields. look at getRepositoriesForWorkspace
117118
{
118119
method: 'GET',
119120
},
@@ -167,6 +168,61 @@ export class BitbucketApi implements Disposable {
167168
return undefined;
168169
}
169170

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