Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5008,6 +5008,7 @@
"gitlens.advanced.messages": {
"type": "object",
"default": {
"suppressBitbucketPRCommitLinksAppNotInstalledWarning": false,
"suppressCommitHasNoPreviousCommitWarning": false,
"suppressCommitNotFoundWarning": false,
"suppressCreatePullRequestPrompt": false,
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
16 changes: 15 additions & 1 deletion src/git/remotes/azure-devops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
113 changes: 111 additions & 2 deletions src/plus/integrations/providers/azure/azure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions src/plus/integrations/providers/azure/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`;
Expand Down
37 changes: 26 additions & 11 deletions src/plus/integrations/providers/azureDevOps.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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: {
Expand Down
Loading