From 3daad614529df66948d936640a28ab027a2134c5 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Wed, 20 Mar 2024 10:29:05 +0200 Subject: [PATCH] feat: support imports from azure devops Signed-off-by: Andrei Ivanovici --- .changeset/rude-buckets-invite.md | 7 + plugins/catalog-import/src/api/AzureDevops.ts | 81 ++++ .../src/api/AzureRepoApiClient.test.ts | 356 ++++++++++++++++++ .../src/api/AzureRepoApiClient.ts | 269 +++++++++++++ .../src/api/CatalogImportClient.test.ts | 68 +++- .../src/api/CatalogImportClient.ts | 194 +++------- plugins/catalog-import/src/api/GitHub.ts | 135 ++++++- .../ImportInfoCard/ImportInfoCard.tsx | 18 +- 8 files changed, 976 insertions(+), 152 deletions(-) create mode 100644 .changeset/rude-buckets-invite.md create mode 100644 plugins/catalog-import/src/api/AzureDevops.ts create mode 100644 plugins/catalog-import/src/api/AzureRepoApiClient.test.ts create mode 100644 plugins/catalog-import/src/api/AzureRepoApiClient.ts diff --git a/.changeset/rude-buckets-invite.md b/.changeset/rude-buckets-invite.md new file mode 100644 index 0000000000..51dca15a82 --- /dev/null +++ b/.changeset/rude-buckets-invite.md @@ -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 diff --git a/plugins/catalog-import/src/api/AzureDevops.ts b/plugins/catalog-import/src/api/AzureDevops.ts new file mode 100644 index 0000000000..d2cf9eacd7 --- /dev/null +++ b/plugins/catalog-import/src/api/AzureDevops.ts @@ -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, + }; +} 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..6579bba5a9 --- /dev/null +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts @@ -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; + + 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, + ); + }); +}); diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.ts new file mode 100644 index 0000000000..ea152b2947 --- /dev/null +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.ts @@ -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 | 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, + ): Promise { + 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( + path: string, + version: string, + payload: unknown, + ): Promise { + 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 { + return this.get(`/${repositoryName}`, apiVersions.V7_2p_1); + } + + async getDefaultBranch(repo: AzureRepo): Promise { + 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 { + 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.V7_2p_2, + push, + ); + return result.refUpdates[0]; + } + + async createPullRequest( + repoName: string, + sourceName: string, + targetName: string, + options: CreatePrOptions, + ): Promise { + const payload: CreateAzurePr = { + title: options.title, + description: options.description, + sourceRefName: sourceName, + targetRefName: targetName, + }; + + return await this.post( + `/${repoName}/pullrequests`, + apiVersions.V7_2p_2, + payload, + ); + } +} + +export async function createAzurePullRequest( + options: AzurePrOptions, + client: RepoApiClient | undefined = undefined, +): Promise { + 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, + ); +} diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index df44ec94c4..d4e4eeed54 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -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'; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index cecb85f9a3..e05e3882c4 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -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}`; } 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}`; +} diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index be8a457572..a55c9825d5 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -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) => { <> Link to a repository{' '} - + + + + + GitHub Example: {exampleGitRepositoryUrl} - Example: {exampleRepositoryUrl} + Azure Example: {exampleAzureRepositoryUrl} The wizard discovers all {catalogFilename} files in the