Merge pull request #7134 from backstage/rugvip/azure
integrations,backend-common: Fix azure URL parsing and readTree/search
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Fix Azure `readTree` and `search` handling to properly support paths.
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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<SearchResponse> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user