Skip to content

Add Azure DevOps Server integration support #4516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 6, 2025
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Adds ability to set or change the upstream branch for branches in the _Commit Graph_ and other GitLens views ([#4498](https://github.com/gitkraken/vscode-gitlens/issues/4498))
- Adds new _Set Upstream..._ and _Change Upstream..._ context menu items to branches in the _Commit Graph_ and other GitLens views
- Adds a new _upstream_ sub-command to the _branch_ Git Command Palette
- Add Azure DevOps Server integration support ([#4478](https://github.com/gitkraken/vscode-gitlens/issues/4478))

### Changed

Expand Down
14 changes: 7 additions & 7 deletions docs/telemetry-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ void
```typescript
{
'hostingProvider.key': string,
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello'
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello'
}
```

Expand All @@ -601,7 +601,7 @@ void
```typescript
{
'hostingProvider.key': string,
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello'
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello'
}
```

Expand All @@ -612,7 +612,7 @@ void
```typescript
{
'issueProvider.key': string,
'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello'
'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello'
}
```

Expand All @@ -623,7 +623,7 @@ void
```typescript
{
'issueProvider.key': string,
'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello'
'issueProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello'
}
```

Expand Down Expand Up @@ -657,7 +657,7 @@ or when connection refresh is skipped due to being a non-cloud session

```typescript
{
'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello'
'integration.id': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello'
}
```

Expand Down Expand Up @@ -1800,7 +1800,7 @@ void
```typescript
{
'hostingProvider.key': string,
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello',
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello',
// @deprecated: true
'remoteProviders.key': string
}
Expand All @@ -1813,7 +1813,7 @@ void
```typescript
{
'hostingProvider.key': string,
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'jira' | 'trello',
'hostingProvider.provider': 'github' | 'gitlab' | 'bitbucket' | 'azureDevOps' | 'bitbucket-server' | 'github-enterprise' | 'cloud-github-enterprise' | 'gitlab-self-hosted' | 'cloud-gitlab-self-hosted' | 'azure-devops-server' | 'jira' | 'trello',
// @deprecated: true
'remoteProviders.key': string
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24596,7 +24596,7 @@
},
"dependencies": {
"@gitkraken/gitkraken-components": "13.0.0-vnext.8",
"@gitkraken/provider-apis": "0.28.5",
"@gitkraken/provider-apis": "0.29.6",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we checked for any breaking changes between our old version and the new one? If there were, we should check all affected areas.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @axosoft-ramint

I've reviewed changes that has happened in the the library from 0.28.5 to 0.29.6 and added corresponding section in Verification steps in the issue.

image

Also I've tested it briefly and hasn't found any obvious problems.

"@gitkraken/shared-web-components": "0.1.1-rc.15",
"@gk-nzaytsev/fast-string-truncated-width": "1.1.0",
"@lit-labs/signals": "0.1.3",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions src/autolinks/autolinksProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import type { IntegrationIds } from '../constants.integrations';
import { IssuesCloudHostIntegrationId } from '../constants.integrations';
import type { Container } from '../container';
import type { GitRemote } from '../git/models/remote';
import type { RemoteProviderId } from '../git/remotes/remoteProvider';
import type { RemoteProvider, RemoteProviderId } from '../git/remotes/remoteProvider';
import { getIssueOrPullRequestHtmlIcon, getIssueOrPullRequestMarkdownIcon } from '../git/utils/-webview/icons';
import type { ConfiguredIntegrationsChangeEvent } from '../plus/integrations/authentication/configuredIntegrationService';
import type { GitHostIntegration } from '../plus/integrations/models/gitHostIntegration';
import type { Integration } from '../plus/integrations/models/integration';
import { IntegrationBase } from '../plus/integrations/models/integration';
import type { IssuesIntegration } from '../plus/integrations/models/issuesIntegration';
import { convertRemoteProviderIdToIntegrationId } from '../plus/integrations/utils/-webview/integration.utils';
import {
convertRemoteProviderIdToIntegrationId,
getIntegrationIdForRemote,
} from '../plus/integrations/utils/-webview/integration.utils';
import { configuration } from '../system/-webview/configuration';
import { fromNow } from '../system/date';
import { debug } from '../system/decorators/log';
Expand Down Expand Up @@ -227,7 +230,8 @@ export class AutolinksProvider implements Disposable {
: // TODO: Tighten the typing on ProviderReference to be specific to a remote provider, and then have a separate "integration" property (on autolinks and elsewhere)
// that is of a new type IntegrationReference specific to integrations. Otherwise, make remote provider ids line up directly with integration ids.
// Either way, this converting/casting hackery needs to go away.
convertRemoteProviderIdToIntegrationId(link.provider.id as RemoteProviderId);
(getIntegrationIdForRemote(link.provider as RemoteProvider) ??
convertRemoteProviderIdToIntegrationId(link.provider.id as RemoteProviderId));
if (integrationId == null) {
// Fall back to the old logic assuming that integration id might be saved as provider id.
// TODO: it should be removed when we put providers and integrations in order. Conversation: https://github.com/gitkraken/vscode-gitlens/pull/3996#discussion_r1936422826
Expand Down
10 changes: 10 additions & 0 deletions src/constants.integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export enum GitSelfManagedHostIntegrationId {
CloudGitHubEnterprise = 'cloud-github-enterprise',
GitLabSelfHosted = 'gitlab-self-hosted',
CloudGitLabSelfHosted = 'cloud-gitlab-self-hosted',
AzureDevOpsServer = 'azure-devops-server',
}

export enum IssuesCloudHostIntegrationId {
Expand All @@ -21,6 +22,7 @@ export enum IssuesCloudHostIntegrationId {
export type CloudGitSelfManagedHostIntegrationIds =
| GitSelfManagedHostIntegrationId.CloudGitHubEnterprise
| GitSelfManagedHostIntegrationId.BitbucketServer
| GitSelfManagedHostIntegrationId.AzureDevOpsServer
| GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted;

export type GitHostIntegrationIds = GitCloudHostIntegrationId | GitSelfManagedHostIntegrationId;
Expand All @@ -35,6 +37,7 @@ export const supportedOrderedCloudIntegrationIds = [
GitCloudHostIntegrationId.GitLab,
GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted,
GitCloudHostIntegrationId.AzureDevOps,
GitSelfManagedHostIntegrationId.AzureDevOpsServer,
GitCloudHostIntegrationId.Bitbucket,
GitSelfManagedHostIntegrationId.BitbucketServer,
IssuesCloudHostIntegrationId.Jira,
Expand Down Expand Up @@ -92,6 +95,13 @@ export const supportedCloudIntegrationDescriptors: IntegrationDescriptor[] = [
supports: ['prs', 'issues'],
requiresPro: true,
},
{
id: GitSelfManagedHostIntegrationId.AzureDevOpsServer,
name: 'Azure DevOps Server',
icon: 'gl-provider-azdo',
supports: ['prs', 'issues'],
requiresPro: true,
},
{
id: GitCloudHostIntegrationId.Bitbucket,
name: 'Bitbucket',
Expand Down
6 changes: 3 additions & 3 deletions src/git/models/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class GitRemote<TProvider extends RemoteProvider | undefined = RemoteProv
get maybeIntegrationConnected(): boolean | undefined {
if (!this.provider?.id) return false;

const integrationId = getIntegrationIdForRemote(this);
const integrationId = getIntegrationIdForRemote(this.provider);
if (integrationId == null) return false;

// Special case for GitHub, since we support the legacy GitHub integration
Expand Down Expand Up @@ -105,7 +105,7 @@ export class GitRemote<TProvider extends RemoteProvider | undefined = RemoteProv
}

async getIntegration(): Promise<GitHostIntegration | undefined> {
const integrationId = getIntegrationIdForRemote(this);
const integrationId = getIntegrationIdForRemote(this.provider);
return integrationId && this.container.integrations.get(integrationId, this.provider?.domain);
}

Expand All @@ -125,7 +125,7 @@ export class GitRemote<TProvider extends RemoteProvider | undefined = RemoteProv
}

supportsIntegration(): this is GitRemote<RemoteProvider> {
return Boolean(getIntegrationIdForRemote(this));
return Boolean(getIntegrationIdForRemote(this.provider));
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/git/remotes/remoteProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const cloudProviderCreatorsMap: Record<
path: string,
scheme: string | undefined,
) => new BitbucketServerRemote(container, domain, path, cleanProtocol(scheme)),
[GitSelfManagedHostIntegrationId.AzureDevOpsServer]: (container: Container, domain: string, path: string) =>
new AzureDevOpsRemote(container, domain, path),
};

const dirtyProtocolPattern = /(\w+)\W*/;
Expand Down
7 changes: 5 additions & 2 deletions src/git/utils/-webview/repository.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { convertRemoteProviderIdToIntegrationId } from '../../../plus/integrations/utils/-webview/integration.utils';
import { getIntegrationIdForRemote } from '../../../plus/integrations/utils/-webview/integration.utils';
import { configuration } from '../../../system/-webview/configuration';
import { formatDate, fromNow } from '../../../system/date';
import { map } from '../../../system/iterable';
Expand Down Expand Up @@ -80,13 +80,16 @@ export async function toRepositoryShapeWithProvider(
icon: remote.provider.icon === 'remote' ? 'cloud' : remote.provider.icon,
integration: remote.supportsIntegration()
? {
id: convertRemoteProviderIdToIntegrationId(remote.provider.id)!,
id: getIntegrationIdForRemote(remote.provider)!,
connected: remote.maybeIntegrationConnected ?? false,
}
: undefined,
supportedFeatures: remote.provider.supportedFeatures,
url: await remote.provider.url({ type: RemoteResourceType.Repo }),
};
if (provider.integration?.id == null) {
provider.integration = undefined;
}
}

return { ...toRepositoryShape(repo), provider: provider };
Expand Down
8 changes: 7 additions & 1 deletion src/plus/integrations/authentication/azureDevOps.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { GitCloudHostIntegrationId } from '../../../constants.integrations';
import { GitCloudHostIntegrationId, GitSelfManagedHostIntegrationId } from '../../../constants.integrations';
import { CloudIntegrationAuthenticationProvider } from './integrationAuthenticationProvider';

export class AzureDevOpsAuthenticationProvider extends CloudIntegrationAuthenticationProvider<GitCloudHostIntegrationId.AzureDevOps> {
protected override get authProviderId(): GitCloudHostIntegrationId.AzureDevOps {
return GitCloudHostIntegrationId.AzureDevOps;
}
}

export class AzureDevOpsServerAuthenticationProvider extends CloudIntegrationAuthenticationProvider<GitSelfManagedHostIntegrationId.AzureDevOpsServer> {
protected override get authProviderId(): GitSelfManagedHostIntegrationId.AzureDevOpsServer {
return GitSelfManagedHostIntegrationId.AzureDevOpsServer;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export class IntegrationAuthenticationService implements Disposable {
supports(providerId: string): boolean {
switch (providerId) {
case GitCloudHostIntegrationId.AzureDevOps:
case GitSelfManagedHostIntegrationId.AzureDevOpsServer:
case GitCloudHostIntegrationId.Bitbucket:
case GitSelfManagedHostIntegrationId.GitHubEnterprise:
case GitCloudHostIntegrationId.GitLab:
Expand All @@ -67,6 +68,11 @@ export class IntegrationAuthenticationService implements Disposable {
await import(/* webpackChunkName: "integrations" */ './azureDevOps')
).AzureDevOpsAuthenticationProvider(this.container, this, this.configuredIntegrationService);
break;
case GitSelfManagedHostIntegrationId.AzureDevOpsServer:
provider = new (
await import(/* webpackChunkName: "integrations" */ './azureDevOps')
).AzureDevOpsServerAuthenticationProvider(this.container, this, this.configuredIntegrationService);
break;
case GitCloudHostIntegrationId.Bitbucket:
provider = new (
await import(/* webpackChunkName: "integrations" */ './bitbucket')
Expand Down
3 changes: 3 additions & 0 deletions src/plus/integrations/authentication/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type CloudIntegrationType =
| 'bitbucket'
| 'bitbucketServer'
| 'azure'
| 'azureDevopsServer'
| 'githubEnterprise'
| 'gitlabSelfHosted';

Expand Down Expand Up @@ -77,6 +78,7 @@ export const toIntegrationId: { [key in CloudIntegrationType]: IntegrationIds }
bitbucket: GitCloudHostIntegrationId.Bitbucket,
bitbucketServer: GitSelfManagedHostIntegrationId.BitbucketServer,
azure: GitCloudHostIntegrationId.AzureDevOps,
azureDevopsServer: GitSelfManagedHostIntegrationId.AzureDevOpsServer,
};

export const toCloudIntegrationType: { [key in IntegrationIds]: CloudIntegrationType | undefined } = {
Expand All @@ -86,6 +88,7 @@ export const toCloudIntegrationType: { [key in IntegrationIds]: CloudIntegration
[GitCloudHostIntegrationId.GitHub]: 'github',
[GitCloudHostIntegrationId.Bitbucket]: 'bitbucket',
[GitCloudHostIntegrationId.AzureDevOps]: 'azure',
[GitSelfManagedHostIntegrationId.AzureDevOpsServer]: 'azureDevopsServer',
[GitSelfManagedHostIntegrationId.CloudGitHubEnterprise]: 'githubEnterprise',
[GitSelfManagedHostIntegrationId.CloudGitLabSelfHosted]: 'gitlabSelfHosted',
[GitSelfManagedHostIntegrationId.BitbucketServer]: 'bitbucketServer',
Expand Down
40 changes: 40 additions & 0 deletions src/plus/integrations/integrationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,46 @@ export class IntegrationService implements Disposable {
) as GitHostIntegration as IntegrationById<T>;
break;

case GitSelfManagedHostIntegrationId.AzureDevOpsServer:
if (domain == null) {
integration = this.findCachedById(id);
// return immediately in order to not to cache it after the "switch" block:
if (integration != null) return integration;

const configured = this.getConfiguredLite(GitSelfManagedHostIntegrationId.AzureDevOpsServer);
if (configured.length) {
const { domain: configuredDomain } = configured[0];
if (configuredDomain == null) throw new Error(`Domain is required for '${id}' integration`);

integration = new (
await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps')
).AzureDevOpsServerIntegration(
this.container,
this.authenticationService,
this.getProvidersApi.bind(this),
this._onDidChangeIntegrationConnection,
configuredDomain,
) as GitHostIntegration as IntegrationById<T>;

// assign domain because it's part of caching key:
domain = configuredDomain;
break;
}

return undefined;
}

integration = new (
await import(/* webpackChunkName: "integrations" */ './providers/azureDevOps')
).AzureDevOpsServerIntegration(
this.container,
this.authenticationService,
this.getProvidersApi.bind(this),
this._onDidChangeIntegrationConnection,
domain,
) as GitHostIntegration as IntegrationById<T>;
break;

case IssuesCloudHostIntegrationId.Jira:
integration = new (
await import(/* webpackChunkName: "integrations" */ './providers/jira')
Expand Down
1 change: 1 addition & 0 deletions src/plus/integrations/models/gitHostIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export abstract class GitHostIntegration<
await Promise.all(
projectInputs.map(async projectInput => {
const results = await api.getIssuesForAzureProject(
providerId,
projectInput.namespace,
projectInput.project,
{
Expand Down
Loading