diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d8fe104c175a..4299d9a131875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- Adds support for associated BitBucket, BitBucket Server, and Azure DevOps pull requests on commits ([#4192](https://github.com/gitkraken/vscode-gitlens/issues/4192)) - Adds the ability to search for GitHub Enterprise and GitLab Self-Managed pull requests by URL in the main step of Launchpad - Adds Ollama and OpenRouter support for GitLens' AI features ([#3311](https://github.com/gitkraken/vscode-gitlens/issues/3311), [#3906](https://github.com/gitkraken/vscode-gitlens/issues/3906)) - Adds Google Gemini 2.5 Flash (Preview) model, and OpenAI GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4 mini, and o3 models for GitLens' AI features ([#4235](https://github.com/gitkraken/vscode-gitlens/issues/4235)) diff --git a/package.json b/package.json index 1e7ca5a8c4106..a71fd6fd24543 100644 --- a/package.json +++ b/package.json @@ -5008,6 +5008,7 @@ "gitlens.advanced.messages": { "type": "object", "default": { + "suppressBitbucketPRCommitLinksAppNotInstalledWarning": false, "suppressCommitHasNoPreviousCommitWarning": false, "suppressCommitNotFoundWarning": false, "suppressCreatePullRequestPrompt": false, diff --git a/src/config.ts b/src/config.ts index 264939dcde15b..a39ea25653f5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -151,6 +151,7 @@ export type StatusBarCommands = // NOTE: Must be kept in sync with `gitlens.advanced.messages` setting in the package.json export type SuppressedMessages = + | 'suppressBitbucketPRCommitLinksAppNotInstalledWarning' | 'suppressCommitHasNoPreviousCommitWarning' | 'suppressCommitNotFoundWarning' | 'suppressCreatePullRequestPrompt' diff --git a/src/git/remotes/azure-devops.ts b/src/git/remotes/azure-devops.ts index c022e09736af3..dc67f33c235e6 100644 --- a/src/git/remotes/azure-devops.ts +++ b/src/git/remotes/azure-devops.ts @@ -4,7 +4,7 @@ import type { Source } from '../../constants.telemetry'; import type { Container } from '../../container'; import { HostingIntegration } from '../../plus/integrations/integration'; import { remoteProviderIdToIntegrationId } from '../../plus/integrations/integrationService'; -import { parseAzureHttpsUrl } from '../../plus/integrations/providers/azure/models'; +import { isVsts, parseAzureHttpsUrl } from '../../plus/integrations/providers/azure/models'; import type { Brand, Unbrand } from '../../system/brand'; import type { CreatePullRequestRemoteResource } from '../models/remoteResource'; import type { Repository } from '../models/repository'; @@ -122,6 +122,20 @@ export class AzureDevOpsRemote extends RemoteProvider { return 'Azure DevOps'; } + override get owner(): string | undefined { + if (isVsts(this.domain)) { + return this.domain.split('.')[0]; + } + return super.owner; + } + + override get repoName(): string | undefined { + if (isVsts(this.domain)) { + return this.path; + } + return super.repoName; + } + override get providerDesc(): | { id: GkProviderId; diff --git a/src/messages.ts b/src/messages.ts index 65c9485feb0ee..9e7f654262ec1 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -94,6 +94,22 @@ export async function showGenericErrorMessage(message: string): Promise { } } +export async function showBitbucketPRCommitLinksAppNotInstalledWarningMessage(revLink: string): Promise { + const allowAccess = { title: 'Allow Access' }; + const result = await showMessage( + 'warn', + `GitLens cannot access Bitbucket PRs for commits. + Allow access by visiting [this commit](${revLink}) on Bitbucket and click “Pull requests” under the “Apps” section on the bottom right + or [read our docs](https://help.gitkraken.com/gitlens/gitlens-troubleshooting/#enable-showing-bitbucket-pull-request-for-a-commit) for more info.`, + 'suppressBitbucketPRCommitLinksAppNotInstalledWarning', + { title: "Don't Show Again" }, + allowAccess, + ); + if (result === allowAccess) { + void openUrl(revLink); + } +} + export function showFileNotUnderSourceControlWarningMessage(message: string): Promise { return showMessage( 'warn', diff --git a/src/plus/integrations/providers/azure/azure.ts b/src/plus/integrations/providers/azure/azure.ts index aa9d41255894f..cb5f93d728685 100644 --- a/src/plus/integrations/providers/azure/azure.ts +++ b/src/plus/integrations/providers/azure/azure.ts @@ -13,6 +13,7 @@ import { RequestClientError, RequestNotFoundError, } from '../../../../errors'; +import type { UnidentifiedAuthor } from '../../../../git/models/author'; import type { Issue } from '../../../../git/models/issue'; import type { IssueOrPullRequest } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; @@ -25,6 +26,7 @@ import type { LogScope } from '../../../../system/logger.scope'; import { getLogScope } from '../../../../system/logger.scope'; import { maybeStopWatch } from '../../../../system/stopwatch'; import type { + AzureGitCommit, AzureProjectDescriptor, AzurePullRequest, AzurePullRequestWithLinks, @@ -112,6 +114,63 @@ export class AzureDevOpsApi implements Disposable { } } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequestForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + const [projectName, _, repoName] = repo.split('/'); + try { + const prResult = await this.request<{ results: Record[] }>( + provider, + token, + baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/pullrequestquery?api-version=7.1`, + { + method: 'POST', + body: JSON.stringify({ + queries: [ + { + items: [rev], + type: 'commit', + }, + ], + }), + }, + scope, + cancellation, + ); + + const pr = prResult?.results[0]?.[rev]?.[0]; + if (pr == null) return undefined; + + const pullRequest = await this.request( + provider, + token, + undefined, + pr.url, + { method: 'GET' }, + scope, + cancellation, + ); + if (pullRequest == null) return undefined; + + return fromAzurePullRequest(pullRequest, provider, owner); + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + @debug({ args: { 0: p => p.name, 1: '' } }) public async getIssueOrPullRequest( provider: Provider, @@ -255,6 +314,56 @@ export class AzureDevOpsApi implements Disposable { return undefined; } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getAccountForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + ): Promise { + const scope = getLogScope(); + const [projectName, _, repoName] = repo.split('/'); + + try { + // Try to get the Work item (wit) first with specific fields + const commit = await this.request( + provider, + token, + baseUrl, + `${owner}/${projectName}/_apis/git/repositories/${repoName}/commits/${rev}`, + { + method: 'GET', + }, + scope, + ); + const author = commit?.author; + if (!author) { + return undefined; + } + // Azure API never gives us an id/username we can use, therefore we always return UnidentifiedAuthor + return { + provider: provider, + id: undefined, + username: undefined, + name: author?.name, + email: author?.email, + avatarUrl: undefined, + } satisfies UnidentifiedAuthor; + } catch (ex) { + if (ex.original?.status !== 404) { + Logger.error(ex, scope); + return undefined; + } + } + + return undefined; + } + async getWorkItemStateCategory( issueType: string, state: string, @@ -310,13 +419,13 @@ export class AzureDevOpsApi implements Disposable { private async request( provider: Provider, token: string, - baseUrl: string, + baseUrl: string | undefined, route: string, options: { method: RequestInit['method'] } & Record, scope: LogScope | undefined, cancellation?: CancellationToken | undefined, ): Promise { - const url = `${baseUrl}/${route}`; + const url = baseUrl ? `${baseUrl}/${route}` : route; let rsp: Response; try { diff --git a/src/plus/integrations/providers/azure/models.ts b/src/plus/integrations/providers/azure/models.ts index fdb44da3ad3ec..c6df21a2097f0 100644 --- a/src/plus/integrations/providers/azure/models.ts +++ b/src/plus/integrations/providers/azure/models.ts @@ -202,11 +202,44 @@ export interface AzureRepository { isInMaintenance: boolean; } +export interface AzureGitUser { + date?: string; + email?: string; + imageUrl?: string; + name: string; +} + export interface AzureGitCommitRef { commitId: string; url: string; } +export interface AzureGitCommit { + _links: { + changes: AzureLink; + repository: AzureLink; + self: AzureLink; + web: AzureLink; + }; + author: AzureGitUser; + comment: string; + commentTruncated?: boolean; + commitId: string; + commitTooManyChanges?: boolean; + committer: AzureGitUser; + parents: string[]; + push: { + date: string; + pushedBy: AzureUser; + pushId: number; + }; + remoteUrl: string; + statuses?: AzureGitStatus[]; + treeId: string; + url: string; + workItems?: AzureResourceRef[]; +} + export interface AzureResourceRef { id: string; url: string; @@ -332,6 +365,9 @@ export function getAzureOwner(url: URL): string { const isVSTS = vstsHostnameRegex.test(url.hostname); return isVSTS ? getVSTSOwner(url) : getAzureDevOpsOwner(url); } +export function isVsts(domain: string): boolean { + return vstsHostnameRegex.test(domain); +} export function getAzureRepo(pr: AzurePullRequest): string { return `${pr.repository.project.name}/_git/${pr.repository.name}`; diff --git a/src/plus/integrations/providers/azureDevOps.ts b/src/plus/integrations/providers/azureDevOps.ts index aca87bc1d33c4..e7a9d92b40d8e 100644 --- a/src/plus/integrations/providers/azureDevOps.ts +++ b/src/plus/integrations/providers/azureDevOps.ts @@ -1,7 +1,7 @@ import type { AuthenticationSession, CancellationToken } from 'vscode'; import { window } from 'vscode'; import { HostingIntegrationId } from '../../../constants.integrations'; -import type { Account } from '../../../git/models/author'; +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'; @@ -218,14 +218,22 @@ export class AzureDevOpsIntegration extends HostingIntegration< } protected override async getProviderAccountForCommit( - _session: AuthenticationSession, - _repo: AzureRepositoryDescriptor, - _rev: string, - _options?: { + { accessToken }: AuthenticationSession, + repo: AzureRepositoryDescriptor, + rev: string, + options?: { avatarSize?: number; }, - ): Promise { - return Promise.resolve(undefined); + ): Promise { + return (await this.container.azure)?.getAccountForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + options, + ); } protected override async getProviderAccountForEmail( @@ -293,11 +301,18 @@ export class AzureDevOpsIntegration extends HostingIntegration< } protected override async getProviderPullRequestForCommit( - _session: AuthenticationSession, - _repo: AzureRepositoryDescriptor, - _rev: string, + { accessToken }: AuthenticationSession, + repo: AzureRepositoryDescriptor, + rev: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.azure)?.getPullRequestForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + ); } public override async getRepoInfo(repo: { diff --git a/src/plus/integrations/providers/bitbucket-server.ts b/src/plus/integrations/providers/bitbucket-server.ts index 0123424b71923..6d52e1c069eea 100644 --- a/src/plus/integrations/providers/bitbucket-server.ts +++ b/src/plus/integrations/providers/bitbucket-server.ts @@ -2,7 +2,7 @@ import type { AuthenticationSession, CancellationToken } from 'vscode'; import { md5 } from '@env/crypto'; import { SelfHostedIntegrationId } from '../../../constants.integrations'; import type { Container } from '../../../container'; -import type { Account } from '../../../git/models/author'; +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, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; @@ -64,14 +64,24 @@ export class BitbucketServerIntegration extends HostingIntegration< } protected override async getProviderAccountForCommit( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _rev: string, - _options?: { + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + rev: string, + options?: { avatarSize?: number; }, - ): Promise { - return Promise.resolve(undefined); + ): Promise { + return (await this.container.bitbucket)?.getServerAccountForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + { + avatarSize: options?.avatarSize, + }, + ); } protected override async getProviderAccountForEmail( @@ -101,10 +111,6 @@ export class BitbucketServerIntegration extends HostingIntegration< if (type === 'issue') { return undefined; } - const integration = await this.container.integrations.get(this.id); - if (!integration) { - return undefined; - } return (await this.container.bitbucket)?.getServerPullRequestById( this, accessToken, @@ -112,7 +118,6 @@ export class BitbucketServerIntegration extends HostingIntegration< repo.name, id, this.apiBaseUrl, - integration, ); } @@ -133,10 +138,6 @@ export class BitbucketServerIntegration extends HostingIntegration< include?: PullRequestState[]; }, ): Promise { - const integration = await this.container.integrations.get(this.id); - if (!integration) { - return undefined; - } return (await this.container.bitbucket)?.getServerPullRequestForBranch( this, accessToken, @@ -144,16 +145,22 @@ export class BitbucketServerIntegration extends HostingIntegration< repo.name, branch, this.apiBaseUrl, - integration, ); } protected override async getProviderPullRequestForCommit( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _rev: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + rev: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getServerPullRequestForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + ); } public override async getRepoInfo(repo: { owner: string; name: string }): Promise { @@ -210,14 +217,13 @@ export class BitbucketServerIntegration extends HostingIntegration< } const api = await this.getProvidersApi(); - const integration = await this.container.integrations.get(this.id); - if (!api || !integration) { + if (!api) { return undefined; } const prs = await api.getBitbucketServerPullRequestsForCurrentUser(this.apiBaseUrl, { accessToken: session.accessToken, }); - return prs?.map(pr => fromProviderPullRequest(pr, integration)); + return prs?.map(pr => fromProviderPullRequest(pr, this)); } protected override async searchProviderMyIssues( diff --git a/src/plus/integrations/providers/bitbucket-server/models.ts b/src/plus/integrations/providers/bitbucket-server/models.ts index a4aa5f41f6d3d..2b4974814c40d 100644 --- a/src/plus/integrations/providers/bitbucket-server/models.ts +++ b/src/plus/integrations/providers/bitbucket-server/models.ts @@ -51,6 +51,12 @@ export interface BitbucketServerPullRequestRef { }; } +export interface BitbucketAuthor { + name: string; + emailAddress: string; + id: undefined; +} + export interface BitbucketServerUser { name: string; emailAddress: string; @@ -73,6 +79,20 @@ export interface BitbucketServerPullRequestUser { status: 'UNAPPROVED' | 'NEEDS_WORK' | 'APPROVED'; } +export interface BitbucketServerBriefCommit { + displayId: string; + id: string; +} + +export interface BitbucketServerCommit extends BitbucketServerBriefCommit { + author: BitbucketServerUser | BitbucketAuthor; + authorTimestamp: number; + committer: BitbucketServerUser | BitbucketAuthor; + committerTimestamp: number; + message: string; + parents: (BitbucketServerCommit | BitbucketServerBriefCommit)[]; +} + export interface BitbucketServerPullRequest { id: number; version: number; diff --git a/src/plus/integrations/providers/bitbucket.ts b/src/plus/integrations/providers/bitbucket.ts index fcda8d888e98d..e67dd2e06da3d 100644 --- a/src/plus/integrations/providers/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket.ts @@ -1,7 +1,7 @@ import type { AuthenticationSession, CancellationToken } from 'vscode'; import { md5 } from '@env/crypto'; import { HostingIntegrationId } from '../../../constants.integrations'; -import type { Account } from '../../../git/models/author'; +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, IssueOrPullRequestType } from '../../../git/models/issueOrPullRequest'; @@ -49,14 +49,24 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderAccountForCommit( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _rev: string, - _options?: { + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + rev: string, + options?: { avatarSize?: number; }, - ): Promise { - return Promise.resolve(undefined); + ): Promise { + return (await this.container.bitbucket)?.getAccountForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + { + avatarSize: options?.avatarSize, + }, + ); } protected override async getProviderAccountForEmail( @@ -131,11 +141,18 @@ export class BitbucketIntegration extends HostingIntegration< } protected override async getProviderPullRequestForCommit( - _session: AuthenticationSession, - _repo: BitbucketRepositoryDescriptor, - _rev: string, + { accessToken }: AuthenticationSession, + repo: BitbucketRepositoryDescriptor, + rev: string, ): Promise { - return Promise.resolve(undefined); + return (await this.container.bitbucket)?.getPullRequestForCommit( + this, + accessToken, + repo.owner, + repo.name, + rev, + this.apiBaseUrl, + ); } protected override async getProviderRepositoryMetadata( diff --git a/src/plus/integrations/providers/bitbucket/bitbucket.ts b/src/plus/integrations/providers/bitbucket/bitbucket.ts index 2c94e8338f667..52f06cdb22b17 100644 --- a/src/plus/integrations/providers/bitbucket/bitbucket.ts +++ b/src/plus/integrations/providers/bitbucket/bitbucket.ts @@ -13,24 +13,32 @@ import { RequestClientError, RequestNotFoundError, } from '../../../../errors'; +import type { Account, CommitAuthor, UnidentifiedAuthor } from '../../../../git/models/author'; import type { Issue } from '../../../../git/models/issue'; import type { IssueOrPullRequest, IssueOrPullRequestType } from '../../../../git/models/issueOrPullRequest'; import type { PullRequest } from '../../../../git/models/pullRequest'; import type { Provider } from '../../../../git/models/remoteProvider'; import type { RepositoryMetadata } from '../../../../git/models/repositoryMetadata'; -import { showIntegrationRequestFailed500WarningMessage } from '../../../../messages'; +import { + showBitbucketPRCommitLinksAppNotInstalledWarningMessage, + 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 { Integration } from '../../integration'; -import type { BitbucketServerPullRequest } from '../bitbucket-server/models'; +import type { BitbucketServerCommit, BitbucketServerPullRequest } from '../bitbucket-server/models'; import { normalizeBitbucketServerPullRequest } from '../bitbucket-server/models'; import { fromProviderPullRequest } from '../models'; -import type { BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models'; -import { bitbucketIssueStateToState, fromBitbucketIssue, fromBitbucketPullRequest } from './models'; +import type { BitbucketCommit, BitbucketIssue, BitbucketPullRequest, BitbucketRepository } from './models'; +import { + bitbucketIssueStateToState, + fromBitbucketIssue, + fromBitbucketPullRequest, + parseRawBitbucketAuthor, +} from './models'; export class BitbucketApi implements Disposable { private readonly _disposable: Disposable; @@ -105,7 +113,6 @@ export class BitbucketApi implements Disposable { repo: string, branch: string, baseUrl: string, - integration: Integration, ): Promise { const scope = getLogScope(); @@ -130,7 +137,7 @@ export class BitbucketApi implements Disposable { } const providersPr = normalizeBitbucketServerPullRequest(response.values[0]); - const gitlensPr = fromProviderPullRequest(providersPr, integration); + const gitlensPr = fromProviderPullRequest(providersPr, provider); return gitlensPr; } @@ -283,7 +290,6 @@ export class BitbucketApi implements Disposable { repo: string, id: string, baseUrl: string, - integration: Integration, ): Promise { const scope = getLogScope(); @@ -301,7 +307,7 @@ export class BitbucketApi implements Disposable { if (prResponse) { const providersPr = normalizeBitbucketServerPullRequest(prResponse); - const gitlensPr = fromProviderPullRequest(providersPr, integration); + const gitlensPr = fromProviderPullRequest(providersPr, provider); return gitlensPr; } } catch (ex) { @@ -369,6 +375,217 @@ export class BitbucketApi implements Disposable { } } + @debug({ args: { 0: p => p.name, 1: '' } }) + async getServerPullRequestForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + try { + const response = await this.request<{ values: BitbucketServerPullRequest[] }>( + provider, + token, + baseUrl, + `projects/${owner}/repos/${repo}/commits/${rev}/pull-requests`, //?fields=${fieldsParam}`, + { + method: 'GET', + }, + scope, + cancellation, + ); + const prResponse = response?.values?.reduce( + (acc, pr) => (!acc || pr.updatedDate > acc.updatedDate ? pr : acc), + undefined, + ); + if (!prResponse) return undefined; + const providersPr = normalizeBitbucketServerPullRequest(prResponse); + const gitlensPr = fromProviderPullRequest(providersPr, provider); + return gitlensPr; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getPullRequestForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + try { + const fields = [ + '+values.*', + '+values.destination.repository', + '+values.destination.branch.*', + '+values.destination.commit.*', + '+values.source.repository.*', + '+values.source.branch.*', + '+values.source.commit.*', + ]; + const fieldsParam = encodeURIComponent(fields.join(',')); + const response = await this.request<{ values: BitbucketPullRequest[] }>( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/commit/${rev}/pullrequests?fields=${fieldsParam}`, + { + method: 'GET', + }, + scope, + cancellation, + ); + const pr = response?.values?.reduce( + (acc, pr) => (!acc || pr.updated_on > acc.updated_on ? pr : acc), + undefined, + ); + if (!pr) return undefined; + return fromBitbucketPullRequest(pr, provider); + } catch (ex) { + if (ex.original instanceof ProviderFetchError) { + const json = await ex.original.response.json(); + if (json?.error === 'Invalid or unknown installation') { + // TODO: In future get it on to home as an worning on the integratin istelf "this integration has issues" + // even user suppresses the message it's still visible with some capacity. It's a broader thing to get other errors. + const commitWebUrl = `https://bitbucket.org/${owner}/${repo}/commits/${rev}`; + void showBitbucketPRCommitLinksAppNotInstalledWarningMessage(commitWebUrl); + return undefined; + } + } + Logger.error(ex, scope); + return undefined; + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getAccountForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + try { + const commit = await this.request( + provider, + token, + baseUrl, + `repositories/${owner}/${repo}/commit/${rev}`, + { + method: 'GET', + }, + scope, + cancellation, + ); + if (!commit) { + return undefined; + } + + const { name, email } = parseRawBitbucketAuthor(commit.author.raw); + const commitAuthor: CommitAuthor = { + provider: provider, + id: commit.author.user?.account_id, + username: commit.author.user?.nickname, + name: commit.author.user?.display_name || name, + email: email, + avatarUrl: commit.author.user?.links?.avatar?.href, + }; + if (commitAuthor.id != null && commitAuthor.username != null) { + return { + ...commitAuthor, + id: commitAuthor.id, + } satisfies Account; + } + return { + ...commitAuthor, + id: undefined, + username: undefined, + } satisfies UnidentifiedAuthor; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + + @debug({ args: { 0: p => p.name, 1: '' } }) + async getServerAccountForCommit( + provider: Provider, + token: string, + owner: string, + repo: string, + rev: string, + baseUrl: string, + _options?: { + avatarSize?: number; + }, + cancellation?: CancellationToken, + ): Promise { + const scope = getLogScope(); + + try { + const commit = await this.request( + provider, + token, + baseUrl, + `projects/${owner}/repos/${repo}/commits/${rev}`, + { + method: 'GET', + }, + scope, + cancellation, + ); + if (!commit?.author) { + return undefined; + } + if (commit.author.id != null) { + return { + provider: provider, + id: commit.author.id.toString(), + username: commit.author.name, + name: commit.author.name, + email: commit.author.emailAddress, + avatarUrl: commit.author?.avatarUrl, + } satisfies Account; + } + return { + provider: provider, + id: undefined, + username: undefined, + name: commit.author.name, + email: commit.author.emailAddress, + avatarUrl: undefined, + } satisfies UnidentifiedAuthor; + } catch (ex) { + Logger.error(ex, scope); + return undefined; + } + } + private async request( provider: Provider, token: string, diff --git a/src/plus/integrations/providers/bitbucket/models.ts b/src/plus/integrations/providers/bitbucket/models.ts index 63637f849c789..8bfaf0711a5f2 100644 --- a/src/plus/integrations/providers/bitbucket/models.ts +++ b/src/plus/integrations/providers/bitbucket/models.ts @@ -104,6 +104,12 @@ export interface BitbucketRepository { }; } +interface BitbucketCommitAuthor { + type: 'author'; + raw: string; + user: BitbucketUser; +} + type BitbucketMergeStrategy = | 'merge_commit' | 'squash' @@ -118,7 +124,43 @@ interface BitbucketBranch { default_merge_strategy?: BitbucketMergeStrategy; } -interface BitbucketPullRequestCommit { +// It parses a raw author sitring like "Sergei Shmakov GK " to name and email +const parseRawBitbucketAuthorRegex = /^(.*) <(.*)>$/; +export function parseRawBitbucketAuthor(raw: string): { name: string; email: string } { + const match = raw.match(parseRawBitbucketAuthorRegex); + if (match) { + return { name: match[1], email: match[2] }; + } + return { name: raw, email: '' }; +} + +export interface BitbucketCommit extends BitbucketBriefCommit { + author: BitbucketCommitAuthor; + date: string; + links: { + approve: BitbucketLink; + comments: BitbucketLink; + diff: BitbucketLink; + html: BitbucketLink; + self: BitbucketLink; + statuses: BitbucketLink; + }; + message: string; + parents: BitbucketBriefCommit[]; + participants: BitbucketPullRequestParticipant[]; + rendered: { + message: string; + }; + repository: BitbucketRepository; + summary: { + type: 'rendered'; + raw: string; + markup: string; + html: string; + }; +} + +interface BitbucketBriefCommit { type: 'commit'; hash: string; links: { @@ -144,7 +186,7 @@ export interface BitbucketPullRequest { title: string; description: string; state: BitbucketPullRequestState; - merge_commit: null | BitbucketPullRequestCommit; + merge_commit: null | BitbucketBriefCommit; comment_count: number; task_count: number; close_source_branch: boolean; @@ -155,12 +197,12 @@ export interface BitbucketPullRequest { updated_on: string; destination: { branch: BitbucketBranch; - commit: BitbucketPullRequestCommit; + commit: BitbucketBriefCommit; repository: BitbucketRepository; }; source: { branch: BitbucketBranch; - commit: BitbucketPullRequestCommit; + commit: BitbucketBriefCommit; repository: BitbucketRepository; }; summary: { diff --git a/src/plus/integrations/providers/models.ts b/src/plus/integrations/providers/models.ts index 66c376ace5e6f..e91db4022628a 100644 --- a/src/plus/integrations/providers/models.ts +++ b/src/plus/integrations/providers/models.ts @@ -55,7 +55,7 @@ import { PullRequestReviewState, PullRequestStatusCheckRollupState, } from '../../../git/models/pullRequest'; -import type { ProviderReference } from '../../../git/models/remoteProvider'; +import type { Provider, ProviderReference } from '../../../git/models/remoteProvider'; import { equalsIgnoreCase } from '../../../system/string'; import type { EnrichableItem } from '../../launchpad/models/enrichedItem'; import type { Integration, IntegrationType } from '../integration'; @@ -959,11 +959,11 @@ export function toProviderPullRequest(pr: PullRequest): ProviderPullRequest { export function fromProviderPullRequest( pr: ProviderPullRequest, - integration: Integration, + provider: Provider, options?: { project?: IssueProject }, ): PullRequest { return new PullRequest( - integration, + provider, fromProviderAccount(pr.author), pr.id, pr.graphQLId || pr.id,