Added support for {org}.visualstudio.com domains used by Azure DevOps

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Address CodeQL comments

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Another correction

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Fixed casing

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Adjusted to be more secure based on feedback

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Tighten up endsWith

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Corrections to TSDoc comment

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Changes based on feedback

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Correct URL for discovery

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated docs

Signed-off-by: Andre Wanlin <awanlin@spotify.com>

Updated changeset

Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
Andre Wanlin
2025-06-20 14:37:01 -05:00
parent 3581e7d2c9
commit cc6206e436
9 changed files with 225 additions and 39 deletions
@@ -270,4 +270,53 @@ describe('AzureUrl', () => {
),
).toThrow('Azure URL must point to a git repository');
});
it('should work with the old visualstudio long URL', () => {
const url = AzureUrl.fromRepoUrl(
'https://my-org.visualstudio.com/my-project/_git/my-repo',
);
expect(url.getOwner()).toBe('my-org');
expect(url.getProject()).toBe('my-project');
expect(url.getRepo()).toBe('my-repo');
expect(url.getRef()).toBeUndefined();
expect(url.getPath()).toBeUndefined();
});
it('should work with the old visualstudio long URL form with a path', () => {
const url = AzureUrl.fromRepoUrl(
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml',
);
expect(url.getOwner()).toBe('my-org');
expect(url.getProject()).toBe('my-project');
expect(url.getRepo()).toBe('my-repo');
expect(url.getRef()).toBeUndefined();
expect(url.getPath()).toBe('/test.yaml');
expect(url.toRepoUrl()).toBe(
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ftest.yaml',
);
expect(url.toFileUrl()).toBe(
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?api-version=6.0&path=%2Ftest.yaml',
);
expect(url.toArchiveUrl()).toBe(
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ftest.yaml',
);
expect(url.toCommitsUrl()).toBe(
'https://my-org.visualstudio.com/my-project/_apis/git/repositories/my-repo/commits?api-version=6.0',
);
});
it('should work with the old visualstudio long URL form with a path and ref', () => {
const url = AzureUrl.fromRepoUrl(
'https://my-org.visualstudio.com/my-project/_git/my-repo?path=%2Ffolder&version=GBtest-branch',
);
expect(url.getOwner()).toBe('my-org');
expect(url.getProject()).toBe('my-project');
expect(url.getRepo()).toBe('my-repo');
expect(url.getRef()).toBe('test-branch');
expect(url.getPath()).toBe('/folder');
});
});
+77 -29
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { isVisualStudioDomain } from './core';
export class AzureUrl {
/**
* Parses an azure URL as copied from the browser address bar.
@@ -28,7 +30,12 @@ export class AzureUrl {
let repo;
const parts = url.pathname.split('/').map(part => decodeURIComponent(part));
if (parts[2] === '_git') {
if (isVisualStudioDomain(url.origin) && parts[2] === '_git') {
owner = url.host.split('.')[0];
project = parts[1];
repo = parts[3];
} else if (parts[2] === '_git') {
owner = parts[1];
project = repo = parts[3];
} else if (parts[3] === '_git') {
@@ -102,7 +109,9 @@ export class AzureUrl {
*/
toRepoUrl(): string {
let url;
if (this.#project === this.#repo) {
if (isVisualStudioDomain(this.#origin)) {
url = this.#baseUrl(this.#project, '_git', this.#repo);
} else if (this.#project === this.#repo) {
url = this.#baseUrl(this.#owner, '_git', this.#repo);
} else {
url = this.#baseUrl(this.#owner, this.#project, '_git', this.#repo);
@@ -130,15 +139,28 @@ export class AzureUrl {
);
}
const url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
let url;
if (isVisualStudioDomain(this.#origin)) {
url = this.#baseUrl(
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
} else {
url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
}
url.searchParams.set('api-version', '6.0');
url.searchParams.set('path', this.#path);
@@ -155,15 +177,28 @@ export class AzureUrl {
* Throws an error if the URL does not point to a repo.
*/
toArchiveUrl(): string {
const url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
let url;
if (isVisualStudioDomain(this.#origin)) {
url = this.#baseUrl(
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
} else {
url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'items',
);
}
url.searchParams.set('recursionLevel', 'full');
url.searchParams.set('download', 'true');
url.searchParams.set('api-version', '6.0');
@@ -184,15 +219,28 @@ export class AzureUrl {
* Throws an error if the URL does not point to a commit.
*/
toCommitsUrl(): string {
const url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'commits',
);
let url;
if (isVisualStudioDomain(this.#origin)) {
url = this.#baseUrl(
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'commits',
);
} else {
url = this.#baseUrl(
this.#owner,
this.#project,
'_apis',
'git',
'repositories',
this.#repo,
'commits',
);
}
url.searchParams.set('api-version', '6.0');
if (this.#ref) {
@@ -445,5 +445,25 @@ describe('DefaultAzureDevOpsCredentialProvider', () => {
});
});
});
describe('Azure DevOps (visualstudio.com)', () => {
it('Should return a token when a credential with the same organization is specified', async () => {
const provider = buildProvider([
{
host: 'org1.visualstudio.com',
credentials: [
{
personalAccessToken: 'pat',
},
],
},
]);
const credentials = provider.getCredentials({
url: 'https://org1.visualstudio.com/project1',
});
expect(credentials).toBeDefined();
});
});
});
});
@@ -20,6 +20,7 @@ import {
import { CachedAzureDevOpsCredentialsProvider } from './CachedAzureDevOpsCredentialsProvider';
import { ScmIntegrationRegistry } from '../registry';
import { DefaultAzureCredential } from '@azure/identity';
import { isVisualStudioDomain } from './core';
/**
* Default implementation of AzureDevOpsCredentialsProvider.
@@ -110,6 +111,17 @@ export class DefaultAzureDevOpsCredentialsProvider
return undefined;
}
private forVisualStudioOrganization(
url: URL,
): AzureDevOpsCredentialsProvider | undefined {
const parts = url.host.split('.');
if (isVisualStudioDomain(url.origin) && parts.length > 0) {
return this.providers.get(url.host);
}
return undefined;
}
private forHost(url: URL): AzureDevOpsCredentialsProvider | undefined {
return this.providers.get(url.host);
}
@@ -121,6 +133,7 @@ export class DefaultAzureDevOpsCredentialsProvider
const provider =
this.forAzureDevOpsOrganization(url) ??
this.forAzureDevOpsServerOrganization(url) ??
this.forVisualStudioOrganization(url) ??
this.forHost(url);
if (provider === undefined) {
+11
View File
@@ -53,3 +53,14 @@ export function getAzureDownloadUrl(url: string): string {
export function getAzureCommitsUrl(url: string): string {
return AzureUrl.fromRepoUrl(url).toCommitsUrl();
}
/**
* Given a URL, return true if it contains `.visualstudio.com` and false if it does not
* URLs can be in these two formats: `dev.azure.com/{org}` or the legacy `{org}.visualstudio.com`
*
* @param origin - A URL origin string pointing to an Azure DevOps instance
* @public
*/
export function isVisualStudioDomain(origin: string): boolean {
return origin.endsWith('.visualstudio.com');
}