diff --git a/.changeset/neat-mugs-add.md b/.changeset/neat-mugs-add.md new file mode 100644 index 0000000000..b90c95440d --- /dev/null +++ b/.changeset/neat-mugs-add.md @@ -0,0 +1,7 @@ +--- +'@backstage/integration': patch +--- + +Fix Azure URL handling to properly support both repo shorthand (`/owner/_git/project`) and full URLs (`/owner/project/_git/repo`). + +Fix Azure DevOps Server URL handling by being able to parse URLs with hosts other than `dev.azure.com`. Note that the `api-version` used for API requests is currently `6.0`, meaning you need to support at least this version in your Azure DevOps Server instance. diff --git a/.changeset/real-beans-collect.md b/.changeset/real-beans-collect.md new file mode 100644 index 0000000000..43dae9cd9a --- /dev/null +++ b/.changeset/real-beans-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Fix Azure `readTree` and `search` handling to properly support paths. diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index c7c8853929..e014fa2e75 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -81,14 +81,14 @@ describe('AzureUrlReader', () => { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', config: createConfig(), response: expect.objectContaining({ - url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml&version=master', }), }, { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', config: createConfig(), response: expect.objectContaining({ - url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + url: 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml', }), }, { @@ -125,14 +125,12 @@ describe('AzureUrlReader', () => { { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', config: createConfig(), - error: - 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + error: 'Azure URL must point to a git repository', }, { url: 'com/a/b/blob/master/path/to/c.yaml', config: createConfig(), - error: - 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + error: 'Invalid URL: com/a/b/blob/master/path/to/c.yaml', }, { url: '', diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index 910e8e04a5..6015b044bc 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -23,11 +23,9 @@ import { ScmIntegrations, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUrl from 'git-url-parse'; import { Minimatch } from 'minimatch'; import { Readable } from 'stream'; import { NotFoundError, NotModifiedError } from '@backstage/errors'; -import { stripFirstDirectoryFromPath } from './tree/util'; import { ReadTreeResponseFactory, ReaderFactory, @@ -129,28 +127,38 @@ export class AzureUrlReader implements UrlReader { throw new Error(message); } + // When downloading a zip archive from azure on a subpath we get an extra directory + // layer added at the top. With for example the file /a/b/c.txt and a download of + // /a/b, we'll see /b/c.txt in the zip archive. This picks out /b so that we can remove it. + let subpath; + const path = new URL(url).searchParams.get('path'); + if (path) { + subpath = path.split('/').filter(Boolean).slice(-1)[0]; + } + return await this.deps.treeResponseFactory.fromZipArchive({ stream: archiveAzureResponse.body as unknown as Readable, etag: commitSha, filter: options?.filter, + subpath, }); } async search(url: string, options?: SearchOptions): Promise { - const { filepath } = parseGitUrl(url); - const matcher = new Minimatch(filepath); + const treeUrl = new URL(url); + + const path = treeUrl.searchParams.get('path'); + const matcher = path && new Minimatch(path.replace(/^\/+/, '')); // TODO(freben): For now, read the entire repo and filter through that. In // a future improvement, we could be smart and try to deduce that non-glob // prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used // to get just that part of the repo. - const treeUrl = new URL(url); treeUrl.searchParams.delete('path'); - treeUrl.pathname = treeUrl.pathname.replace(/\/+$/, ''); const tree = await this.readTree(treeUrl.toString(), { etag: options?.etag, - filter: path => matcher.match(stripFirstDirectoryFromPath(path)), + filter: p => (matcher ? matcher.match(p) : true), }); const files = await tree.files(); diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 80cc68047f..c30283f432 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -60,22 +60,43 @@ describe('AzureIntegration', () => { expect( integration.resolveUrl({ url: '/a.yaml', - base: 'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', + base: 'https://internal.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', lineNumber: 14, }), ).toBe( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml&line=14&lineEnd=15&lineStartColumn=1&lineEndColumn=1', + 'https://internal.com/organization/project/_git/repository?path=%2Fa.yaml&line=14&lineEnd=15&lineStartColumn=1&lineEndColumn=1', ); expect( integration.resolveUrl({ url: './a.yaml', - base: 'https://dev.azure.com/organization/project/_git/repository', + base: 'https://dev.azure.com/organization/_git/project', + }), + ).toBe('https://dev.azure.com/organization/_git/project?path=%2Fa.yaml'); + + expect( + integration.resolveUrl({ + url: 'https://dev.azure.com/organization/_git/project?path=%2Fa.yaml', + base: 'https://dev.azure.com/organization/_git/project', + }), + ).toBe('https://dev.azure.com/organization/_git/project?path=%2Fa.yaml'); + + expect( + integration.resolveUrl({ + url: 'https://dev.azure.com/other-organization/_git/other-project?path=%2Fa.yaml', + base: 'https://dev.azure.com/organization/_git/project', }), ).toBe( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + 'https://dev.azure.com/other-organization/_git/other-project?path=%2Fa.yaml', ); + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'http://not-azure.com/organization/_git/project', + }), + ).toBe('http://not-azure.com/organization/_git/project?path=%2Fa.yaml'); + expect( integration.resolveUrl({ url: 'https://absolute.com/path', diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index c2a8dd8f5e..ab1b2489ae 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import parseGitUrl from 'git-url-parse'; import { basicIntegrations, isValidUrl } from '../helpers'; import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { AzureUrl } from './AzureUrl'; import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; export class AzureIntegration implements ScmIntegration { @@ -61,29 +61,27 @@ export class AzureIntegration implements ScmIntegration { return url; } - const parsed = parseGitUrl(base); - const { organization, owner, name, filepath } = parsed; + try { + const azureUrl = AzureUrl.fromRepoUrl(base); + const newUrl = new URL(base); - // If not an actual file path within a repo, treat the URL as raw - if (!organization || !owner || !name) { + // We lean on the URL path resolution logic to resolve the path param + const mockBaseUrl = new URL(`https://a.com${azureUrl.getPath() ?? ''}`); + const updatedPath = new URL(url, mockBaseUrl).pathname; + newUrl.searchParams.set('path', updatedPath); + + if (options.lineNumber) { + newUrl.searchParams.set('line', String(options.lineNumber)); + newUrl.searchParams.set('lineEnd', String(options.lineNumber + 1)); + newUrl.searchParams.set('lineStartColumn', '1'); + newUrl.searchParams.set('lineEndColumn', '1'); + } + + return newUrl.toString(); + } catch { + // If not an actual file path within a repo, treat the URL as raw return new URL(url, base).toString(); } - - const path = filepath?.replace(/^\//, '') || ''; - const mockBaseUrl = new URL(`https://a.com/${path}`); - const updatedPath = new URL(url, mockBaseUrl).pathname; - - const newUrl = new URL(base); - newUrl.searchParams.set('path', updatedPath); - - if (options.lineNumber) { - newUrl.searchParams.set('line', String(options.lineNumber)); - newUrl.searchParams.set('lineEnd', String(options.lineNumber + 1)); - newUrl.searchParams.set('lineStartColumn', '1'); - newUrl.searchParams.set('lineEndColumn', '1'); - } - - return newUrl.toString(); } resolveEditUrl(url: string): string { diff --git a/packages/integration/src/azure/AzureUrl.test.ts b/packages/integration/src/azure/AzureUrl.test.ts new file mode 100644 index 0000000000..2f22dd13da --- /dev/null +++ b/packages/integration/src/azure/AzureUrl.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AzureUrl } from './AzureUrl'; + +describe('AzureUrl', () => { + it('should work with the short URL form', () => { + const url = AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/_git/my-project', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-project'); + expect(url.getRef()).toBeUndefined(); + expect(url.getPath()).toBeUndefined(); + + expect(url.toRepoUrl()).toBe( + 'https://dev.azure.com/my-org/_git/my-project', + ); + expect(() => url.toFileUrl()).toThrow( + 'Azure URL must point to a specific path to be able to download a file', + ); + expect(url.toArchiveUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/items?recursionLevel=full&download=true&api-version=6.0', + ); + expect(url.toCommitsUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/commits?api-version=6.0', + ); + }); + + it('should work with the short URL form with a path', () => { + const url = AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/_git/my-project?path=%2Ftest.yaml', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-project'); + expect(url.getRef()).toBeUndefined(); + expect(url.getPath()).toBe('/test.yaml'); + + expect(url.toRepoUrl()).toBe( + 'https://dev.azure.com/my-org/_git/my-project?path=%2Ftest.yaml', + ); + expect(url.toFileUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/items?api-version=6.0&path=%2Ftest.yaml', + ); + expect(url.toArchiveUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ftest.yaml', + ); + expect(url.toCommitsUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/commits?api-version=6.0', + ); + }); + + it('should work with the short URL form with a path and ref', () => { + const url = AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/_git/my-project?path=%2Ftest.yaml&version=GBtest-branch', + ); + + expect(url.getOwner()).toBe('my-org'); + expect(url.getProject()).toBe('my-project'); + expect(url.getRepo()).toBe('my-project'); + expect(url.getRef()).toBe('test-branch'); + expect(url.getPath()).toBe('/test.yaml'); + + expect(url.toRepoUrl()).toBe( + 'https://dev.azure.com/my-org/_git/my-project?path=%2Ftest.yaml&version=GBtest-branch', + ); + expect(url.toFileUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/items?api-version=6.0&path=%2Ftest.yaml&version=test-branch', + ); + expect(url.toArchiveUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ftest.yaml&version=test-branch', + ); + expect(url.toCommitsUrl()).toBe( + 'https://dev.azure.com/my-org/my-project/_apis/git/repositories/my-project/commits?api-version=6.0&searchCriteria.itemVersion.version=test-branch', + ); + }); + + it('should work with the long URL', () => { + const url = AzureUrl.fromRepoUrl( + 'http://my-host/my-org/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(); + + expect(url.toRepoUrl()).toBe( + 'http://my-host/my-org/my-project/_git/my-repo', + ); + expect(() => url.toFileUrl()).toThrow( + 'Azure URL must point to a specific path to be able to download a file', + ); + expect(url.toArchiveUrl()).toBe( + 'http://my-host/my-org/my-project/_apis/git/repositories/my-repo/items?recursionLevel=full&download=true&api-version=6.0', + ); + expect(url.toCommitsUrl()).toBe( + 'http://my-host/my-org/my-project/_apis/git/repositories/my-repo/commits?api-version=6.0', + ); + }); + + it('should work with the long URL form with a path and ref', () => { + const url = AzureUrl.fromRepoUrl( + 'http://my-host/my-org/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'); + + expect(url.toRepoUrl()).toBe( + 'http://my-host/my-org/my-project/_git/my-repo?path=%2Ffolder&version=GBtest-branch', + ); + expect(url.toFileUrl()).toBe( + 'http://my-host/my-org/my-project/_apis/git/repositories/my-repo/items?api-version=6.0&path=%2Ffolder&version=test-branch', + ); + expect(url.toArchiveUrl()).toBe( + 'http://my-host/my-org/my-project/_apis/git/repositories/my-repo/items?recursionLevel=full&download=true&api-version=6.0&scopePath=%2Ffolder&version=test-branch', + ); + expect(url.toCommitsUrl()).toBe( + 'http://my-host/my-org/my-project/_apis/git/repositories/my-repo/commits?api-version=6.0&searchCriteria.itemVersion.version=test-branch', + ); + }); + + it('should reject non-branch refs', () => { + expect(() => + AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/_git/my-project?version=GC6eead79870d998a3befd4bc7c72cc89e446f2970', + ), + ).toThrow('Azure URL version must point to a git branch'); + }); + + it('should reject non-repo URLs', () => { + expect(() => + AzureUrl.fromRepoUrl('https://dev.azure.com/my-org/_git'), + ).toThrow('Azure URL must point to a git repository'); + expect(() => + AzureUrl.fromRepoUrl('https://dev.azure.com/my-org/_git/'), + ).toThrow('Azure URL must point to a git repository'); + expect(() => + AzureUrl.fromRepoUrl('https://dev.azure.com/my-org/my-project/'), + ).toThrow('Azure URL must point to a git repository'); + expect(() => + AzureUrl.fromRepoUrl('https://dev.azure.com/my-org/my-project/_not-git'), + ).toThrow('Azure URL must point to a git repository'); + expect(() => + AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/my-project/_not-git/my-repo', + ), + ).toThrow('Azure URL must point to a git repository'); + expect(() => + AzureUrl.fromRepoUrl( + 'https://dev.azure.com/my-org/_workitems/recentlyupdated/', + ), + ).toThrow('Azure URL must point to a git repository'); + }); +}); diff --git a/packages/integration/src/azure/AzureUrl.ts b/packages/integration/src/azure/AzureUrl.ts new file mode 100644 index 0000000000..76de8550ed --- /dev/null +++ b/packages/integration/src/azure/AzureUrl.ts @@ -0,0 +1,231 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const VERSION_PREFIX_GIT_BRANCH = 'GB'; + +export class AzureUrl { + /** + * Parses an azure URL as copied from the browser address bar. + * + * Throws an error if the URL is not a valid azure repo URL. + */ + static fromRepoUrl(repoUrl: string): AzureUrl { + const url = new URL(repoUrl); + + let owner; + let project; + let repo; + + const parts = url.pathname.split('/').map(part => decodeURIComponent(part)); + if (parts[2] === '_git') { + owner = parts[1]; + project = repo = parts[3]; + } else if (parts[3] === '_git') { + owner = parts[1]; + project = parts[2]; + repo = parts[4]; + } + + if (!owner || !project || !repo) { + throw new Error('Azure URL must point to a git repository'); + } + + const path = url.searchParams.get('path') ?? undefined; + + let ref; + const version = url.searchParams.get('version'); + if (version) { + const prefix = version.slice(0, 2); + if (prefix !== 'GB') { + throw new Error('Azure URL version must point to a git branch'); + } + ref = version.slice(2); + } + + return new AzureUrl(url.origin, owner, project, repo, path, ref); + } + + #origin: string; + #owner: string; + #project: string; + #repo: string; + #path?: string; + #ref?: string; + + private constructor( + origin: string, + owner: string, + project: string, + repo: string, + path?: string, + ref?: string, + ) { + this.#origin = origin; + this.#owner = owner; + this.#project = project; + this.#repo = repo; + this.#path = path; + this.#ref = ref; + } + + #baseUrl = (...parts: string[]): URL => { + const url = new URL(this.#origin); + url.pathname = parts.map(part => encodeURIComponent(part)).join('/'); + return url; + }; + + /** + * Returns a repo URL that can be used to navigate to the resource in azure. + * + * Throws an error if the URL is not a valid azure repo URL. + */ + toRepoUrl(): string { + let url; + if (this.#project === this.#repo) { + url = this.#baseUrl(this.#owner, '_git', this.#repo); + } else { + url = this.#baseUrl(this.#owner, this.#project, '_git', this.#repo); + } + + if (this.#path) { + url.searchParams.set('path', this.#path); + } + if (this.#ref) { + url.searchParams.set('version', VERSION_PREFIX_GIT_BRANCH + this.#ref); + } + + return url.toString(); + } + + /** + * Returns the file download URL for this azure resource. + * + * Throws an error if the URL does not point to a file. + */ + toFileUrl(): string { + if (!this.#path) { + throw new Error( + 'Azure URL must point to a specific path to be able to download a file', + ); + } + + const 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); + + if (this.#ref) { + url.searchParams.set('version', this.#ref); + } + + return url.toString(); + } + + /** + * Returns the archive download URL for this azure resource. + * + * 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', + ); + url.searchParams.set('recursionLevel', 'full'); + url.searchParams.set('download', 'true'); + url.searchParams.set('api-version', '6.0'); + + if (this.#path) { + url.searchParams.set('scopePath', this.#path); + } + if (this.#ref) { + url.searchParams.set('version', this.#ref); + } + + return url.toString(); + } + + /** + * Returns the API url for fetching commits from a branch for this azure resource. + * + * 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', + ); + url.searchParams.set('api-version', '6.0'); + + if (this.#ref) { + url.searchParams.set('searchCriteria.itemVersion.version', this.#ref); + } + + return url.toString(); + } + + /** + * Returns the name of the owner, a user or an organization. + */ + getOwner(): string { + return this.#owner; + } + + /** + * Returns the name of the project. + */ + getProject(): string { + return this.#project; + } + + /** + * Returns the name of the repo. + */ + getRepo(): string { + return this.#repo; + } + + /** + * Returns the file path within the repo if the URL contains one. + */ + getPath(): string | undefined { + return this.#path; + } + + /** + * Returns the git ref in the repo if the URL contains one. + */ + getRef(): string | undefined { + return this.#ref; + } +} diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts index 37fe50f3ea..ad8f2691b5 100644 --- a/packages/integration/src/azure/core.test.ts +++ b/packages/integration/src/azure/core.test.ts @@ -45,22 +45,22 @@ describe('azure core', () => { { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', result: - 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml&version=master', }, { url: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', result: - 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml', }, { url: 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml', result: - 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml', }, { url: 'https://api.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', result: - 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + 'https://api.com/org-name/project-name/_apis/git/repositories/repo-name/items?api-version=6.0&path=my-template.yaml&version=master', }, ])('should handle happy path %#', async ({ url, result }) => { expect(getAzureFileFetchUrl(url)).toBe(result); @@ -69,13 +69,11 @@ describe('azure core', () => { it.each([ { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', - error: - 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + error: 'Azure URL must point to a git repository', }, { url: 'com/a/b/blob/master/path/to/c.yaml', - error: - 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + error: 'Invalid URL: com/a/b/blob/master/path/to/c.yaml', }, ])('should handle error path %#', ({ url, error }) => { expect(() => getAzureFileFetchUrl(url)).toThrow(error); @@ -95,7 +93,7 @@ describe('azure core', () => { const result = getAzureDownloadUrl( 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', ); - expect(new URL(result).searchParams.get('scopePath')).toEqual('docs'); + expect(new URL(result).searchParams.get('scopePath')).toEqual('/docs'); }); it.each([ diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts index e22764f643..30603b1f09 100644 --- a/packages/integration/src/azure/core.ts +++ b/packages/integration/src/azure/core.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import parseGitUrl from 'git-url-parse'; +import { AzureUrl } from './AzureUrl'; import { AzureIntegrationConfig } from './config'; /** @@ -28,53 +28,7 @@ import { AzureIntegrationConfig } from './config'; * @param url A URL pointing to a file */ export function getAzureFileFetchUrl(url: string): string { - try { - const parsedUrl = new URL(url); - - const [empty, userOrOrg, project, srcKeyword, repoName] = - parsedUrl.pathname.split('/'); - - const path = parsedUrl.searchParams.get('path') || ''; - const ref = parsedUrl.searchParams.get('version')?.substr(2); - - if ( - empty !== '' || - userOrOrg === '' || - project === '' || - srcKeyword !== '_git' || - repoName === '' || - path === '' || - ref === '' - ) { - throw new Error('Wrong Azure Devops URL or Invalid file path'); - } - - // transform to api - parsedUrl.pathname = [ - empty, - userOrOrg, - project, - '_apis', - 'git', - 'repositories', - repoName, - 'items', - ].join('/'); - - const queryParams = [`path=${path}`]; - - if (ref) { - queryParams.push(`version=${ref}`); - } - - parsedUrl.search = queryParams.join('&'); - - parsedUrl.protocol = 'https'; - - return parsedUrl.toString(); - } catch (e) { - throw new Error(`Incorrect URL: ${url}, ${e}`); - } + return AzureUrl.fromRepoUrl(url).toFileUrl(); } /** @@ -84,33 +38,7 @@ export function getAzureFileFetchUrl(url: string): string { * @param url A URL pointing to a path */ export function getAzureDownloadUrl(url: string): string { - const { - name: repoName, - owner: project, - organization, - protocol, - resource, - filepath, - } = parseGitUrl(url); - - // scopePath will limit the downloaded content - // /docs will only download the docs folder and everything below it - // /docs/index.md will only download index.md but put it in the root of the archive - const scopePath = filepath - ? `&scopePath=${encodeURIComponent(filepath)}` - : ''; - - if (resource === 'dev.azure.com') { - return `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; - } - - // For Azure DevOps Server `parseGitUrl` returns the same values - // for `organization` and `project` like this: `organization/project/_git` - // so we drop `project` and then strip `/_git` from `organization` - return `${protocol}://${resource}/${organization.replace( - '/_git', - '', - )}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; + return AzureUrl.fromRepoUrl(url).toArchiveUrl(); } /** @@ -119,49 +47,7 @@ export function getAzureDownloadUrl(url: string): string { * @param url A URL pointing to a repository or a sub-path */ export function getAzureCommitsUrl(url: string): string { - try { - const parsedUrl = new URL(url); - - const [empty, userOrOrg, project, srcKeyword, repoName] = - parsedUrl.pathname.split('/'); - - // Remove the "GB" from "GBmain" for example. - const ref = parsedUrl.searchParams.get('version')?.substr(2); - - if ( - !!empty || - !userOrOrg || - !project || - srcKeyword !== '_git' || - !repoName - ) { - throw new Error('Wrong Azure Devops URL'); - } - - // transform to commits api - parsedUrl.pathname = [ - empty, - userOrOrg, - project, - '_apis', - 'git', - 'repositories', - repoName, - 'commits', - ].join('/'); - - const queryParams = []; - if (ref) { - queryParams.push(`searchCriteria.itemVersion.version=${ref}`); - } - parsedUrl.search = queryParams.join('&'); - - parsedUrl.protocol = 'https'; - - return parsedUrl.toString(); - } catch (e) { - throw new Error(`Incorrect URL: ${url}, ${e}`); - } + return AzureUrl.fromRepoUrl(url).toCommitsUrl(); } /**