diff --git a/.changeset/rude-buckets-invite.md b/.changeset/rude-buckets-invite.md new file mode 100644 index 0000000000..03a9c1b8fd --- /dev/null +++ b/.changeset/rude-buckets-invite.md @@ -0,0 +1,5 @@ +--- +'@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 diff --git a/plugins/catalog-import/src/api/AzureDevops.test.ts b/plugins/catalog-import/src/api/AzureDevops.test.ts new file mode 100644 index 0000000000..4a3dfca42d --- /dev/null +++ b/plugins/catalog-import/src/api/AzureDevops.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { parseRepoUrl } from './AzureDevops'; + +describe('parseRepoUrl', () => { + it('parses Azure DevOps Cloud url', async () => { + const result = parseRepoUrl( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + ); + + expect(result.host).toEqual('dev.azure.com'); + expect(result.org).toEqual('organization'); + expect(result.project).toEqual('project'); + expect(result.repo).toEqual('repository'); + }); + + it('parses Azure DevOps Server url', async () => { + const result = parseRepoUrl( + 'https://server.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + ); + + expect(result.host).toEqual('server.com'); + expect(result.org).toEqual('organization'); + expect(result.project).toEqual('project'); + expect(result.repo).toEqual('repository'); + }); + + it('parses TFS subpath Url', async () => { + const result = parseRepoUrl( + 'https://server.com/tfs/organization/project/_git/repository?path=%2Fcatalog-info.yaml', + ); + + expect(result.host).toEqual('server.com/tfs'); + expect(result.org).toEqual('organization'); + expect(result.project).toEqual('project'); + expect(result.repo).toEqual('repository'); + }); +}); diff --git a/plugins/catalog-import/src/api/AzureDevops.ts b/plugins/catalog-import/src/api/AzureDevops.ts new file mode 100644 index 0000000000..f7309d01f8 --- /dev/null +++ b/plugins/catalog-import/src/api/AzureDevops.ts @@ -0,0 +1,104 @@ +/* + * 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 { ScmAuthApi } from '@backstage/integration-react'; +import { ConfigApi } from '@backstage/core-plugin-api'; +import { getBranchName, getCatalogFilename } from '../components/helpers'; +import { createAzurePullRequest } from './AzureRepoApiClient'; + +export interface AzureRepoParts { + tenantUrl: string; + repoName: string; + project: string; +} + +export function parseAzureUrl(repoUrl: string): AzureRepoParts { + const { org, repo, project, host } = parseRepoUrl(repoUrl); + if (!org || !repo || !project) { + throw new Error( + 'Invalid AzureDevops Repository. Please use a valid repository url and try again ', + ); + } + const tenantUrl = `https://${host}/${org}`; + + return { tenantUrl, repoName: repo, project: project }; +} + +export async function submitAzurePrToRepo( + 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); + 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, + }; +} + +export function parseRepoUrl(sourceUrl: string) { + const url = new URL(sourceUrl); + + let host = url.host; + let org; + let project; + let repo; + + const parts = url.pathname.split('/').map(part => decodeURIComponent(part)); + if (parts[2] === '_git') { + org = parts[1]; + project = repo = parts[3]; + } else if (parts[3] === '_git') { + org = parts[1]; + project = parts[2]; + repo = parts[4]; + } else if (parts[4] === '_git') { + host = `${host}/${parts[1]}`; + org = parts[2]; + project = parts[3]; + repo = parts[5]; + } + + return { host, org, project, repo }; +} diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts new file mode 100644 index 0000000000..a8fbe9db76 --- /dev/null +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts @@ -0,0 +1,391 @@ +/* + * 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 testToken = new Date().toString(); + const sut = new RepoApiClient({ + project: 'project', + tenantUrl: 'https://dev.azure.com/acme', + } as any); + beforeEach(() => { + server.use( + mockPrEndpoint(), + mockRefsEndpoint(), + mockPushEndpoint(), + mockRepoEndpoint(), + ); + }); + describe('getRepository', () => { + it('should get an existing repository', async () => { + await expect(sut.getRepository('success', testToken)).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', testToken)).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', + }, + testToken, + ); + 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', + }, + testToken, + ); + 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', + }, + testToken, + ); + await expect(promise).rejects.toThrow( + new Error(`The requested ref 'heads/missing_branch' was not found`), + ); + }); + }); + describe('pushNewBranch', () => { + let options: NewBranchOptions; + let expectedResult: AzureRefUpdate; + + beforeEach(() => { + options = { + repoName: 'name', + title: 'title', + sourceBranch: { + name: 'refs/heads/main', + objectId: '0000000000000000000000000000000000000000', + }, + 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( + { + ...options, + repoName: 'success', + }, + testToken, + ), + ).resolves.toEqual(expectedResult); + }); + it('should throw when api call fails', async () => { + await expect( + sut.pushNewBranch( + { + ...options, + repoName: 'error', + }, + testToken, + ), + ).rejects.toThrow(new Error('internal error')); + }); + }); + describe('createPullRequest', () => { + const options: CreatePrOptions = { + repoName: 'repoName', + sourceName: 'refs/heads/main', + targetName: 'refs/heads/backstage-integration', + title: 'Title', + description: 'Description', + }; + it('should create a new Pull request', async () => { + await expect( + sut.createPullRequest( + { + ...options, + repoName: 'success', + }, + testToken, + ), + ).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({ ...options, repoName: 'error' }, testToken), + ).rejects.toThrow(new Error('internal error')); + }); + }); +}); +describe('createAzurePullRequest', () => { + let client: RepoApiClient; + let clientMock: Record; + + 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 testToken = new Date().getTime().toString(); + 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: testToken, + }; + 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, + testToken, + ); + expect(clientMock.getDefaultBranch).toHaveBeenCalledWith(repo, testToken); + const expectedBranchOptions: NewBranchOptions = { + repoName: repo.name, + sourceBranch: defaultBranch, + branchName: options.branchName, + fileContent: options.fileContent, + fileName: options.fileName, + title: options.title, + }; + expect(clientMock.pushNewBranch).toHaveBeenCalledWith( + expectedBranchOptions, + testToken, + ); + + const expectedPrOptions: CreatePrOptions = { + repoName: repo.name, + description: options.description, + title: options.title, + sourceName: options.branchName, + targetName: defaultBranch.name, + }; + expect(clientMock.createPullRequest).toHaveBeenCalledWith( + expectedPrOptions, + testToken, + ); + }); +}); diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.ts new file mode 100644 index 0000000000..56be626f5e --- /dev/null +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.ts @@ -0,0 +1,292 @@ +/* + * 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 = '6.0'; + +export interface RepoApiClientOptions { + project: string; + tenantUrl: string; +} + +export interface NewBranchOptions { + fileContent: string; + fileName: string; + title: string; + branchName: string; + repoName: string; + sourceBranch: AzureRef; +} + +export interface CreatePrOptions { + repoName: string; + sourceName: string; + targetName: string; + description: string; + title: string; +} + +export class RepoApiClient { + private createEndpoint = ( + path: string, + version: string, + queryParams: Record | 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( + path: string, + version: string, + queryParams: Record | undefined = undefined, + token: string, + ): Promise { + const endpoint = this.createEndpoint(path, version, queryParams); + const result = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!result.ok) { + return result.json().then(it => Promise.reject(new Error(it.message))); + } + return await result.json(); + } + + private async post( + path: string, + version: string, + payload: unknown, + token: string, + ): Promise { + const endpoint = this.createEndpoint(path, version); + const result = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${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, + token: string, + ): Promise { + return this.get(`/${repositoryName}`, apiVersions, undefined, token); + } + + async getDefaultBranch(repo: AzureRepo, token: string): Promise { + const filter = repo.defaultBranch.replace('refs/', ''); + const result: RefQueryResult = await this.get( + `/${repo.name}/refs`, + apiVersions, + { filter }, + token, + ); + if (!result.value?.length) { + return Promise.reject( + new Error(`The requested ref '${filter}' was not found`), + ); + } + return result.value[0]; + } + + async pushNewBranch( + options: NewBranchOptions, + token: string, + ): Promise { + const { sourceBranch, repoName } = options; + 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( + `/${repoName}/pushes`, + apiVersions, + push, + token, + ); + return result.refUpdates[0]; + } + + async createPullRequest( + options: CreatePrOptions, + token: string, + ): Promise { + const { repoName, sourceName, targetName } = options; + const payload: CreateAzurePr = { + title: options.title, + description: options.description, + sourceRefName: sourceName, + targetRefName: targetName, + }; + + return await this.post( + `/${repoName}/pullrequests`, + apiVersions, + payload, + token, + ); + } +} + +export async function createAzurePullRequest( + options: AzurePrOptions, + client: RepoApiClient | undefined = undefined, +): Promise { + const { + title, + repository, + token, + fileContent, + fileName, + branchName, + description, + } = options; + const actualClient = client ?? new RepoApiClient(options); + const repo = await actualClient.getRepository(repository, token); + const defaultBranch = await actualClient.getDefaultBranch(repo, token); + const branchOptions: NewBranchOptions = { + title: title, + repoName: repo.name, + sourceBranch: defaultBranch, + branchName: branchName, + fileContent: fileContent, + fileName: fileName, + }; + const refUpdate = await actualClient.pushNewBranch(branchOptions, token); + const prOptions: CreatePrOptions = { + title, + description, + repoName: repo.name, + sourceName: refUpdate.name, + targetName: defaultBranch.name, + }; + return actualClient.createPullRequest(prOptions, token); +} diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index 98cf972446..1f16840c33 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -43,9 +43,16 @@ jest.mock('@octokit/rest', () => { return octokit; } } + 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 +62,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(); @@ -262,7 +274,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', @@ -281,7 +293,7 @@ describe('CatalogImportClient', () => { ), ).rejects.toThrow( new Error( - 'This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a catalog-info.yaml file instead.', + 'This URL was not recognized as a valid git URL because there was no configured integration that matched the given host name. Currently GitHub and Azure DevOps are supported. You could try to paste the full URL to a catalog-info.yaml file instead.', ), ); }); @@ -612,6 +624,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'; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index ec5950ec51..78e54ac98c 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -17,18 +17,18 @@ import { CatalogApi } from '@backstage/catalog-client'; import { ConfigApi, DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { - GithubIntegrationConfig, + 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}. @@ -85,19 +85,21 @@ 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 = + !!foundIntegration && + 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( - `This URL was not recognized as a valid GitHub URL because there was no configured integration that matched the given host name. You could try to paste the full URL to a ${catalogFilename} file instead.`, + `This URL was not recognized as a valid git URL because there was no configured integration that matched the given host name. Currently GitHub and Azure DevOps are supported. You could try to paste the full URL to a ${catalogFilename} file instead.`, ); } @@ -140,7 +142,7 @@ export class CatalogImportClient implements CatalogImportApi { return { type: 'repository', - integrationType: 'github', + integrationType: foundIntegration.type, url: url, generatedEntities: analyzation.generateEntities.map(x => x.entity), }; @@ -181,21 +183,40 @@ 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( + { + 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 @@ -234,125 +255,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}`; } diff --git a/plugins/catalog-import/src/api/GitHub.ts b/plugins/catalog-import/src/api/GitHub.ts index b659832c10..4eb56b3a97 100644 --- a/plugins/catalog-import/src/api/GitHub.ts +++ b/plugins/catalog-import/src/api/GitHub.ts @@ -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}`; +}