Repository: gitkraken/vscode-gitlens
Pull request #4243: Implements `getPullRequestForCommit` and `getAccountForCommit` for Azure and Bitbucket
Author: login: sergeibbb
Created: 2025-04-22T12:53:21Z | Merged: 2025-04-30T20:47:53Z

# Description
Implements `getPullRequestForCommit` and `getAccountForCommit` for Azure and Bitbucket.
Solves #4192

# Checklist

<!-- Please check off the following -->

- [x] I have followed the guidelines in the Contributing document
- [x] My changes follow the coding style of this project
- [x] My changes build without any errors or warnings
- [x] My changes have been formatted and linted
- [x] My changes include any required corresponding changes to the documentation (including CHANGELOG.md and README.md)
- [x] My changes have been rebased and squashed to the minimal number (typically 1) of relevant commits
- [x] My changes have a descriptive commit message with a short title, including a `Fixes $XXX -` or `Closes #XXX -` prefix to auto-close the issue that your PR addresses



Diff:
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<void> {
 	}
 }
 
+export async function showBitbucketPRCommitLinksAppNotInstalledWarningMessage(revLink: string): Promise<void> {
+	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<MessageItem | undefined> {
 	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<AzureDevOpsApi['getPullRequestForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getPullRequestForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+		cancellation?: CancellationToken,
+	): Promise<PullRequest | undefined> {
+		const scope = getLogScope();
+		const [projectName, _, repoName] = repo.split('/');
+		try {
+			const prResult = await this.request<{ results: Record<string, AzurePullRequest[]>[] }>(
+				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<AzurePullRequestWithLinks>(
+				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<AzureDevOpsApi['getIssueOrPullRequest']>({ args: { 0: p => p.name, 1: '<token>' } })
 	public async getIssueOrPullRequest(
 		provider: Provider,
@@ -255,6 +314,56 @@ export class AzureDevOpsApi implements Disposable {
 		return undefined;
 	}
 
+	@debug<AzureDevOpsApi['getAccountForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getAccountForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+	): Promise<UnidentifiedAuthor | undefined> {
+		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<AzureGitCommit>(
+				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<T>(
 		provider: Provider,
 		token: string,
-		baseUrl: string,
+		baseUrl: string | undefined,
 		route: string,
 		options: { method: RequestInit['method'] } & Record<string, unknown>,
 		scope: LogScope | undefined,
 		cancellation?: CancellationToken | undefined,
 	): Promise<T | undefined> {
-		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<Account | undefined> {
-		return Promise.resolve(undefined);
+	): Promise<UnidentifiedAuthor | undefined> {
+		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<PullRequest | undefined> {
-		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<Account | undefined> {
-		return Promise.resolve(undefined);
+	): Promise<Account | UnidentifiedAuthor | undefined> {
+		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<PullRequest | undefined> {
-		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<PullRequest | undefined> {
-		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<ProviderRepository | undefined> {
@@ -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<Account | undefined> {
-		return Promise.resolve(undefined);
+	): Promise<Account | UnidentifiedAuthor | undefined> {
+		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<PullRequest | undefined> {
-		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<PullRequest | undefined> {
 		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<IssueOrPullRequest | undefined> {
 		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<BitbucketApi['getServerPullRequestForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getServerPullRequestForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+		cancellation?: CancellationToken,
+	): Promise<PullRequest | undefined> {
+		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<BitbucketServerPullRequest | undefined>(
+				(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<BitbucketApi['getPullRequestForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getPullRequestForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+		cancellation?: CancellationToken,
+	): Promise<PullRequest | undefined> {
+		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<BitbucketPullRequest | undefined>(
+				(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<BitbucketApi['getAccountForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getAccountForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+		cancellation?: CancellationToken,
+	): Promise<Account | UnidentifiedAuthor | undefined> {
+		const scope = getLogScope();
+
+		try {
+			const commit = await this.request<BitbucketCommit>(
+				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<BitbucketApi['getServerAccountForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getServerAccountForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+		cancellation?: CancellationToken,
+	): Promise<Account | UnidentifiedAuthor | undefined> {
+		const scope = getLogScope();
+
+		try {
+			const commit = await this.request<BitbucketServerCommit>(
+				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<T>(
 		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 <sergei.shmakov@gitkraken.com>" 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,


Reviews:
user: login: ramin-t
body: Unfortunately, I'm not able to get this to work. I'm getting a bunch of errors with Azure DevOps that say `invalid response body...unexpected token <`.

Screenshot:

![image](https://github.com/user-attachments/assets/d2380ba2-db55-45d8-8105-de6255bf173c)

But I think it might be some issue with the Azure DevOps integraiton outside of this PR, because it is affecting other calls as well.

Will investigate, and re-test once I've addressed it. In the meantime, here are a few tiny comments.

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T20:50:28Z
user: login: sergeibbb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T10:15:32Z
user: login: sergeibbb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T10:29:07Z
user: login: sergeibbb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T10:32:29Z
user: login: sergeibbb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T10:32:34Z
user: login: ramin-t
body: I am not able to test this because I'm getting errors everywhere with my Azure connection/integration, but that will need to be investigated outside of this PR. Code LGTM - suggest having @d13 test it.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:07:35Z

Review comments:
diff_hunk: @@ -118,7 +124,45 @@ interface BitbucketBranch {
 	default_merge_strategy?: BitbucketMergeStrategy;
 }
 
-interface BitbucketPullRequestCommit {
+// It parses a raw author sitring like "Sergei Shmakov GK <sergei.shmakov@gitkraken.com>" 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 {
path: src/plus/integrations/providers/bitbucket/models.ts
user: login: ramin-t
body: This can maybe extend `BitbucketBriefCommit`?
created_at: 2025-04-23T18:16:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -94,6 +94,22 @@ export async function showGenericErrorMessage(message: string): Promise<void> {
 	}
 }
 
+export async function showBitbucketPRCommitLinksAppNotInstalledWarningMessage(revLink: string): Promise<void> {
+	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.`,
path: src/messages.ts
user: login: ramin-t
body: This page doesn't exist (or, at least, 404s for me). We should make sure it does before merging.
created_at: 2025-04-23T20:23:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -255,6 +314,55 @@ export class AzureDevOpsApi implements Disposable {
 		return undefined;
 	}
 
+	@debug<AzureDevOpsApi['getAccountForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getAccountForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+	): Promise<UnidentifiedAuthor | undefined> {
path: src/plus/integrations/providers/azure/azure.ts
user: login: ramin-t
body: I feel like we probably want a comment on why we need to use `UnidentifiedAuthor` type here even when we find the author, because at a glance it sounds confusing. I'm guessing it's because the API never gives us an id/username we can use, and the more solid types require it?
created_at: 2025-04-23T20:26:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
 
 ### Fixed
 
+- Gets "associated PR for commit" working on Bitbucket and Azure DevOps ([#4192](https://github.com/gitkraken/vscode-gitlens/issues/4192))
path: CHANGELOG.md
user: login: ramin-t
body: Since the changelog represents the user-facing change reflected in the issue, probably more along something like "added support for associated BitBucket, BitBucket Server, and Azure DevOps pull requests on commits" in the "Added" category, or "fixed _______ not appearing on commits" if we want it in the "Fixed" category.
created_at: 2025-04-23T20:49:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -94,6 +94,22 @@ export async function showGenericErrorMessage(message: string): Promise<void> {
 	}
 }
 
+export async function showBitbucketPRCommitLinksAppNotInstalledWarningMessage(revLink: string): Promise<void> {
+	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.`,
path: src/messages.ts
user: login: sergeibbb
body: It has appeared:
<img width="500" src="https://github.com/user-attachments/assets/8098b3ff-f829-44c0-93b6-aa1863806251">

created_at: 2025-04-28T10:15:32Z
author_association: MEMBER
diff_hunk: @@ -255,6 +314,55 @@ export class AzureDevOpsApi implements Disposable {
 		return undefined;
 	}
 
+	@debug<AzureDevOpsApi['getAccountForCommit']>({ args: { 0: p => p.name, 1: '<token>' } })
+	async getAccountForCommit(
+		provider: Provider,
+		token: string,
+		owner: string,
+		repo: string,
+		rev: string,
+		baseUrl: string,
+		_options?: {
+			avatarSize?: number;
+		},
+	): Promise<UnidentifiedAuthor | undefined> {
path: src/plus/integrations/providers/azure/azure.ts
user: login: sergeibbb
body: @axosoft-ramint 

> I'm guessing it's because the API never gives us an id/username we can use

Yes. It never gives us id of the committer. I've added a comment.


However, it gives us pusher. But we don't know pusher's email and we cannot say if the author is the pusher or not.
created_at: 2025-04-28T10:29:07Z
author_association: MEMBER
diff_hunk: @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
 
 ### Fixed
 
+- Gets "associated PR for commit" working on Bitbucket and Azure DevOps ([#4192](https://github.com/gitkraken/vscode-gitlens/issues/4192))
path: CHANGELOG.md
user: login: sergeibbb
body: I've moved it to "added" category
created_at: 2025-04-28T10:32:29Z
author_association: MEMBER
diff_hunk: @@ -118,7 +124,45 @@ interface BitbucketBranch {
 	default_merge_strategy?: BitbucketMergeStrategy;
 }
 
-interface BitbucketPullRequestCommit {
+// It parses a raw author sitring like "Sergei Shmakov GK <sergei.shmakov@gitkraken.com>" 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 {
path: src/plus/integrations/providers/bitbucket/models.ts
user: login: sergeibbb
body: fixed
created_at: 2025-04-28T10:32:34Z
author_association: MEMBER

PR comments:
user: login: d13
created_at: 2025-04-24T14:00:44Z
body: > Unfortunately, I'm not able to get this to work. I'm getting a bunch of errors with Azure DevOps that say `invalid response body...unexpected token <`.
> 
> 
> 
> Screenshot:
> 
> 
> 
> ![image](https://github.com/user-attachments/assets/d2380ba2-db55-45d8-8105-de6255bf173c)
> 
> 
> 
> But I think it might be some issue with the Azure DevOps integraiton outside of this PR, because it is affecting other calls as well.
> 
> 
> 
> Will investigate, and re-test once I've addressed it. In the meantime, here are a few tiny comments.
> 
> 

Looks like the response returned HTML instead of JSON
author_association: MEMBER
user: login: sergeibbb
created_at: 2025-04-24T14:35:51Z
body: @axosoft-ramint @d13 
Thank you! I'll look at it
author_association: MEMBER
user: login: sergeibbb
created_at: 2025-04-29T08:54:40Z
body: @axosoft-ramint , @d13 

I've fixed the problem. It turned out that `?api-version=7.1` attribute is required for this endpoint. 


author_association: MEMBER
user: login: ramin-t
created_at: 2025-04-29T17:11:26Z
body: @sergeibbb On top of your latest changes, I'm still seeing the error everywhere. It is affecting nearly every query with Azure, including `getPullRequestForCommit`. Logs attached.
[GitLens-1.log](https://github.com/user-attachments/files/19964720/GitLens-1.log)


author_association: CONTRIBUTOR
user: login: sergeibbb
created_at: 2025-04-29T22:20:29Z
body: @axosoft-ramint 

Is Launchpad broken for Azure?
author_association: MEMBER
user: login: sergeibbb
created_at: 2025-04-30T14:47:57Z
body: @axosoft-ramint 

It might be a clue in `azure-devops.ts`.

Is your repository on `dev.azure.com` or is it `visualstudio.net`?
author_association: MEMBER
user: login: sergeibbb
created_at: 2025-04-30T14:54:34Z
body: @axosoft-ramint 

Can you please show what URL your repository has for this request on `main` branch:
![image](https://github.com/user-attachments/assets/f0d99fac-8c14-4d93-a8c0-40e8192275d4)


author_association: MEMBER
user: login: ramin-t
created_at: 2025-04-30T20:47:15Z
body: Going to land this and we can verify in pre-release
author_association: CONTRIBUTOR