Skip to content

Commit f8da766

Browse files
committed
Retrieves Bitbucket PRs and shows on the Launchpad
(#4046, #4099)
1 parent f273671 commit f8da766

File tree

9 files changed

+320
-22
lines changed

9 files changed

+320
-22
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

+144-10
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,19 @@ 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 { ResourceDescriptor } from '../integration';
10+
import type { ProviderAuthenticationSession } from '../authentication/models';
1111
import { HostingIntegration } from '../integration';
12-
import { providersMetadata } from './models';
12+
import type {
13+
BitbucketRemoteRepositoryDescriptor,
14+
BitbucketRepositoryDescriptor,
15+
BitbucketWorkspaceDescriptor,
16+
} from './bitbucket/models';
17+
import type { ProviderPullRequest } from './models';
18+
import { fromProviderPullRequest, providersMetadata } from './models';
1319

1420
const metadata = providersMetadata[HostingIntegrationId.Bitbucket];
1521
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes });
1622

17-
interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
18-
owner: string;
19-
name: string;
20-
}
21-
2223
export class BitbucketIntegration extends HostingIntegration<
2324
HostingIntegrationId.Bitbucket,
2425
BitbucketRepositoryDescriptor
@@ -136,11 +137,136 @@ export class BitbucketIntegration extends HostingIntegration<
136137
return Promise.resolve(undefined);
137138
}
138139

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

146272
protected override async searchProviderMyIssues(
@@ -149,6 +275,14 @@ export class BitbucketIntegration extends HostingIntegration<
149275
): Promise<IssueShape[] | undefined> {
150276
return Promise.resolve(undefined);
151277
}
278+
279+
private fromBitbucketProviderPullRequest(
280+
remotePullRequest: ProviderPullRequest,
281+
// repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
282+
): PullRequest {
283+
remotePullRequest.graphQLId = remotePullRequest.id;
284+
return fromProviderPullRequest(remotePullRequest, this);
285+
}
152286
}
153287

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

+59-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPull
33
import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest';
44
import { PullRequest, PullRequestReviewDecision, PullRequestReviewState } from '../../../../git/models/pullRequest';
55
import type { Provider } from '../../../../git/models/remoteProvider';
6+
import type { ResourceDescriptor } from '../../integration';
7+
8+
export interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
9+
owner: string;
10+
name: string;
11+
}
12+
13+
export interface BitbucketWorkspaceDescriptor extends ResourceDescriptor {
14+
id: string;
15+
name: string;
16+
slug: string;
17+
}
18+
19+
export interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
20+
owner: string;
21+
name: string;
22+
cloneUrlHttps?: string;
23+
cloneUrlSsh?: string;
24+
}
625

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

@@ -24,6 +43,30 @@ interface BitbucketUser {
2443
};
2544
}
2645

46+
interface BitbucketWorkspace {
47+
type: 'workspace';
48+
uuid: string;
49+
name: string;
50+
slug: string;
51+
links: {
52+
self: BitbucketLink;
53+
html: BitbucketLink;
54+
avatar: BitbucketLink;
55+
};
56+
}
57+
58+
interface BitbucketProject {
59+
type: 'project';
60+
key: string;
61+
uuid: string;
62+
name: string;
63+
links: {
64+
self: BitbucketLink;
65+
html: BitbucketLink;
66+
avatar: BitbucketLink;
67+
};
68+
}
69+
2770
interface BitbucketPullRequestParticipant {
2871
type: 'participant';
2972
user: BitbucketUser;
@@ -33,15 +76,28 @@ interface BitbucketPullRequestParticipant {
3376
participated_on: null | string;
3477
}
3578

36-
interface BitbucketRepository {
79+
export interface BitbucketRepository {
3780
type: 'repository';
3881
uuid: string;
3982
full_name: string;
4083
name: string;
84+
slug: string;
4185
description?: string;
86+
is_private: boolean;
87+
parent: null | BitbucketRepository;
88+
scm: 'git';
89+
owner: BitbucketUser;
90+
workspace: BitbucketWorkspace;
91+
project: BitbucketProject;
92+
created_on: string;
93+
updated_on: string;
94+
size: number;
95+
language: string;
96+
has_issues: boolean;
97+
has_wiki: boolean;
98+
fork_policy: 'allow_forks' | 'no_public_forks' | 'no_forks';
99+
website: string;
42100
mainbranch?: BitbucketBranch;
43-
parent?: BitbucketRepository;
44-
owner?: BitbucketUser;
45101
links: {
46102
self: BitbucketLink;
47103
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)