feat: support imports from azure devops
Signed-off-by: Andrei Ivanovici <andrei.ivanovici@gmail.com>
This commit is contained in:
committed by
Andrei Ivanovici
parent
dd041b8b22
commit
3daad61452
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
Integrated Azure Devops as a catalog import source. This enables Backstage to create Pull Requests to Azure Devops repositories as it does with GitHub repositories
|
||||
|
||||
NO BREAKING CHANGES
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2024 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 { AzureIntegration } from '@backstage/integration';
|
||||
import { ScmAuthApi } from '@backstage/integration-react';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { getBranchName, getCatalogFilename } from '../components/helpers';
|
||||
import { createAzurePullRequest } from './AzureRepoApiClient';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
|
||||
export interface AzureRepoParts {
|
||||
tenantUrl: string;
|
||||
repoName: string;
|
||||
project: string;
|
||||
}
|
||||
|
||||
export function parseAzureUrl(
|
||||
repoUrl: string,
|
||||
integration: AzureIntegration,
|
||||
): AzureRepoParts {
|
||||
const { organization, owner, name } = parseGitUrl(repoUrl);
|
||||
const tenantUrl = `https://${integration.config.host}/${organization}`;
|
||||
return { tenantUrl, repoName: name, project: owner };
|
||||
}
|
||||
|
||||
export async function submitAzurePrToRepo(
|
||||
integration: AzureIntegration,
|
||||
options: {
|
||||
title: string;
|
||||
body: string;
|
||||
fileContent: string;
|
||||
repositoryUrl: string;
|
||||
},
|
||||
scmAuthApi: ScmAuthApi,
|
||||
configApi: ConfigApi,
|
||||
) {
|
||||
const { repositoryUrl, fileContent, title, body } = options;
|
||||
|
||||
const branchName = getBranchName(configApi);
|
||||
const fileName = getCatalogFilename(configApi);
|
||||
|
||||
const { token } = await scmAuthApi.getCredentials({
|
||||
url: repositoryUrl,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
},
|
||||
});
|
||||
const { tenantUrl, repoName, project } = parseAzureUrl(
|
||||
repositoryUrl,
|
||||
integration,
|
||||
);
|
||||
const result = await createAzurePullRequest({
|
||||
token,
|
||||
fileContent,
|
||||
title,
|
||||
description: body,
|
||||
project,
|
||||
repository: repoName,
|
||||
branchName,
|
||||
tenantUrl,
|
||||
fileName,
|
||||
});
|
||||
const catalogLocation = `${result.repository.webUrl}?path=/${fileName}`;
|
||||
const prLocation = `${result.repository.webUrl}/pullrequest/${result.pullRequestId}`;
|
||||
return {
|
||||
link: prLocation!,
|
||||
location: catalogLocation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright 2024 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 { rest } from 'msw';
|
||||
import {
|
||||
AzurePrOptions,
|
||||
AzurePrResult,
|
||||
AzureRef,
|
||||
AzureRefUpdate,
|
||||
AzureRepo,
|
||||
createAzurePullRequest,
|
||||
CreatePrOptions,
|
||||
NewBranchOptions,
|
||||
RepoApiClient,
|
||||
} from './AzureRepoApiClient';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
|
||||
function mockRepoEndpoint() {
|
||||
return rest.get(
|
||||
'https://dev.azure.com/acme/project/_apis/git/repositories/:path',
|
||||
(req, res, ctx) => {
|
||||
const { path } = req.params;
|
||||
if (path === 'success') {
|
||||
return res(
|
||||
ctx.json({
|
||||
id: '01',
|
||||
name: 'success',
|
||||
defaultBranch: 'refs/heads/master',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path === 'not-found') {
|
||||
return res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
message: 'repository not found',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.status(200));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockRefsEndpoint() {
|
||||
return rest.get(
|
||||
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/refs',
|
||||
(req, res, ctx) => {
|
||||
const filter = req.url.searchParams.get('filter');
|
||||
const { repo } = req.params;
|
||||
if (repo !== 'success') {
|
||||
return res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
message: 'repository not found',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (filter === 'heads/main') {
|
||||
const result = {
|
||||
value: [
|
||||
{
|
||||
name: 'refs/heads/main',
|
||||
objectId: '0000000000000000000000000000000000000000',
|
||||
},
|
||||
],
|
||||
};
|
||||
return res(ctx.json(result));
|
||||
}
|
||||
return res(ctx.json({ value: [] }));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockPushEndpoint() {
|
||||
return rest.post(
|
||||
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/pushes',
|
||||
(req, res, ctx) => {
|
||||
const { repo } = req.params;
|
||||
if (repo === 'success') {
|
||||
return res(
|
||||
ctx.json({
|
||||
refUpdates: [
|
||||
{
|
||||
repositoryId: '01',
|
||||
name: 'refs/heads/backstage-integration',
|
||||
oldObjectId: '0000000000000000000000000000000000000000',
|
||||
newObjectId: '0000000000000000000000000000000000000001',
|
||||
},
|
||||
],
|
||||
} satisfies { refUpdates: AzureRefUpdate[] }),
|
||||
);
|
||||
}
|
||||
|
||||
if (repo === 'error') {
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
message: 'internal error',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
message: 'Unexpected call',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mockPrEndpoint() {
|
||||
return rest.post(
|
||||
'https://dev.azure.com/acme/project/_apis/git/repositories/:repo/pullrequests',
|
||||
(req, res, ctx) => {
|
||||
const { repo } = req.params;
|
||||
if (repo === 'success') {
|
||||
return res(
|
||||
ctx.json({
|
||||
pullRequestId: 'PR01',
|
||||
repository: {
|
||||
name: 'success',
|
||||
webUrl: 'https://example.com',
|
||||
},
|
||||
} satisfies AzurePrResult),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(500),
|
||||
ctx.json({
|
||||
message: 'internal error',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('RepoApiClient', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
const sut = new RepoApiClient({
|
||||
token: 'token',
|
||||
project: 'project',
|
||||
tenantUrl: 'https://dev.azure.com/acme',
|
||||
});
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
mockPrEndpoint(),
|
||||
mockRefsEndpoint(),
|
||||
mockPushEndpoint(),
|
||||
mockRepoEndpoint(),
|
||||
);
|
||||
});
|
||||
describe('getRepository', () => {
|
||||
it('should get an existing repository', async () => {
|
||||
await expect(sut.getRepository('success')).resolves.toEqual({
|
||||
id: '01',
|
||||
name: 'success',
|
||||
defaultBranch: 'refs/heads/master',
|
||||
});
|
||||
});
|
||||
it('should throw when the repository repository does not exist', async () => {
|
||||
await expect(sut.getRepository('not-found')).rejects.toThrow(
|
||||
new Error('repository not found'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('getDefaultBranch', () => {
|
||||
it('should return when correct branch', async () => {
|
||||
const foundRef = await sut.getDefaultBranch({
|
||||
name: 'success',
|
||||
defaultBranch: 'refs/heads/main',
|
||||
id: '01',
|
||||
});
|
||||
expect(foundRef).toEqual({
|
||||
name: 'refs/heads/main',
|
||||
objectId: '0000000000000000000000000000000000000000',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when the repository does not exist', async () => {
|
||||
const promise = sut.getDefaultBranch({
|
||||
name: 'fail',
|
||||
defaultBranch: 'refs/heads/main',
|
||||
id: '01',
|
||||
});
|
||||
await expect(promise).rejects.toThrow(new Error('repository not found'));
|
||||
});
|
||||
|
||||
it('should throw when the default branch does not exist', async () => {
|
||||
const promise = sut.getDefaultBranch({
|
||||
name: 'success',
|
||||
defaultBranch: 'refs/heads/missing_branch',
|
||||
id: '01',
|
||||
});
|
||||
await expect(promise).rejects.toThrow(
|
||||
new Error(`The requested ref 'heads/missing_branch' was not found`),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('pushNewBranch', () => {
|
||||
let sourceBranch: AzureRef;
|
||||
let options: NewBranchOptions;
|
||||
let expectedResult: AzureRefUpdate;
|
||||
|
||||
beforeEach(() => {
|
||||
sourceBranch = {
|
||||
name: 'refs/heads/main',
|
||||
objectId: '0000000000000000000000000000000000000000',
|
||||
};
|
||||
options = {
|
||||
title: 'title',
|
||||
branchName: 'backstage-integration',
|
||||
fileName: 'catalog-info.yaml',
|
||||
fileContent: 'This is a test',
|
||||
};
|
||||
expectedResult = {
|
||||
repositoryId: '01',
|
||||
name: 'refs/heads/backstage-integration',
|
||||
oldObjectId: '0000000000000000000000000000000000000000',
|
||||
newObjectId: '0000000000000000000000000000000000000001',
|
||||
};
|
||||
});
|
||||
it('should create a new branch', async () => {
|
||||
await expect(
|
||||
sut.pushNewBranch('success', sourceBranch, options),
|
||||
).resolves.toEqual(expectedResult);
|
||||
});
|
||||
it('should throw when api call fails', async () => {
|
||||
await expect(
|
||||
sut.pushNewBranch('error', sourceBranch, options),
|
||||
).rejects.toThrow(new Error('internal error'));
|
||||
});
|
||||
});
|
||||
describe('createPullRequest', () => {
|
||||
const sourceBranchName = 'refs/heads/main';
|
||||
const targetBranchName = 'refs/heads/backstage-integration';
|
||||
const options: CreatePrOptions = {
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
};
|
||||
it('should create a new Pull request', async () => {
|
||||
await expect(
|
||||
sut.createPullRequest(
|
||||
'success',
|
||||
sourceBranchName,
|
||||
targetBranchName,
|
||||
options,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
pullRequestId: 'PR01',
|
||||
repository: {
|
||||
name: 'success',
|
||||
webUrl: 'https://example.com',
|
||||
},
|
||||
} satisfies AzurePrResult);
|
||||
});
|
||||
it('should throw when api call fails', async () => {
|
||||
await expect(
|
||||
sut.createPullRequest(
|
||||
'error',
|
||||
sourceBranchName,
|
||||
targetBranchName,
|
||||
options,
|
||||
),
|
||||
).rejects.toThrow(new Error('internal error'));
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('createAzurePullRequest', () => {
|
||||
let client: RepoApiClient;
|
||||
let clientMock: Record<keyof RepoApiClient, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
clientMock = {
|
||||
createPullRequest: jest.fn(),
|
||||
pushNewBranch: jest.fn(),
|
||||
getRepository: jest.fn(),
|
||||
getDefaultBranch: jest.fn(),
|
||||
};
|
||||
client = clientMock as any as RepoApiClient;
|
||||
});
|
||||
|
||||
it('should create a new Pull request', async () => {
|
||||
const options: AzurePrOptions = {
|
||||
tenantUrl: 'https://dev.azure.com/acme',
|
||||
repository: 'test',
|
||||
project: 'project',
|
||||
fileName: 'catalog-info.yaml',
|
||||
title: 'Test Title',
|
||||
fileContent: 'content',
|
||||
branchName: 'backstage-integration',
|
||||
description: 'Test Description',
|
||||
token: 'token',
|
||||
};
|
||||
const repo: AzureRepo = {
|
||||
name: options.repository,
|
||||
defaultBranch: 'ref/heads/main',
|
||||
id: '01',
|
||||
};
|
||||
const defaultBranch: AzureRef = {
|
||||
name: 'ref/heads/main',
|
||||
objectId: '000000000000000000000000000000000000000',
|
||||
};
|
||||
const expectedResult: AzurePrResult = {
|
||||
pullRequestId: 'PR01',
|
||||
repository: {
|
||||
name: options.repository,
|
||||
webUrl: 'https://dev.azure.com/acme/project',
|
||||
},
|
||||
};
|
||||
|
||||
clientMock.getRepository.mockResolvedValue(repo);
|
||||
clientMock.getDefaultBranch.mockResolvedValue(defaultBranch);
|
||||
clientMock.pushNewBranch.mockResolvedValue({
|
||||
name: options.branchName,
|
||||
oldObjectId: '000000000000000000000000000000000000000',
|
||||
newObjectId: '000000000000000000000000000000000000001',
|
||||
repositoryId: '01',
|
||||
} satisfies AzureRefUpdate);
|
||||
clientMock.createPullRequest.mockResolvedValue(expectedResult);
|
||||
|
||||
await expect(createAzurePullRequest(options, client)).resolves.toEqual(
|
||||
expectedResult,
|
||||
);
|
||||
expect(clientMock.getRepository).toHaveBeenCalledWith(options.repository);
|
||||
expect(clientMock.getDefaultBranch).toHaveBeenCalledWith(repo);
|
||||
expect(clientMock.pushNewBranch).toHaveBeenCalledWith(
|
||||
repo.name,
|
||||
defaultBranch,
|
||||
options,
|
||||
);
|
||||
expect(clientMock.createPullRequest).toHaveBeenCalledWith(
|
||||
repo.name,
|
||||
options.branchName,
|
||||
defaultBranch.name,
|
||||
options,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export interface AzurePrOptions {
|
||||
tenantUrl: string;
|
||||
title: string;
|
||||
project: string;
|
||||
branchName: string;
|
||||
repository: string;
|
||||
token: string;
|
||||
fileContent: string;
|
||||
fileName: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateAzurePr {
|
||||
sourceRefName: string;
|
||||
targetRefName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AzureRepo {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
export interface AzureRef {
|
||||
name: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
interface RefQueryResult {
|
||||
value: AzureRef[];
|
||||
}
|
||||
|
||||
export interface AzureCommit {
|
||||
comment: string;
|
||||
|
||||
changes: {
|
||||
changeType: string;
|
||||
item: {
|
||||
path: string;
|
||||
};
|
||||
newContent: {
|
||||
content: string;
|
||||
contentType: 'rawtext';
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AzureRefUpdate {
|
||||
repositoryId: string;
|
||||
name: string;
|
||||
oldObjectId: string;
|
||||
newObjectId: string;
|
||||
}
|
||||
|
||||
export interface AzurePushResult {
|
||||
refUpdates: AzureRefUpdate[];
|
||||
}
|
||||
|
||||
export interface AzurePush {
|
||||
refUpdates: {
|
||||
name: string;
|
||||
oldObjectId: string;
|
||||
}[];
|
||||
commits: AzureCommit[];
|
||||
}
|
||||
|
||||
export interface AzurePrResult {
|
||||
pullRequestId: string;
|
||||
repository: {
|
||||
name: string;
|
||||
webUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
const apiVersions = {
|
||||
V7_2p_1: '7.2-preview.1',
|
||||
V7_2p_2: '7.2-preview.2',
|
||||
};
|
||||
|
||||
export interface RepoApiClientOptions {
|
||||
project: string;
|
||||
tenantUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface NewBranchOptions {
|
||||
fileContent: string;
|
||||
fileName: string;
|
||||
title: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface CreatePrOptions {
|
||||
description: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export class RepoApiClient {
|
||||
private createEndpoint = (
|
||||
path: string,
|
||||
version: string,
|
||||
queryParams: Record<string, string> | undefined = undefined,
|
||||
) => {
|
||||
const url = new URL(
|
||||
`${this._options.tenantUrl}/${this._options.project}/_apis/git/repositories`,
|
||||
);
|
||||
url.pathname += path;
|
||||
|
||||
url.searchParams.set('api-version', version);
|
||||
Object.entries(queryParams ?? {}).forEach(([key, value]) =>
|
||||
url.searchParams.set(key, value),
|
||||
);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
constructor(private _options: RepoApiClientOptions) {}
|
||||
|
||||
private async get<T>(
|
||||
path: string,
|
||||
version: string,
|
||||
queryParams: Record<string, string> | undefined = undefined,
|
||||
): Promise<T> {
|
||||
const endpoint = this.createEndpoint(path, version, queryParams);
|
||||
const result = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._options.token}`,
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
return result.json().then(it => Promise.reject(new Error(it.message)));
|
||||
}
|
||||
return await result.json();
|
||||
}
|
||||
|
||||
private async post<T>(
|
||||
path: string,
|
||||
version: string,
|
||||
payload: unknown,
|
||||
): Promise<T> {
|
||||
const endpoint = this.createEndpoint(path, version);
|
||||
const result = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this._options.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!result.ok) {
|
||||
return result.json().then(it => Promise.reject(new Error(it.message)));
|
||||
}
|
||||
return await result.json();
|
||||
}
|
||||
|
||||
async getRepository(repositoryName: string): Promise<AzureRepo> {
|
||||
return this.get(`/${repositoryName}`, apiVersions.V7_2p_1);
|
||||
}
|
||||
|
||||
async getDefaultBranch(repo: AzureRepo): Promise<AzureRef> {
|
||||
const filter = repo.defaultBranch.replace('refs/', '');
|
||||
const result: RefQueryResult = await this.get(
|
||||
`/${repo.name}/refs`,
|
||||
apiVersions.V7_2p_2,
|
||||
{ filter },
|
||||
);
|
||||
if (!result.value?.length) {
|
||||
return Promise.reject(
|
||||
new Error(`The requested ref '${filter}' was not found`),
|
||||
);
|
||||
}
|
||||
return result.value[0];
|
||||
}
|
||||
|
||||
async pushNewBranch(
|
||||
repoName: string,
|
||||
sourceBranch: AzureRef,
|
||||
options: NewBranchOptions,
|
||||
): Promise<AzureRefUpdate> {
|
||||
const push: AzurePush = {
|
||||
refUpdates: [
|
||||
{
|
||||
name: `refs/heads/${options.branchName}`,
|
||||
oldObjectId: sourceBranch.objectId,
|
||||
},
|
||||
],
|
||||
commits: [
|
||||
{
|
||||
comment: options.title,
|
||||
changes: [
|
||||
{
|
||||
changeType: 'add',
|
||||
item: {
|
||||
path: `/${options.fileName}`,
|
||||
},
|
||||
newContent: {
|
||||
content: options.fileContent,
|
||||
contentType: 'rawtext',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await this.post<AzurePushResult>(
|
||||
`/${repoName}/pushes`,
|
||||
apiVersions.V7_2p_2,
|
||||
push,
|
||||
);
|
||||
return result.refUpdates[0];
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
repoName: string,
|
||||
sourceName: string,
|
||||
targetName: string,
|
||||
options: CreatePrOptions,
|
||||
): Promise<AzurePrResult> {
|
||||
const payload: CreateAzurePr = {
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
sourceRefName: sourceName,
|
||||
targetRefName: targetName,
|
||||
};
|
||||
|
||||
return await this.post<AzurePrResult>(
|
||||
`/${repoName}/pullrequests`,
|
||||
apiVersions.V7_2p_2,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAzurePullRequest(
|
||||
options: AzurePrOptions,
|
||||
client: RepoApiClient | undefined = undefined,
|
||||
): Promise<AzurePrResult> {
|
||||
const actualClient = client ?? new RepoApiClient(options);
|
||||
const repo = await actualClient.getRepository(options.repository);
|
||||
const defaultBranch = await actualClient.getDefaultBranch(repo);
|
||||
const refUpdate = await actualClient.pushNewBranch(
|
||||
repo.name,
|
||||
defaultBranch,
|
||||
options,
|
||||
);
|
||||
return actualClient.createPullRequest(
|
||||
repo.name,
|
||||
refUpdate.name,
|
||||
defaultBranch.name,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,12 @@ jest.mock('@octokit/rest', () => {
|
||||
return { Octokit };
|
||||
});
|
||||
|
||||
jest.mock('./AzureRepoApiClient', () => {
|
||||
return {
|
||||
createAzurePullRequest: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { ScmAuthApi } from '@backstage/integration-react';
|
||||
@@ -55,6 +61,11 @@ import { Octokit } from '@octokit/rest';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { CatalogImportClient } from './CatalogImportClient';
|
||||
import {
|
||||
AzurePrOptions,
|
||||
AzurePrResult,
|
||||
createAzurePullRequest,
|
||||
} from './AzureRepoApiClient';
|
||||
|
||||
describe('CatalogImportClient', () => {
|
||||
const server = setupServer();
|
||||
@@ -269,7 +280,7 @@ describe('CatalogImportClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject for integrations that are not github ones', async () => {
|
||||
it('should reject for integrations that are not github or azure', async () => {
|
||||
await expect(
|
||||
catalogImportClient.analyzeUrl(
|
||||
'https://registered-but-not-github.com/backstage/backstage',
|
||||
@@ -619,6 +630,61 @@ describe('CatalogImportClient', () => {
|
||||
base: 'main',
|
||||
});
|
||||
});
|
||||
it('should create AzureDevops pull request', async () => {
|
||||
catalogApi.validateEntity.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
});
|
||||
const azureMock = createAzurePullRequest as jest.Mock;
|
||||
azureMock.mockResolvedValueOnce({
|
||||
repository: {
|
||||
name: 'backstage',
|
||||
webUrl: 'https://dev.azure.com/spotify/backstage/_git/backstage',
|
||||
},
|
||||
pullRequestId: '01',
|
||||
} satisfies AzurePrResult);
|
||||
const expectedPrOptions: AzurePrOptions = {
|
||||
title: 'A title/message',
|
||||
description: 'A body',
|
||||
repository: 'backstage',
|
||||
fileName: 'catalog-info.yaml',
|
||||
project: 'backstage',
|
||||
tenantUrl: 'https://dev.azure.com/spotify',
|
||||
branchName: 'backstage-integration',
|
||||
token: 'token',
|
||||
fileContent: `
|
||||
{
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "Component",
|
||||
"metadata": {
|
||||
"name": "valid-name",
|
||||
"annotations": {
|
||||
"github.com/project-slug": "backstage/example-repo"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "other",
|
||||
"lifecycle": "unknown",
|
||||
"owner": "backstage"
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
await expect(
|
||||
catalogImportClient.submitPullRequest({
|
||||
repositoryUrl:
|
||||
'https://dev.azure.com/spotify/backstage/_git/backstage',
|
||||
fileContent: expectedPrOptions.fileContent,
|
||||
title: expectedPrOptions.title,
|
||||
body: expectedPrOptions.description,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
link: 'https://dev.azure.com/spotify/backstage/_git/backstage/pullrequest/01',
|
||||
location:
|
||||
'https://dev.azure.com/spotify/backstage/_git/backstage?path=/catalog-info.yaml',
|
||||
});
|
||||
|
||||
expect(azureMock).toHaveBeenCalledWith(expectedPrOptions);
|
||||
});
|
||||
it('Submit Pull Request with invalid component name', async () => {
|
||||
const ErrorMessage =
|
||||
'Policy check failed for component:default/invalid name; caused by Error: "metadata.name" is not valid; expected a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total but found "invalid name". To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md';
|
||||
|
||||
@@ -21,18 +21,19 @@ import {
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
GithubIntegrationConfig,
|
||||
AzureIntegration,
|
||||
GithubIntegration,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import { ScmAuthApi } from '@backstage/integration-react';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { Base64 } from 'js-base64';
|
||||
import { AnalyzeResult, CatalogImportApi } from './CatalogImportApi';
|
||||
import YAML from 'yaml';
|
||||
import { getGithubIntegrationConfig } from './GitHub';
|
||||
import { getBranchName, getCatalogFilename } from '../components/helpers';
|
||||
import { GitHubOptions, submitGitHubPrToRepo } from './GitHub';
|
||||
import { getCatalogFilename } from '../components/helpers';
|
||||
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { submitAzurePrToRepo } from './AzureDevops';
|
||||
|
||||
/**
|
||||
* The default implementation of the {@link CatalogImportApi}.
|
||||
@@ -89,15 +90,17 @@ export class CatalogImportClient implements CatalogImportApi {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const ghConfig = getGithubIntegrationConfig(this.scmIntegrationsApi, url);
|
||||
if (!ghConfig) {
|
||||
const other = this.scmIntegrationsApi.byUrl(url);
|
||||
const supportedIntegrations = ['github', 'azure'];
|
||||
const foundIntegration = this.scmIntegrationsApi.byUrl(url);
|
||||
const iSupported = supportedIntegrations.find(
|
||||
it => it === foundIntegration?.type,
|
||||
);
|
||||
if (!iSupported) {
|
||||
const catalogFilename = getCatalogFilename(this.configApi);
|
||||
|
||||
if (other) {
|
||||
if (foundIntegration) {
|
||||
throw new Error(
|
||||
`The ${other.title} integration only supports full URLs to ${catalogFilename} files. Did you try to pass in the URL of a directory instead?`,
|
||||
`The ${foundIntegration.title} integration only supports full URLs to ${catalogFilename} files. Did you try to pass in the URL of a directory instead?`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
@@ -144,7 +147,7 @@ export class CatalogImportClient implements CatalogImportApi {
|
||||
|
||||
return {
|
||||
type: 'repository',
|
||||
integrationType: 'github',
|
||||
integrationType: foundIntegration!.type,
|
||||
url: url,
|
||||
generatedEntities: analyzation.generateEntities.map(x => x.entity),
|
||||
};
|
||||
@@ -185,21 +188,41 @@ the component will become available.\n\nFor more information, read an \
|
||||
if (!validationResponse.valid) {
|
||||
throw new Error(validationResponse.errors[0].message);
|
||||
}
|
||||
const ghConfig = getGithubIntegrationConfig(
|
||||
this.scmIntegrationsApi,
|
||||
repositoryUrl,
|
||||
);
|
||||
|
||||
if (ghConfig) {
|
||||
return await this.submitGitHubPrToRepo({
|
||||
...ghConfig,
|
||||
repositoryUrl,
|
||||
fileContent,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
const provider = this.scmIntegrationsApi.byUrl(repositoryUrl);
|
||||
|
||||
switch (provider?.type) {
|
||||
case 'github': {
|
||||
const { config } = provider as GithubIntegration;
|
||||
const { name, owner } = parseGitUrl(repositoryUrl);
|
||||
const options2: GitHubOptions = {
|
||||
githubIntegrationConfig: config,
|
||||
repo: name,
|
||||
owner: owner,
|
||||
repositoryUrl,
|
||||
fileContent,
|
||||
title,
|
||||
body,
|
||||
};
|
||||
return submitGitHubPrToRepo(options2, this.scmAuthApi, this.configApi);
|
||||
}
|
||||
case 'azure': {
|
||||
return submitAzurePrToRepo(
|
||||
provider as AzureIntegration,
|
||||
{
|
||||
repositoryUrl,
|
||||
fileContent,
|
||||
title,
|
||||
body,
|
||||
},
|
||||
this.scmAuthApi,
|
||||
this.configApi,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
throw new Error('unimplemented!');
|
||||
}
|
||||
}
|
||||
throw new Error('unimplemented!');
|
||||
}
|
||||
|
||||
// TODO: this could be part of the catalog api
|
||||
@@ -238,125 +261,4 @@ the component will become available.\n\nFor more information, read an \
|
||||
const payload = await response.json();
|
||||
return payload;
|
||||
}
|
||||
|
||||
// TODO: extract this function and implement for non-github
|
||||
private async submitGitHubPrToRepo(options: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body: string;
|
||||
fileContent: string;
|
||||
repositoryUrl: string;
|
||||
githubIntegrationConfig: GithubIntegrationConfig;
|
||||
}): Promise<{ link: string; location: string }> {
|
||||
const {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
fileContent,
|
||||
repositoryUrl,
|
||||
githubIntegrationConfig,
|
||||
} = options;
|
||||
|
||||
const { token } = await this.scmAuthApi.getCredentials({
|
||||
url: repositoryUrl,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
},
|
||||
});
|
||||
|
||||
const octo = new Octokit({
|
||||
auth: token,
|
||||
baseUrl: githubIntegrationConfig.apiBaseUrl,
|
||||
});
|
||||
|
||||
const branchName = getBranchName(this.configApi);
|
||||
const fileName = getCatalogFilename(this.configApi);
|
||||
|
||||
const repoData = await octo.repos
|
||||
.get({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
|
||||
});
|
||||
|
||||
const parentRef = await octo.git
|
||||
.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${repoData.data.default_branch}`,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage("Couldn't fetch default branch data", e),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.git
|
||||
.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: parentRef.data.object.sha,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a new branch with name '${branchName}'`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.repos
|
||||
.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path: fileName,
|
||||
message: title,
|
||||
content: Base64.encode(fileContent),
|
||||
branch: branchName,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a commit with ${fileName} file added`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const pullRequestResponse = await octo.pulls
|
||||
.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
head: branchName,
|
||||
body,
|
||||
base: repoData.data.default_branch,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a pull request for ${branchName} branch`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
link: pullRequestResponse.data.html_url,
|
||||
location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatHttpErrorMessage(
|
||||
message: string,
|
||||
error: { status: number; message: string },
|
||||
) {
|
||||
return `${message}, received http response status code ${error.status}: ${error.message}`;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,26 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
import {
|
||||
GithubIntegrationConfig,
|
||||
ScmIntegrationRegistry,
|
||||
} from '@backstage/integration';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { ScmAuthApi } from '@backstage/integration-react';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { getBranchName, getCatalogFilename } from '../components/helpers';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
export interface GitHubOptions {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
body: string;
|
||||
fileContent: string;
|
||||
repositoryUrl: string;
|
||||
githubIntegrationConfig: GithubIntegrationConfig;
|
||||
}
|
||||
export const getGithubIntegrationConfig = (
|
||||
scmIntegrationsApi: ScmIntegrationRegistry,
|
||||
location: string,
|
||||
@@ -33,3 +50,119 @@ export const getGithubIntegrationConfig = (
|
||||
githubIntegrationConfig: integration.config,
|
||||
};
|
||||
};
|
||||
|
||||
export async function submitGitHubPrToRepo(
|
||||
options: GitHubOptions,
|
||||
scmAuthApi: ScmAuthApi,
|
||||
configApi: ConfigApi,
|
||||
): Promise<{ link: string; location: string }> {
|
||||
const {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
fileContent,
|
||||
repositoryUrl,
|
||||
githubIntegrationConfig,
|
||||
} = options;
|
||||
|
||||
const { token } = await scmAuthApi.getCredentials({
|
||||
url: repositoryUrl,
|
||||
additionalScope: {
|
||||
repoWrite: true,
|
||||
},
|
||||
});
|
||||
|
||||
const octo = new Octokit({
|
||||
auth: token,
|
||||
baseUrl: githubIntegrationConfig.apiBaseUrl,
|
||||
});
|
||||
|
||||
const branchName = getBranchName(configApi);
|
||||
const fileName = getCatalogFilename(configApi);
|
||||
|
||||
const repoData = await octo.repos
|
||||
.get({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e));
|
||||
});
|
||||
|
||||
const parentRef = await octo.git
|
||||
.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${repoData.data.default_branch}`,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage("Couldn't fetch default branch data", e),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.git
|
||||
.createRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: parentRef.data.object.sha,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a new branch with name '${branchName}'`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await octo.repos
|
||||
.createOrUpdateFileContents({
|
||||
owner,
|
||||
repo,
|
||||
path: fileName,
|
||||
message: title,
|
||||
content: Base64.encode(fileContent),
|
||||
branch: branchName,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a commit with ${fileName} file added`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const pullRequestResponse = await octo.pulls
|
||||
.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
head: branchName,
|
||||
body,
|
||||
base: repoData.data.default_branch,
|
||||
})
|
||||
.catch(e => {
|
||||
throw new Error(
|
||||
formatHttpErrorMessage(
|
||||
`Couldn't create a pull request for ${branchName} branch`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
link: pullRequestResponse.data.html_url,
|
||||
location: `https://${githubIntegrationConfig.host}/${owner}/${repo}/blob/${repoData.data.default_branch}/${fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHttpErrorMessage(
|
||||
message: string,
|
||||
error: { status: number; message: string },
|
||||
) {
|
||||
return `${message}, received http response status code ${error.status}: ${error.message}`;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ import { useCatalogFilename } from '../../hooks';
|
||||
*/
|
||||
export interface ImportInfoCardProps {
|
||||
exampleLocationUrl?: string;
|
||||
exampleRepositoryUrl?: string;
|
||||
exampleGitRepositoryUrl?: string;
|
||||
exampleAzureRepositoryUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,8 @@ export interface ImportInfoCardProps {
|
||||
export const ImportInfoCard = (props: ImportInfoCardProps) => {
|
||||
const {
|
||||
exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
|
||||
exampleRepositoryUrl = 'https://github.com/backstage/backstage',
|
||||
exampleGitRepositoryUrl = 'https://github.com/backstage/backstage',
|
||||
exampleAzureRepositoryUrl = 'https://dev.azure.com/spotify/backstage/_git/backstage',
|
||||
} = props;
|
||||
|
||||
const configApi = useApi(configApiRef);
|
||||
@@ -77,10 +79,18 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => {
|
||||
<>
|
||||
<Typography component="h4" variant="h6">
|
||||
Link to a repository{' '}
|
||||
<Chip label="GitHub only" variant="outlined" size="small" />
|
||||
<Chip
|
||||
label="GitHub and AzureDevops"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
<Typography variant="subtitle2" color="textSecondary" paragraph>
|
||||
GitHub Example: <code>{exampleGitRepositoryUrl}</code>
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" color="textSecondary" paragraph>
|
||||
Example: <code>{exampleRepositoryUrl}</code>
|
||||
Azure Example: <code>{exampleAzureRepositoryUrl}</code>
|
||||
</Typography>
|
||||
<Typography variant="body2" paragraph>
|
||||
The wizard discovers all <code>{catalogFilename}</code> files in the
|
||||
|
||||
Reference in New Issue
Block a user