Skip to content

Commit c708331

Browse files
committed
Adds Bitbucket integration and retrieves PR for a branch
(#4045)
1 parent f1680a0 commit c708331

File tree

4 files changed

+502
-4
lines changed

4 files changed

+502
-4
lines changed

src/container.ts

+25
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { ConfiguredIntegrationService } from './plus/integrations/authentication
3636
import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthenticationService';
3737
import { IntegrationService } from './plus/integrations/integrationService';
3838
import type { AzureDevOpsApi } from './plus/integrations/providers/azure/azure';
39+
import type { BitbucketApi } from './plus/integrations/providers/bitbucket/bitbucket-api';
3940
import type { GitHubApi } from './plus/integrations/providers/github/github';
4041
import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab';
4142
import { EnrichmentService } from './plus/launchpad/enrichmentService';
@@ -500,6 +501,30 @@ export class Container {
500501
return this._azure;
501502
}
502503

504+
private _bitbucket: Promise<BitbucketApi | undefined> | undefined;
505+
get bitbucket(): Promise<BitbucketApi | undefined> {
506+
if (this._bitbucket == null) {
507+
async function load(this: Container) {
508+
try {
509+
const bitbucket = new (
510+
await import(
511+
/* webpackChunkName: "integrations" */ './plus/integrations/providers/bitbucket/bitbucket-api'
512+
)
513+
).BitbucketApi(this);
514+
this._disposables.push(bitbucket);
515+
return bitbucket;
516+
} catch (ex) {
517+
Logger.error(ex);
518+
return undefined;
519+
}
520+
}
521+
522+
this._bitbucket = load.call(this);
523+
}
524+
525+
return this._bitbucket;
526+
}
527+
503528
private _github: Promise<GitHubApi | undefined> | undefined;
504529
get github(): Promise<GitHubApi | undefined> {
505530
if (this._github == null) {

src/plus/integrations/providers/bitbucket.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,24 @@ export class BitbucketIntegration extends HostingIntegration<
9696
}
9797

9898
protected override async getProviderPullRequestForBranch(
99-
_session: AuthenticationSession,
100-
_repo: BitbucketRepositoryDescriptor,
101-
_branch: string,
99+
{ accessToken }: AuthenticationSession,
100+
repo: BitbucketRepositoryDescriptor,
101+
branch: string,
102102
_options?: {
103103
avatarSize?: number;
104104
include?: PullRequestState[];
105105
},
106106
): Promise<PullRequest | undefined> {
107-
return Promise.resolve(undefined);
107+
return (await this.container.bitbucket)?.getPullRequestForBranch(
108+
this,
109+
accessToken,
110+
repo.owner,
111+
repo.name,
112+
branch,
113+
{
114+
baseUrl: this.apiBaseUrl,
115+
},
116+
);
108117
}
109118

110119
protected override async getProviderPullRequestForCommit(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import type { HttpsProxyAgent } from 'https-proxy-agent';
2+
import type { CancellationToken, Disposable } from 'vscode';
3+
import { window } from 'vscode';
4+
import type { RequestInit, Response } from '@env/fetch';
5+
import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch';
6+
import { isWeb } from '@env/platform';
7+
import type { Container } from '../../../../container';
8+
import {
9+
AuthenticationError,
10+
AuthenticationErrorReason,
11+
CancellationError,
12+
ProviderFetchError,
13+
RequestClientError,
14+
RequestNotFoundError,
15+
} from '../../../../errors';
16+
import type { PullRequest } from '../../../../git/models/pullRequest';
17+
import type { Provider } from '../../../../git/models/remoteProvider';
18+
import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages';
19+
import { configuration } from '../../../../system/-webview/configuration';
20+
import { Logger } from '../../../../system/logger';
21+
import type { LogScope } from '../../../../system/logger.scope';
22+
import { getLogScope } from '../../../../system/logger.scope';
23+
import { maybeStopWatch } from '../../../../system/stopwatch';
24+
import type { BitbucketPullRequest } from './models';
25+
import { fromBitbucketPullRequest } from './models';
26+
27+
export class BitbucketApi implements Disposable {
28+
private readonly _disposable: Disposable;
29+
30+
constructor(_container: Container) {
31+
this._disposable = configuration.onDidChangeAny(e => {
32+
if (
33+
configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) ||
34+
configuration.changed(e, ['outputLevel', 'proxy'])
35+
) {
36+
this.resetCaches();
37+
}
38+
});
39+
}
40+
41+
dispose(): void {
42+
this._disposable.dispose();
43+
}
44+
45+
private _proxyAgent: HttpsProxyAgent | null | undefined = null;
46+
private get proxyAgent(): HttpsProxyAgent | undefined {
47+
if (isWeb) return undefined;
48+
49+
if (this._proxyAgent === null) {
50+
this._proxyAgent = getProxyAgent();
51+
}
52+
return this._proxyAgent;
53+
}
54+
55+
private resetCaches(): void {
56+
this._proxyAgent = null;
57+
}
58+
59+
public async getPullRequestForBranch(
60+
provider: Provider,
61+
token: string,
62+
owner: string,
63+
repo: string,
64+
branch: string,
65+
options: {
66+
baseUrl: string;
67+
},
68+
): Promise<PullRequest | undefined> {
69+
const scope = getLogScope();
70+
71+
const response = await this.request<{
72+
values: BitbucketPullRequest[];
73+
pagelen: number;
74+
size: number;
75+
page: number;
76+
}>(
77+
provider,
78+
token,
79+
options.baseUrl,
80+
`repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=values.*`,
81+
{
82+
method: 'GET',
83+
},
84+
scope,
85+
);
86+
87+
if (!response?.values?.length) {
88+
return undefined;
89+
}
90+
return fromBitbucketPullRequest(response.values[0], provider);
91+
}
92+
93+
private async request<T>(
94+
provider: Provider,
95+
token: string,
96+
baseUrl: string,
97+
route: string,
98+
options: { method: RequestInit['method'] } & Record<string, unknown>,
99+
scope: LogScope | undefined,
100+
cancellation?: CancellationToken | undefined,
101+
): Promise<T | undefined> {
102+
const url = `${baseUrl}/${route}`;
103+
104+
let rsp: Response;
105+
try {
106+
const sw = maybeStopWatch(`[BITBUCKET] ${options?.method ?? 'GET'} ${url}`, { log: false });
107+
const agent = this.proxyAgent;
108+
109+
try {
110+
let aborter: AbortController | undefined;
111+
if (cancellation != null) {
112+
if (cancellation.isCancellationRequested) throw new CancellationError();
113+
114+
aborter = new AbortController();
115+
cancellation.onCancellationRequested(() => aborter!.abort());
116+
}
117+
118+
rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () =>
119+
fetch(url, {
120+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
121+
agent: agent,
122+
signal: aborter?.signal,
123+
...options,
124+
}),
125+
);
126+
127+
if (rsp.ok) {
128+
const data: T = await rsp.json();
129+
return data;
130+
}
131+
132+
throw new ProviderFetchError('Bitbucket', rsp);
133+
} finally {
134+
sw?.stop();
135+
}
136+
} catch (ex) {
137+
if (ex instanceof ProviderFetchError || ex.name === 'AbortError') {
138+
this.handleRequestError(provider, token, ex, scope);
139+
} else if (Logger.isDebugging) {
140+
void window.showErrorMessage(`Bitbucket request failed: ${ex.message}`);
141+
}
142+
143+
throw ex;
144+
}
145+
}
146+
147+
private handleRequestError(
148+
provider: Provider | undefined,
149+
_token: string,
150+
ex: ProviderFetchError | (Error & { name: 'AbortError' }),
151+
scope: LogScope | undefined,
152+
): void {
153+
if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex);
154+
155+
switch (ex.status) {
156+
case 404: // Not found
157+
case 410: // Gone
158+
case 422: // Unprocessable Entity
159+
throw new RequestNotFoundError(ex);
160+
case 401: // Unauthorized
161+
throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Unauthorized, ex);
162+
// TODO: Learn the Bitbucket API docs and put it in order:
163+
// case 403: // Forbidden
164+
// if (ex.message.includes('rate limit')) {
165+
// let resetAt: number | undefined;
166+
167+
// const reset = ex.response?.headers?.get('x-ratelimit-reset');
168+
// if (reset != null) {
169+
// resetAt = parseInt(reset, 10);
170+
// if (Number.isNaN(resetAt)) {
171+
// resetAt = undefined;
172+
// }
173+
// }
174+
175+
// throw new RequestRateLimitError(ex, token, resetAt);
176+
// }
177+
// throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Forbidden, ex);
178+
case 500: // Internal Server Error
179+
Logger.error(ex, scope);
180+
if (ex.response != null) {
181+
provider?.trackRequestException();
182+
void showIntegrationRequestFailed500WarningMessage(
183+
`${provider?.name ?? 'Bitbucket'} failed to respond and might be experiencing issues.${
184+
provider == null || provider.id === 'bitbucket'
185+
? ' Please visit the [Bitbucket status page](https://bitbucket.status.atlassian.com/) for more information.'
186+
: ''
187+
}`,
188+
);
189+
}
190+
return;
191+
case 502: // Bad Gateway
192+
Logger.error(ex, scope);
193+
// TODO: Learn the Bitbucket API docs and put it in order:
194+
// if (ex.message.includes('timeout')) {
195+
// provider?.trackRequestException();
196+
// void showIntegrationRequestTimedOutWarningMessage(provider?.name ?? 'Bitbucket');
197+
// return;
198+
// }
199+
break;
200+
default:
201+
if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex);
202+
break;
203+
}
204+
205+
Logger.error(ex, scope);
206+
if (Logger.isDebugging) {
207+
void window.showErrorMessage(
208+
`Bitbucket request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`,
209+
);
210+
}
211+
}
212+
}

0 commit comments

Comments
 (0)