Skip to content

Commit e98fa74

Browse files
committed
wip: retrieves Bitbucket PRs. #4046
1 parent daf4b83 commit e98fa74

File tree

10 files changed

+399
-18
lines changed

10 files changed

+399
-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 DevOps',
158+
};
159+
155160
export const OpenOnWebQuickInputButton: QuickInputButton = {
156161
iconPath: new ThemeIcon('globe'),
157162
tooltip: 'Open on gitkraken.dev',

src/constants.storage.ts

+26
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export type GlobalStorage = {
9393
[key in `azure:${string}:organizations`]: Stored<StoredAzureOrganization[] | undefined>;
9494
} & {
9595
[key in `azure:${string}:projects`]: Stored<StoredAzureProject[] | undefined>;
96+
} & { [key in `bitbucket:${string}:account`]: Stored<StoredBitbucketAccount | undefined> } & {
97+
[key in `bitbucket:${string}:organizations`]: Stored<StoredBitbucketOrganization[] | undefined>;
98+
} & {
99+
[key in `bitbucket:${string}:projects`]: Stored<StoredBitbucketProject[] | undefined>;
96100
};
97101

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

252+
export interface StoredBitbucketAccount {
253+
id: string;
254+
name: string | undefined;
255+
username: string | undefined;
256+
email: string | undefined;
257+
avatarUrl: string | undefined;
258+
}
259+
260+
export interface StoredBitbucketOrganization {
261+
key: string;
262+
id: string;
263+
name: string;
264+
}
265+
266+
export interface StoredBitbucketProject {
267+
key: string;
268+
id: string;
269+
name: string;
270+
resourceId: string;
271+
resourceName: string;
272+
}
273+
248274
export interface StoredAvatar {
249275
uri: string;
250276
timestamp: number;

src/plus/integrations/providers/bitbucket.ts

+214-4
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import type {
1111
SearchedPullRequest,
1212
} from '../../../git/models/pullRequest';
1313
import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata';
14+
import { getSettledValue } from '../../../system/promise';
1415
import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider';
16+
import type { ProviderAuthenticationSession } from '../authentication/models';
1517
import type { ResourceDescriptor } from '../integration';
1618
import { HostingIntegration } from '../integration';
17-
import { providersMetadata } from './models';
19+
import type { ProviderPullRequest } from './models';
20+
import { fromProviderPullRequest, providersMetadata } from './models';
1821

1922
const metadata = providersMetadata[HostingIntegrationId.Bitbucket];
2023
const authProvider = Object.freeze({ id: metadata.id, scopes: metadata.scopes });
@@ -24,6 +27,24 @@ interface BitbucketRepositoryDescriptor extends ResourceDescriptor {
2427
name: string;
2528
}
2629

30+
interface BitbucketWorkspaceDescriptor extends ResourceDescriptor {
31+
id: string;
32+
name: string;
33+
slug: string;
34+
}
35+
36+
interface BitbucketRemoteRepositoryDescriptor extends ResourceDescriptor {
37+
id: string;
38+
// nodeId?: string;
39+
resourceName: string;
40+
owner: string;
41+
name: string;
42+
// projectName?: string;
43+
// url?: string;
44+
cloneUrlHttps?: string;
45+
cloneUrlSsh?: string;
46+
}
47+
2748
export class BitbucketIntegration extends HostingIntegration<
2849
HostingIntegrationId.Bitbucket,
2950
BitbucketRepositoryDescriptor
@@ -136,11 +157,165 @@ export class BitbucketIntegration extends HostingIntegration<
136157
return Promise.resolve(undefined);
137158
}
138159

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

146321
protected override async searchProviderMyIssues(
@@ -149,6 +324,41 @@ export class BitbucketIntegration extends HostingIntegration<
149324
): Promise<SearchedIssue[] | undefined> {
150325
return Promise.resolve(undefined);
151326
}
327+
328+
private fromBitbucketProviderPullRequest(
329+
remotePullRequest: ProviderPullRequest,
330+
repoDescriptors: BitbucketRemoteRepositoryDescriptor[],
331+
): PullRequest {
332+
const baseRepoDescriptor = repoDescriptors.find(r => r.name === remotePullRequest.repository.name);
333+
const headRepoDescriptor =
334+
remotePullRequest.headRepository != null
335+
? repoDescriptors.find(r => r.name === remotePullRequest.headRepository!.name)
336+
: undefined;
337+
if (baseRepoDescriptor != null) {
338+
remotePullRequest.repository.remoteInfo = {
339+
...remotePullRequest.repository.remoteInfo,
340+
cloneUrlHTTPS: baseRepoDescriptor.cloneUrlHttps ?? '',
341+
cloneUrlSSH: baseRepoDescriptor.cloneUrlSsh ?? '',
342+
};
343+
}
344+
345+
if (headRepoDescriptor != null) {
346+
remotePullRequest.headRepository = {
347+
...remotePullRequest.headRepository,
348+
id: remotePullRequest.headRepository?.id ?? headRepoDescriptor.id,
349+
name: remotePullRequest.headRepository?.name ?? headRepoDescriptor.name,
350+
owner: {
351+
login: remotePullRequest.headRepository?.owner.login ?? headRepoDescriptor.resourceName,
352+
},
353+
remoteInfo: {
354+
...remotePullRequest.headRepository?.remoteInfo,
355+
cloneUrlHTTPS: headRepoDescriptor.cloneUrlHttps ?? '',
356+
cloneUrlSSH: headRepoDescriptor.cloneUrlSsh ?? '',
357+
},
358+
};
359+
}
360+
return fromProviderPullRequest(remotePullRequest, this);
361+
}
152362
}
153363

154364
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`,
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,

0 commit comments

Comments
 (0)