From bd0dc9a583f287e69ea1607231629ec8c0147c38 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Fri, 14 Feb 2025 18:13:21 +0100 Subject: [PATCH 1/6] Adds Bitbucket integration and retrieves PR for a branch (#4045, #4070) --- src/container.ts | 25 ++ src/plus/integrations/providers/bitbucket.ts | 17 +- .../providers/bitbucket/bitbucket.ts | 212 +++++++++++++++ .../providers/bitbucket/models.ts | 253 ++++++++++++++++++ 4 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 src/plus/integrations/providers/bitbucket/bitbucket.ts create mode 100644 src/plus/integrations/providers/bitbucket/models.ts diff --git a/src/container.ts b/src/container.ts index 8102a22e8614e..76b94bbe95be1 100644 --- a/src/container.ts +++ b/src/container.ts @@ -36,6 +36,7 @@ import { ConfiguredIntegrationService } from './plus/integrations/authentication import { IntegrationAuthenticationService } from './plus/integrations/authentication/integrationAuthenticationService'; import { IntegrationService } from './plus/integrations/integrationService'; import type { AzureDevOpsApi } from './plus/integrations/providers/azure/azure'; +import type { BitbucketApi } from './plus/integrations/providers/bitbucket/bitbucket'; import type { GitHubApi } from './plus/integrations/providers/github/github'; import type { GitLabApi } from './plus/integrations/providers/gitlab/gitlab'; import { EnrichmentService } from './plus/launchpad/enrichmentService'; @@ -487,6 +488,30 @@ export class Container { return this._azure; } + private _bitbucket: Promise | undefined; + get bitbucket(): Promise { + if (this._bitbucket == null) { + async function load(this: Container) { + try { + const bitbucket = new ( + await import( + /* webpackChunkName: "integrations" */ './plus/integrations/providers/bitbucket/bitbucket' + ) + ).BitbucketApi(this); + this._disposables.push(bitbucket); + return bitbucket; + } catch (ex) { + Logger.error(ex); + return undefined; + } + } + + this._bitbucket = load.call(this); + } + + return this._bitbucket; + } + private _github: Promise | undefined; get github(): Promise { if (this._github == null) { diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 0f496300b5ba8..7b401d5fb3e36 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -91,15 +91,24 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderPullRequestForBranch( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _branch: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + branch: string, _options?: { avatarSize?: number; include?: PullRequestState[]; }, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getPullRequestForBranch( + this, + accessToken, + repo.owner, + repo.name, + branch, + { + baseUrl: this.apiBaseUrl, + }, + ); } protected override async getProviderPullRequestForCommit( diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts new file mode 100644 index 0000000000000..f18672ca56a16 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -0,0 +1,212 @@ +import type { HttpsProxyAgent } from 'https-proxy-agent'; +import type { CancellationToken, Disposable } from 'vscode'; +import { window } from 'vscode'; +import type { RequestInit, Response } from '@env/fetch'; +import { fetch, getProxyAgent, wrapForForcedInsecureSSL } from '@env/fetch'; +import { isWeb } from '@env/platform'; +import type { Container } from '../../../../container'; +import { + AuthenticationError, + AuthenticationErrorReason, + CancellationError, + ProviderFetchError, + RequestClientError, + RequestNotFoundError, +} from '../../../../errors'; +import type { PullRequest } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; +import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; +import { configuration } from '../../../../system/-webview/configuration'; +import { Logger } from '../../../../system/logger'; +import type { LogScope } from '../../../../system/logger.scope'; +import { getLogScope } from '../../../../system/logger.scope'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import type { BitbucketPullRequest } from './models'; +import { fromBitbucketPullRequest } from './models'; + +export class BitbucketApi implements Disposable { + private readonly _disposable: Disposable; + + constructor(_container: Container) { + this._disposable = configuration.onDidChangeAny(e => { + if ( + configuration.changedCore(e, ['http.proxy', 'http.proxyStrictSSL']) || + configuration.changed(e, ['outputLevel', 'proxy']) + ) { + this.resetCaches(); + } + }); + } + + dispose(): void { + this._disposable.dispose(); + } + + private _proxyAgent: HttpsProxyAgent | null | undefined = null; + private get proxyAgent(): HttpsProxyAgent | undefined { + if (isWeb) return undefined; + + if (this._proxyAgent === null) { + this._proxyAgent = getProxyAgent(); + } + return this._proxyAgent; + } + + private resetCaches(): void { + this._proxyAgent = null; + } + + public async getPullRequestForBranch( + provider: Provider, + token: string, + owner: string, + repo: string, + branch: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + + const response = await this.request<{ + values: BitbucketPullRequest[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + options.baseUrl, + `repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=%2Bvalues.reviewers,%2Bvalues.participants`, + { + method: 'GET', + }, + scope, + ); + + if (!response?.values?.length) { + return undefined; + } + return fromBitbucketPullRequest(response.values[0], provider); + } + + private async request( + provider: Provider, + token: string, + baseUrl: string, + route: string, + options: { method: RequestInit['method'] } & Record, + scope: LogScope | undefined, + cancellation?: CancellationToken | undefined, + ): Promise { + const url = `${baseUrl}/${route}`; + + let rsp: Response; + try { + const sw = maybeStopWatch(`[BITBUCKET] ${options?.method ?? 'GET'} ${url}`, { log: false }); + const agent = this.proxyAgent; + + try { + let aborter: AbortController | undefined; + if (cancellation != null) { + if (cancellation.isCancellationRequested) throw new CancellationError(); + + aborter = new AbortController(); + cancellation.onCancellationRequested(() => aborter!.abort()); + } + + rsp = await wrapForForcedInsecureSSL(provider.getIgnoreSSLErrors(), () => + fetch(url, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + agent: agent, + signal: aborter?.signal, + ...options, + }), + ); + + if (rsp.ok) { + const data: T = await rsp.json(); + return data; + } + + throw new ProviderFetchError('Bitbucket', rsp); + } finally { + sw?.stop(); + } + } catch (ex) { + if (ex instanceof ProviderFetchError || ex.name === 'AbortError') { + this.handleRequestError(provider, token, ex, scope); + } else if (Logger.isDebugging) { + void window.showErrorMessage(`Bitbucket request failed: ${ex.message}`); + } + + throw ex; + } + } + + private handleRequestError( + provider: Provider | undefined, + _token: string, + ex: ProviderFetchError | (Error & { name: 'AbortError' }), + scope: LogScope | undefined, + ): void { + if (ex.name === 'AbortError' || !(ex instanceof ProviderFetchError)) throw new CancellationError(ex); + + switch (ex.status) { + case 404: // Not found + case 410: // Gone + case 422: // Unprocessable Entity + throw new RequestNotFoundError(ex); + case 401: // Unauthorized + throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Unauthorized, ex); + // TODO: Learn the Bitbucket API docs and put it in order: + // case 403: // Forbidden + // if (ex.message.includes('rate limit')) { + // let resetAt: number | undefined; + + // const reset = ex.response?.headers?.get('x-ratelimit-reset'); + // if (reset != null) { + // resetAt = parseInt(reset, 10); + // if (Number.isNaN(resetAt)) { + // resetAt = undefined; + // } + // } + + // throw new RequestRateLimitError(ex, token, resetAt); + // } + // throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Forbidden, ex); + case 500: // Internal Server Error + Logger.error(ex, scope); + if (ex.response != null) { + provider?.trackRequestException(); + void showIntegrationRequestFailed500WarningMessage( + `${provider?.name ?? 'Bitbucket'} failed to respond and might be experiencing issues.${ + provider == null || provider.id === 'bitbucket' + ? ' Please visit the [Bitbucket status page](https://bitbucket.status.atlassian.com/) for more information.' + : '' + }`, + ); + } + return; + case 502: // Bad Gateway + Logger.error(ex, scope); + // TODO: Learn the Bitbucket API docs and put it in order: + // if (ex.message.includes('timeout')) { + // provider?.trackRequestException(); + // void showIntegrationRequestTimedOutWarningMessage(provider?.name ?? 'Bitbucket'); + // return; + // } + break; + default: + if (ex.status >= 400 && ex.status < 500) throw new RequestClientError(ex); + break; + } + + Logger.error(ex, scope); + if (Logger.isDebugging) { + void window.showErrorMessage( + `Bitbucket request failed: ${(ex.response as any)?.errors?.[0]?.message ?? ex.message}`, + ); + } + } +} diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts new file mode 100644 index 0000000000000..357d4f2b10416 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -0,0 +1,253 @@ +import { RepositoryAccessLevel } from '../../../../git/models/issue'; +import type { IssueOrPullRequestState } from '../../../../git/models/issueOrPullRequest'; +import type { PullRequestMember, PullRequestReviewer } from '../../../../git/models/pullRequest'; +import { PullRequest, PullRequestReviewDecision, PullRequestReviewState } from '../../../../git/models/pullRequest'; +import type { Provider } from '../../../../git/models/remoteProvider'; + +export type BitbucketPullRequestState = 'OPEN' | 'DECLINED' | 'MERGED' | 'SUPERSEDED'; + +interface BitbucketLink { + href: string; + name?: string; +} + +interface BitbucketUser { + type: 'user'; + uuid: string; + display_name: string; + account_id?: string; + nickname?: string; + links: { + self: BitbucketLink; + avatar: BitbucketLink; + html: BitbucketLink; + }; +} + +interface BitbucketPullRequestParticipant { + type: 'participant'; + user: BitbucketUser; + role: 'PARTICIPANT' | 'REVIEWER'; + approved: boolean; + state: null | 'approved' | 'changes_requested'; + participated_on: null | string; +} + +interface BitbucketRepository { + type: 'repository'; + uuid: string; + full_name: string; + name: string; + description?: string; + mainbranch?: BitbucketBranch; + parent?: BitbucketRepository; + owner?: BitbucketUser; + links: { + self: BitbucketLink; + html: BitbucketLink; + avatar: BitbucketLink; + }; +} + +type BitbucketMergeStrategy = + | 'merge_commit' + | 'squash' + | 'fast_forward' + | 'squash_fast_forward' + | 'rebase_fast_forward' + | 'rebase_merge'; + +interface BitbucketBranch { + name: string; + merge_strategies?: BitbucketMergeStrategy[]; + default_merge_strategy?: BitbucketMergeStrategy; +} + +interface BitbucketPullRequestCommit { + type: 'commit'; + hash: string; + links: { + self: BitbucketLink; + html: BitbucketLink; + }; +} + +export interface BitbucketPullRequest { + type: 'pullrequest'; + id: number; + title: string; + description: string; + state: BitbucketPullRequestState; + merge_commit: null | BitbucketPullRequestCommit; + comment_count: number; + task_count: number; + close_source_branch: boolean; + closed_by: BitbucketUser | null; + author: BitbucketUser; + reason: string; + created_on: string; + updated_on: string; + destination: { + branch: BitbucketBranch; + commit: BitbucketPullRequestCommit; + repository: BitbucketRepository; + }; + source: { + branch: BitbucketBranch; + commit: BitbucketPullRequestCommit; + repository: BitbucketRepository; + }; + summary: { + type: 'rendered'; + raw: string; + markup: string; + html: string; + }; + reviewers?: BitbucketUser[]; + participants?: BitbucketPullRequestParticipant[]; + links: { + self: BitbucketLink; + html: BitbucketLink; + commits: BitbucketLink; + approve: BitbucketLink; + 'request-changes': BitbucketLink; + diff: BitbucketLink; + diffstat: BitbucketLink; + comments: BitbucketLink; + activity: BitbucketLink; + merge: BitbucketLink; + decline: BitbucketLink; + statuses: BitbucketLink; + }; +} + +export function bitbucketPullRequestStateToState(state: BitbucketPullRequestState): IssueOrPullRequestState { + switch (state) { + case 'DECLINED': + case 'SUPERSEDED': + return 'closed'; + case 'MERGED': + return 'merged'; + case 'OPEN': + default: + return 'opened'; + } +} + +export function isClosedBitbucketPullRequestState(state: BitbucketPullRequestState): boolean { + return bitbucketPullRequestStateToState(state) !== 'opened'; +} + +export function fromBitbucketUser(user: BitbucketUser): PullRequestMember { + return { + avatarUrl: user.links.avatar.href, + name: user.display_name, + url: user.links.html.href, + id: user.uuid, + }; +} + +export function fromBitbucketParticipantToReviewer( + prt: BitbucketPullRequestParticipant, + closedBy: BitbucketUser | null, + prState: BitbucketPullRequestState, +): PullRequestReviewer { + return { + reviewer: fromBitbucketUser(prt.user), + state: prt.approved + ? PullRequestReviewState.Approved + : prt.state === 'changes_requested' + ? PullRequestReviewState.ChangesRequested + : prt.participated_on != null + ? PullRequestReviewState.Commented + : prt.user.uuid === closedBy?.uuid && prState === 'DECLINED' + ? PullRequestReviewState.Dismissed + : PullRequestReviewState.Pending, + }; +} + +function getBitbucketReviewDecision(pr: BitbucketPullRequest): PullRequestReviewDecision | undefined { + if (!pr.participants?.length && pr.reviewers?.length) { + return PullRequestReviewDecision.ReviewRequired; + } + if (!pr.participants) { + return undefined; + } + let hasReviews = false; + let hasChangeRequests = false; + let hasApprovals = false; + for (const prt of pr.participants) { + if (prt.participated_on != null) { + hasReviews = true; + } + if (prt.approved) { + hasApprovals = true; + } + if (prt.state === 'changes_requested') { + hasChangeRequests = true; + } + } + if (hasChangeRequests) return PullRequestReviewDecision.ChangesRequested; + if (hasApprovals) return PullRequestReviewDecision.Approved; + if (hasReviews) return undefined; // not approved, not rejected, but reviewed + return PullRequestReviewDecision.ReviewRequired; // nobody has reviewed yet. +} + +export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Provider): PullRequest { + return new PullRequest( + provider, + fromBitbucketUser(pr.author), + pr.id.toString(), + pr.id.toString(), + pr.title, + pr.links.html.href, + { + owner: pr.destination.repository.full_name.split('/')[0], + repo: pr.destination.repository.name, + id: pr.destination.repository.uuid, + // TODO: Remove this assumption once actual access level is available + accessLevel: RepositoryAccessLevel.Write, + }, + bitbucketPullRequestStateToState(pr.state), + new Date(pr.created_on), + new Date(pr.updated_on), + pr.closed_by ? new Date(pr.updated_on) : undefined, + pr.state === 'MERGED' ? new Date(pr.updated_on) : undefined, + undefined, // mergeableState + undefined, // viewerCanUpdate + { + base: { + branch: pr.destination.branch.name, + sha: pr.destination.commit.hash, + repo: pr.destination.repository.name, + owner: pr.destination.repository.full_name.split('/')[0], + exists: true, + url: pr.destination.repository.links.html.href, + }, + head: { + branch: pr.source.branch.name, + sha: pr.source.commit.hash, + repo: pr.source.repository.name, + owner: pr.source.repository.full_name.split('/')[0], + exists: true, + url: pr.source.repository.links.html.href, + }, + isCrossRepository: pr.source.repository.uuid !== pr.destination.repository.uuid, + }, + undefined, // isDraft + undefined, // additions + undefined, // deletions + undefined, // commentsCount + undefined, // thumbsCount + getBitbucketReviewDecision(pr), + pr.participants // reviewRequests:PullRequestReviewer[] + ?.filter(prt => prt.role === 'REVIEWER') + .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)), + pr.participants // latestReviews:PullRequestReviewer[] + ?.filter(prt => prt.participated_on != null) + .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)), + undefined, // assignees:PullRequestMember[] -- it looks like there is no such thing as assignees on Bitbucket + undefined, // PullRequestStatusCheckRollupState + undefined, // IssueProject + ); +} From 037e64659c7ddbe4a3cf25e1c57bb508db6adca2 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 24 Feb 2025 15:19:23 +0100 Subject: [PATCH 2/6] Shows only requested reviewers who is pending (#4045, #4070) --- src/plus/integrations/providers/bitbucket/models.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts index 357d4f2b10416..e036c96ab7f61 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -242,7 +242,8 @@ export function fromBitbucketPullRequest(pr: BitbucketPullRequest, provider: Pro getBitbucketReviewDecision(pr), pr.participants // reviewRequests:PullRequestReviewer[] ?.filter(prt => prt.role === 'REVIEWER') - .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)), + .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)) + .filter(rv => rv.state === PullRequestReviewState.Pending), pr.participants // latestReviews:PullRequestReviewer[] ?.filter(prt => prt.participated_on != null) .map(prt => fromBitbucketParticipantToReviewer(prt, pr.closed_by, pr.state)), From fd2e6f369f61699906dca01cd74b6611923bdbfc Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 17 Feb 2025 17:14:36 +0100 Subject: [PATCH 3/6] Retrieve a Bitbucket PR by its id, that lets us show it in autolinks (#4045, #4070) --- src/plus/integrations/providers/bitbucket.ts | 10 +++--- .../providers/bitbucket/bitbucket.ts | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 7b401d5fb3e36..75f7b4b61a31f 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -75,11 +75,13 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderIssueOrPullRequest( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _id: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + id: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getIssueOrPullRequest(this, accessToken, repo.owner, repo.name, id, { + baseUrl: this.apiBaseUrl, + }); } protected override async getProviderIssue( diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index f18672ca56a16..b9f5bca1014e9 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -13,10 +13,12 @@ import { RequestClientError, RequestNotFoundError, } from '../../../../errors'; +import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; import { configuration } from '../../../../system/-webview/configuration'; +import { debug } from '../../../../system/decorators/log'; import { Logger } from '../../../../system/logger'; import type { LogScope } from '../../../../system/logger.scope'; import { getLogScope } from '../../../../system/logger.scope'; @@ -56,6 +58,7 @@ export class BitbucketApi implements Disposable { this._proxyAgent = null; } + @debug({ args: { 0: p => p.name, 1: '' } }) public async getPullRequestForBranch( provider: Provider, token: string, @@ -90,6 +93,36 @@ export class BitbucketApi implements Disposable { return fromBitbucketPullRequest(response.values[0], provider); } + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getIssueOrPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + id: string, + options: { + baseUrl: string; + }, + ): Promise { + const scope = getLogScope(); + + const response = await this.request( + provider, + token, + options.baseUrl, + `repositories/${owner}/${repo}/pullrequests/${id}?fields=*`, + { + method: 'GET', + }, + scope, + ); + + if (!response) { + return undefined; + } + return fromBitbucketPullRequest(response, provider); + } + private async request( provider: Provider, token: string, From ad54cdf110ae9453d62203f6ff196aa0480206dd Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 17 Feb 2025 19:46:51 +0100 Subject: [PATCH 4/6] Adds support of Bitbucket issues in autolinks (#4045, #4070) --- src/autolinks/autolinksProvider.ts | 5 +- src/plus/integrations/integration.ts | 12 +- .../integrations/providers/azure/azure.ts | 32 +++--- src/plus/integrations/providers/bitbucket.ts | 4 +- .../providers/bitbucket/bitbucket.ts | 108 ++++++++++++------ .../providers/bitbucket/models.ts | 48 ++++++++ 6 files changed, 156 insertions(+), 53 deletions(-) diff --git a/src/autolinks/autolinksProvider.ts b/src/autolinks/autolinksProvider.ts index 6789a2ba48bdf..96d7e262ef764 100644 --- a/src/autolinks/autolinksProvider.ts +++ b/src/autolinks/autolinksProvider.ts @@ -248,9 +248,12 @@ export class AutolinksProvider implements Disposable { ? integration.getIssueOrPullRequest( link.descriptor ?? remote.provider.repoDesc, this.getAutolinkEnrichableId(link), + { type: link.type }, ) : link.descriptor != null - ? linkIntegration?.getIssueOrPullRequest(link.descriptor, this.getAutolinkEnrichableId(link)) + ? linkIntegration?.getIssueOrPullRequest(link.descriptor, this.getAutolinkEnrichableId(link), { + type: link.type, + }) : undefined; enrichedAutolinks.set(id, [issueOrPullRequestPromise, link]); } diff --git a/src/plus/integrations/integration.ts b/src/plus/integrations/integration.ts index b6ff5e8a2f44e..a353f9f214cbf 100644 --- a/src/plus/integrations/integration.ts +++ b/src/plus/integrations/integration.ts @@ -15,7 +15,7 @@ import type { PagedResult } from '../../git/gitProvider'; import type { Account, UnidentifiedAuthor } from '../../git/models/author'; import type { DefaultBranch } from '../../git/models/defaultBranch'; import type { Issue, IssueShape } from '../../git/models/issue'; -import type { IssueOrPullRequest } from '../../git/models/issueOrPullRequest'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../git/models/issueOrPullRequest'; import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../git/models/repositoryMetadata'; import type { PullRequestUrlIdentity } from '../../git/utils/pullRequest.utils'; @@ -450,7 +450,7 @@ export abstract class IntegrationBase< async getIssueOrPullRequest( resource: T, id: string, - options?: { expiryOverride?: boolean | number }, + options?: { expiryOverride?: boolean | number; type?: IssueOrPullRequestType }, ): Promise { const scope = getLogScope(); @@ -464,7 +464,12 @@ export abstract class IntegrationBase< () => ({ value: (async () => { try { - const result = await this.getProviderIssueOrPullRequest(this._session!, resource, id); + const result = await this.getProviderIssueOrPullRequest( + this._session!, + resource, + id, + options?.type, + ); this.resetRequestExceptionCount(); return result; } catch (ex) { @@ -481,6 +486,7 @@ export abstract class IntegrationBase< session: ProviderAuthenticationSession, resource: T, id: string, + type: undefined | IssueOrPullRequestType, ): Promise; @debug() diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts index ec96fd07e1794..6b55c5111a92c 100644 --- a/src/plus/integrations/providers/azure/azure.ts +++ b/src/plus/integrations/providers/azure/azure.ts @@ -376,22 +376,22 @@ export class AzureDevOpsApi implements Disposable { throw new RequestNotFoundError(ex); case 401: // Unauthorized throw new AuthenticationError('azureDevOps', AuthenticationErrorReason.Unauthorized, ex); - // TODO: Learn the Azure API docs and put it in order: - // case 403: // Forbidden - // if (ex.message.includes('rate limit')) { - // let resetAt: number | undefined; - - // const reset = ex.response?.headers?.get('x-ratelimit-reset'); - // if (reset != null) { - // resetAt = parseInt(reset, 10); - // if (Number.isNaN(resetAt)) { - // resetAt = undefined; - // } - // } - - // throw new RequestRateLimitError(ex, token, resetAt); - // } - // throw new AuthenticationError('azure', AuthenticationErrorReason.Forbidden, ex); + case 403: // Forbidden + // TODO: Learn the Azure API docs and put it in order: + // if (ex.message.includes('rate limit')) { + // let resetAt: number | undefined; + + // const reset = ex.response?.headers?.get('x-ratelimit-reset'); + // if (reset != null) { + // resetAt = parseInt(reset, 10); + // if (Number.isNaN(resetAt)) { + // resetAt = undefined; + // } + // } + + // throw new RequestRateLimitError(ex, token, resetAt); + // } + throw new AuthenticationError('azure', AuthenticationErrorReason.Forbidden, ex); case 500: // Internal Server Error Logger.error(ex, scope); if (ex.response != null) { diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 75f7b4b61a31f..2ecbec1692cd6 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -3,7 +3,7 @@ import { HostingIntegrationId } from '../../../constants.integrations'; import type { Account } from '../../../git/models/author'; import type { DefaultBranch } from '../../../git/models/defaultBranch'; import type { Issue, IssueShape } from '../../../git/models/issue'; -import type { IssueOrPullRequest } from '../../../git/models/issueOrPullRequest'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; import type { PullRequest, PullRequestMergeMethod, PullRequestState } from '../../../git/models/pullRequest'; import type { RepositoryMetadata } from '../../../git/models/repositoryMetadata'; import type { IntegrationAuthenticationProviderDescriptor } from '../authentication/integrationAuthenticationProvider'; @@ -78,9 +78,11 @@ export class BitbucketIntegration extends HostingIntegration< { accessToken }: AuthenticationSession, repo: BitbucketRepositoryDescriptor, id: string, + type: undefined | IssueOrPullRequestType, ): Promise { return (await this.container.bitbucket)?.getIssueOrPullRequest(this, accessToken, repo.owner, repo.name, id, { baseUrl: this.apiBaseUrl, + type: type, }); } diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index b9f5bca1014e9..c253329be584b 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -13,7 +13,7 @@ import { RequestClientError, RequestNotFoundError, } from '../../../../errors'; -import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; +import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; @@ -23,8 +23,8 @@ import { Logger } from '../../../../system/logger'; import type { LogScope } from '../../../../system/logger.scope'; import { getLogScope } from '../../../../system/logger.scope'; import { maybeStopWatch } from '../../../../system/stopwatch'; -import type { BitbucketPullRequest } from './models'; -import { fromBitbucketPullRequest } from './models'; +import type { BitbucketIssue, BitbucketPullRequest } from './models'; +import { bitbucketIssueStateToState, fromBitbucketPullRequest } from './models'; export class BitbucketApi implements Disposable { private readonly _disposable: Disposable; @@ -102,25 +102,69 @@ export class BitbucketApi implements Disposable { id: string, options: { baseUrl: string; + type?: IssueOrPullRequestType; }, ): Promise { const scope = getLogScope(); - const response = await this.request( - provider, - token, - options.baseUrl, - `repositories/${owner}/${repo}/pullrequests/${id}?fields=*`, - { - method: 'GET', - }, - scope, - ); + if (options?.type !== 'issue') { + try { + const prResponse = await this.request( + provider, + token, + options.baseUrl, + `repositories/${owner}/${repo}/pullrequests/${id}?fields=%2Bvalues.reviewers,%2Bvalues.participants`, + { + method: 'GET', + }, + scope, + ); - if (!response) { - return undefined; + if (prResponse) { + return fromBitbucketPullRequest(prResponse, provider); + } + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } } - return fromBitbucketPullRequest(response, provider); + + if (options?.type !== 'pullrequest') { + try { + const issueResponse = await this.request( + provider, + token, + options.baseUrl, + `repositories/${owner}/${repo}/issues/${id}`, + { + method: 'GET', + }, + scope, + ); + + if (issueResponse) { + return { + id: issueResponse.id.toString(), + type: 'issue', + nodeId: issueResponse.id.toString(), + provider: provider, + createdDate: new Date(issueResponse.created_on), + updatedDate: new Date(issueResponse.updated_on), + state: bitbucketIssueStateToState(issueResponse.state), + closed: issueResponse.state === 'closed', + title: issueResponse.title, + url: issueResponse.links.html.href, + }; + } + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + return undefined; } private async request( @@ -192,22 +236,22 @@ export class BitbucketApi implements Disposable { throw new RequestNotFoundError(ex); case 401: // Unauthorized throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Unauthorized, ex); - // TODO: Learn the Bitbucket API docs and put it in order: - // case 403: // Forbidden - // if (ex.message.includes('rate limit')) { - // let resetAt: number | undefined; - - // const reset = ex.response?.headers?.get('x-ratelimit-reset'); - // if (reset != null) { - // resetAt = parseInt(reset, 10); - // if (Number.isNaN(resetAt)) { - // resetAt = undefined; - // } - // } - - // throw new RequestRateLimitError(ex, token, resetAt); - // } - // throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Forbidden, ex); + case 403: // Forbidden + // TODO: Learn the Bitbucket API docs and put it in order: + // if (ex.message.includes('rate limit')) { + // let resetAt: number | undefined; + + // const reset = ex.response?.headers?.get('x-ratelimit-reset'); + // if (reset != null) { + // resetAt = parseInt(reset, 10); + // if (Number.isNaN(resetAt)) { + // resetAt = undefined; + // } + // } + + // throw new RequestRateLimitError(ex, token, resetAt); + // } + throw new AuthenticationError('bitbucket', AuthenticationErrorReason.Forbidden, ex); case 500: // Internal Server Error Logger.error(ex, scope); if (ex.response != null) { diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts index e036c96ab7f61..b64359ac1fbc7 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -72,6 +72,17 @@ interface BitbucketPullRequestCommit { }; } +export type BitbucketIssueState = + | 'submitted' + | 'new' + | 'open' + | 'resolved' + | 'on hold' + | 'invalid' + | 'duplicate' + | 'wontfix' + | 'closed'; + export interface BitbucketPullRequest { type: 'pullrequest'; id: number; @@ -121,6 +132,26 @@ export interface BitbucketPullRequest { }; } +export interface BitbucketIssue { + type: string; + id: number; + title: string; + reporter: BitbucketUser; + assignee?: BitbucketUser; + state: BitbucketIssueState; + created_on: string; + updated_on: string; + repository: BitbucketRepository; + links: { + self: BitbucketLink; + html: BitbucketLink; + comments: BitbucketLink; + attachments: BitbucketLink; + watch: BitbucketLink; + vote: BitbucketLink; + }; +} + export function bitbucketPullRequestStateToState(state: BitbucketPullRequestState): IssueOrPullRequestState { switch (state) { case 'DECLINED': @@ -134,6 +165,23 @@ export function bitbucketPullRequestStateToState(state: BitbucketPullRequestStat } } +export function bitbucketIssueStateToState(state: BitbucketIssueState): IssueOrPullRequestState { + switch (state) { + case 'resolved': + case 'invalid': + case 'duplicate': + case 'wontfix': + case 'closed': + return 'closed'; + case 'submitted': + case 'new': + case 'open': + case 'on hold': + default: + return 'opened'; + } +} + export function isClosedBitbucketPullRequestState(state: BitbucketPullRequestState): boolean { return bitbucketPullRequestStateToState(state) !== 'opened'; } From f883fa77e3e906414e0267976f684e5eac1caa32 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Mon, 24 Feb 2025 15:44:44 +0100 Subject: [PATCH 5/6] Requires baseUrl be provided to methods of BitbucketApi class (#4045, #4070) --- src/plus/integrations/providers/bitbucket.ts | 19 ++++++++++++------- .../providers/bitbucket/bitbucket.ts | 18 ++++++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index 2ecbec1692cd6..601de2af18220 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -80,10 +80,17 @@ export class BitbucketIntegration extends HostingIntegration< id: string, type: undefined | IssueOrPullRequestType, ): Promise { - return (await this.container.bitbucket)?.getIssueOrPullRequest(this, accessToken, repo.owner, repo.name, id, { - baseUrl: this.apiBaseUrl, - type: type, - }); + return (await this.container.bitbucket)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + id, + this.apiBaseUrl, + { + type: type, + }, + ); } protected override async getProviderIssue( @@ -109,9 +116,7 @@ export class BitbucketIntegration extends HostingIntegration< repo.owner, repo.name, branch, - { - baseUrl: this.apiBaseUrl, - }, + this.apiBaseUrl, ); } diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index c253329be584b..a3a3e700b773d 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -65,9 +65,7 @@ export class BitbucketApi implements Disposable { owner: string, repo: string, branch: string, - options: { - baseUrl: string; - }, + baseUrl: string, ): Promise { const scope = getLogScope(); @@ -79,7 +77,7 @@ export class BitbucketApi implements Disposable { }>( provider, token, - options.baseUrl, + baseUrl, `repositories/${owner}/${repo}/pullrequests?q=source.branch.name="${branch}"&fields=%2Bvalues.reviewers,%2Bvalues.participants`, { method: 'GET', @@ -100,8 +98,8 @@ export class BitbucketApi implements Disposable { owner: string, repo: string, id: string, - options: { - baseUrl: string; + baseUrl: string, + options?: { type?: IssueOrPullRequestType; }, ): Promise { @@ -112,7 +110,7 @@ export class BitbucketApi implements Disposable { const prResponse = await this.request( provider, token, - options.baseUrl, + baseUrl, `repositories/${owner}/${repo}/pullrequests/${id}?fields=%2Bvalues.reviewers,%2Bvalues.participants`, { method: 'GET', @@ -136,7 +134,7 @@ export class BitbucketApi implements Disposable { const issueResponse = await this.request( provider, token, - options.baseUrl, + baseUrl, `repositories/${owner}/${repo}/issues/${id}`, { method: 'GET', @@ -172,8 +170,8 @@ export class BitbucketApi implements Disposable { token: string, baseUrl: string, route: string, - options: { method: RequestInit['method'] } & Record, - scope: LogScope | undefined, + options?: { method: RequestInit['method'] } & Record, + scope?: LogScope | undefined, cancellation?: CancellationToken | undefined, ): Promise { const url = `${baseUrl}/${route}`; From aedf6007df195137ae61889ebd016b24bb796584 Mon Sep 17 00:00:00 2001 From: Sergei Shmakov Date: Tue, 18 Feb 2025 13:05:29 +0100 Subject: [PATCH 6/6] Mentions Bitbucket cloud integration in the changelog (#4045, #4070) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1a07dda3009..69f6b83973d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Adds AI model status and model switcher to the _Home_ view ([#4064](https://github.com/gitkraken/vscode-gitlens/issues/4064)) - Adds Anthropic Claude 3.7 Sonnet model for GitLens' AI features ([#4101](https://github.com/gitkraken/vscode-gitlens/issues/4101)) - Adds Google Gemini 2.0 Flash-Lite model for GitLens' AI features ([#4104](https://github.com/gitkraken/vscode-gitlens/issues/4104)) +- Adds integration with Bitbucket Cloud by showing enriched links to PRs and issues [#4045](https://github.com/gitkraken/vscode-gitlens/issues/4045) ### Changed