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 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/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/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 0f496300b5ba8..601de2af18220 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'; @@ -75,11 +75,22 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderIssueOrPullRequest( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _id: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + id: string, + type: undefined | IssueOrPullRequestType, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getIssueOrPullRequest( + this, + accessToken, + repo.owner, + repo.name, + id, + this.apiBaseUrl, + { + type: type, + }, + ); } protected override async getProviderIssue( @@ -91,15 +102,22 @@ 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, + 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..a3a3e700b773d --- /dev/null +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -0,0 +1,287 @@ +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 { IssueOrPullRequest, IssueOrPullRequestType } 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'; +import { maybeStopWatch } from '../../../../system/stopwatch'; +import type { BitbucketIssue, BitbucketPullRequest } from './models'; +import { bitbucketIssueStateToState, 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; + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getPullRequestForBranch( + provider: Provider, + token: string, + owner: string, + repo: string, + branch: string, + baseUrl: string, + ): Promise { + const scope = getLogScope(); + + const response = await this.request<{ + values: BitbucketPullRequest[]; + pagelen: number; + size: number; + page: number; + }>( + provider, + token, + 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); + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + public async getIssueOrPullRequest( + provider: Provider, + token: string, + owner: string, + repo: string, + id: string, + baseUrl: string, + options?: { + type?: IssueOrPullRequestType; + }, + ): Promise { + const scope = getLogScope(); + + if (options?.type !== 'issue') { + try { + const prResponse = await this.request( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/pullrequests/${id}?fields=%2Bvalues.reviewers,%2Bvalues.participants`, + { + method: 'GET', + }, + scope, + ); + + if (prResponse) { + return fromBitbucketPullRequest(prResponse, provider); + } + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } + } + + if (options?.type !== 'pullrequest') { + try { + const issueResponse = await this.request( + provider, + token, + 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( + 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); + 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) { + 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..b64359ac1fbc7 --- /dev/null +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -0,0 +1,302 @@ +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 type BitbucketIssueState = + | 'submitted' + | 'new' + | 'open' + | 'resolved' + | 'on hold' + | 'invalid' + | 'duplicate' + | 'wontfix' + | 'closed'; + +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 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': + case 'SUPERSEDED': + return 'closed'; + case 'MERGED': + return 'merged'; + case 'OPEN': + default: + return 'opened'; + } +} + +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'; +} + +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)) + .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)), + undefined, // assignees:PullRequestMember[] -- it looks like there is no such thing as assignees on Bitbucket + undefined, // PullRequestStatusCheckRollupState + undefined, // IssueProject + ); +}