From 3daad614529df66948d936640a28ab027a2134c5 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Wed, 20 Mar 2024 10:29:05 +0200 Subject: [PATCH 01/10] 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 From 4c7d217da961ec13bd649b2431e9be48eba79755 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Mon, 25 Mar 2024 11:57:04 +0200 Subject: [PATCH 02/10] chore: update api reports Signed-off-by: Andrei Ivanovici --- plugins/catalog-import/api-report.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index a513cfe87a..9a755f4182 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -189,9 +189,11 @@ export const ImportInfoCard: ( // @public export interface ImportInfoCardProps { // (undocumented) - exampleLocationUrl?: string; + exampleAzureRepositoryUrl?: string; // (undocumented) - exampleRepositoryUrl?: string; + exampleGitRepositoryUrl?: string; + // (undocumented) + exampleLocationUrl?: string; } // Warning: (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts From 06419882ea7aa0a1df6e091cd69584cde7d53e38 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Mon, 25 Mar 2024 12:15:37 +0200 Subject: [PATCH 03/10] fix: remove token from test assertions Signed-off-by: Andrei Ivanovici --- plugins/catalog-import/src/api/AzureRepoApiClient.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts index 6579bba5a9..5024b8cda4 100644 --- a/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts @@ -155,10 +155,9 @@ describe('RepoApiClient', () => { const server = setupServer(); setupRequestMockHandlers(server); const sut = new RepoApiClient({ - token: 'token', project: 'project', tenantUrl: 'https://dev.azure.com/acme', - }); + } as any); beforeEach(() => { server.use( mockPrEndpoint(), @@ -307,8 +306,7 @@ describe('createAzurePullRequest', () => { fileContent: 'content', branchName: 'backstage-integration', description: 'Test Description', - token: 'token', - }; + } as any; const repo: AzureRepo = { name: options.repository, defaultBranch: 'ref/heads/main', From 0780898029a6c417c0f35532d9d553664be0c0ab Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Tue, 26 Mar 2024 18:03:36 +0200 Subject: [PATCH 04/10] fix: fix typos Signed-off-by: Andrei Ivanovici --- .../src/components/ImportInfoCard/ImportInfoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index a55c9825d5..cd585a8dc8 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -80,7 +80,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { Link to a repository{' '} From 8a3786b0e55e1beeebee33d3b453d8b4aa208525 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Mon, 22 Apr 2024 13:11:35 +0300 Subject: [PATCH 05/10] chore: update docs Signed-off-by: Andrei Ivanovici --- .changeset/rude-buckets-invite.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/rude-buckets-invite.md b/.changeset/rude-buckets-invite.md index 51dca15a82..03a9c1b8fd 100644 --- a/.changeset/rude-buckets-invite.md +++ b/.changeset/rude-buckets-invite.md @@ -2,6 +2,4 @@ '@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 +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 From 0a5afe95ec1b714fc1ccee075b8b499d77976443 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Mon, 22 Apr 2024 15:43:09 +0300 Subject: [PATCH 06/10] chore: use azure rest api v6 Signed-off-by: Andrei Ivanovici --- .../catalog-import/src/api/AzureRepoApiClient.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.ts index ea152b2947..a743159ea8 100644 --- a/plugins/catalog-import/src/api/AzureRepoApiClient.ts +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.ts @@ -90,10 +90,7 @@ export interface AzurePrResult { }; } -const apiVersions = { - V7_2p_1: '7.2-preview.1', - V7_2p_2: '7.2-preview.2', -}; +const apiVersions = '6.0'; export interface RepoApiClientOptions { project: string; @@ -171,14 +168,14 @@ export class RepoApiClient { } async getRepository(repositoryName: string): Promise { - return this.get(`/${repositoryName}`, apiVersions.V7_2p_1); + return this.get(`/${repositoryName}`, apiVersions); } async getDefaultBranch(repo: AzureRepo): Promise { const filter = repo.defaultBranch.replace('refs/', ''); const result: RefQueryResult = await this.get( `/${repo.name}/refs`, - apiVersions.V7_2p_2, + apiVersions, { filter }, ); if (!result.value?.length) { @@ -221,7 +218,7 @@ export class RepoApiClient { }; const result = await this.post( `/${repoName}/pushes`, - apiVersions.V7_2p_2, + apiVersions, push, ); return result.refUpdates[0]; @@ -242,7 +239,7 @@ export class RepoApiClient { return await this.post( `/${repoName}/pullrequests`, - apiVersions.V7_2p_2, + apiVersions, payload, ); } From 9a22ffef8adabe2d7ff959fe8a2a8767f4b6ac4f Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Mon, 6 May 2024 18:59:38 +0300 Subject: [PATCH 07/10] chore: use generic example Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Andrei Ivanovici --- .../src/components/ImportInfoCard/ImportInfoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index cd585a8dc8..67979f1a3d 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -42,7 +42,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { const { exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', exampleGitRepositoryUrl = 'https://github.com/backstage/backstage', - exampleAzureRepositoryUrl = 'https://dev.azure.com/spotify/backstage/_git/backstage', + exampleAzureRepositoryUrl = 'https://dev.azure.com/org-name/project-name/_git/repo-name, } = props; const configApi = useApi(configApiRef); From 4fefb2a3938f53172096d9df45fa205bb9d21a84 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Fri, 10 May 2024 15:38:48 +0300 Subject: [PATCH 08/10] chore: use alt url parser Signed-off-by: Andrei Ivanovici --- plugins/catalog-import/src/api/AzureDevops.ts | 26 +++++----- .../src/api/CatalogImportClient.ts | 2 - plugins/catalog-import/src/api/util.test.ts | 51 +++++++++++++++++++ plugins/catalog-import/src/api/util.ts | 40 +++++++++++++++ .../ImportInfoCard/ImportInfoCard.tsx | 18 ++----- 5 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 plugins/catalog-import/src/api/util.test.ts create mode 100644 plugins/catalog-import/src/api/util.ts diff --git a/plugins/catalog-import/src/api/AzureDevops.ts b/plugins/catalog-import/src/api/AzureDevops.ts index d2cf9eacd7..d028c75129 100644 --- a/plugins/catalog-import/src/api/AzureDevops.ts +++ b/plugins/catalog-import/src/api/AzureDevops.ts @@ -13,12 +13,11 @@ * 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'; +import { parseRepoUrl } from './util'; export interface AzureRepoParts { tenantUrl: string; @@ -26,17 +25,19 @@ export interface AzureRepoParts { 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 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( - integration: AzureIntegration, options: { title: string; body: string; @@ -57,10 +58,7 @@ export async function submitAzurePrToRepo( repoWrite: true, }, }); - const { tenantUrl, repoName, project } = parseAzureUrl( - repositoryUrl, - integration, - ); + const { tenantUrl, repoName, project } = parseAzureUrl(repositoryUrl); const result = await createAzurePullRequest({ token, fileContent, diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index e05e3882c4..fe7fc5c59e 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -21,7 +21,6 @@ import { IdentityApi, } from '@backstage/core-plugin-api'; import { - AzureIntegration, GithubIntegration, ScmIntegrationRegistry, } from '@backstage/integration'; @@ -208,7 +207,6 @@ the component will become available.\n\nFor more information, read an \ } case 'azure': { return submitAzurePrToRepo( - provider as AzureIntegration, { repositoryUrl, fileContent, diff --git a/plugins/catalog-import/src/api/util.test.ts b/plugins/catalog-import/src/api/util.test.ts new file mode 100644 index 0000000000..3a14a2e7ae --- /dev/null +++ b/plugins/catalog-import/src/api/util.test.ts @@ -0,0 +1,51 @@ +/* + * 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 './util'; + +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/util.ts b/plugins/catalog-import/src/api/util.ts new file mode 100644 index 0000000000..dc6b4ae2a6 --- /dev/null +++ b/plugins/catalog-import/src/api/util.ts @@ -0,0 +1,40 @@ +/* + * 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 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/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index 67979f1a3d..be8a457572 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -29,8 +29,7 @@ import { useCatalogFilename } from '../../hooks'; */ export interface ImportInfoCardProps { exampleLocationUrl?: string; - exampleGitRepositoryUrl?: string; - exampleAzureRepositoryUrl?: string; + exampleRepositoryUrl?: string; } /** @@ -41,8 +40,7 @@ export interface ImportInfoCardProps { export const ImportInfoCard = (props: ImportInfoCardProps) => { const { exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - exampleGitRepositoryUrl = 'https://github.com/backstage/backstage', - exampleAzureRepositoryUrl = 'https://dev.azure.com/org-name/project-name/_git/repo-name, + exampleRepositoryUrl = 'https://github.com/backstage/backstage', } = props; const configApi = useApi(configApiRef); @@ -79,18 +77,10 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { <> Link to a repository{' '} - - - - - GitHub Example: {exampleGitRepositoryUrl} + - Azure Example: {exampleAzureRepositoryUrl} + Example: {exampleRepositoryUrl} The wizard discovers all {catalogFilename} files in the From b54aee0d4ac30562b4e60bd6bee6c34897e30278 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Fri, 10 May 2024 17:46:47 +0300 Subject: [PATCH 09/10] chore: update api report Signed-off-by: Andrei Ivanovici --- plugins/catalog-import/api-report.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index 9a755f4182..a513cfe87a 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -188,12 +188,10 @@ export const ImportInfoCard: ( // @public export interface ImportInfoCardProps { - // (undocumented) - exampleAzureRepositoryUrl?: string; - // (undocumented) - exampleGitRepositoryUrl?: string; // (undocumented) exampleLocationUrl?: string; + // (undocumented) + exampleRepositoryUrl?: string; } // Warning: (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts From 65b8256bf88263af000020ac5f5c1cc344542c50 Mon Sep 17 00:00:00 2001 From: Andrei Ivanovici Date: Sat, 1 Jun 2024 00:52:24 +0300 Subject: [PATCH 10/10] chore: code cleanup Signed-off-by: Andrei Ivanovici --- .../api/{util.test.ts => AzureDevops.test.ts} | 3 +- plugins/catalog-import/src/api/AzureDevops.ts | 27 +++- .../src/api/AzureRepoApiClient.test.ts | 131 +++++++++++------- .../src/api/AzureRepoApiClient.ts | 74 ++++++---- .../src/api/CatalogImportClient.test.ts | 3 +- .../src/api/CatalogImportClient.ts | 10 +- plugins/catalog-import/src/api/util.ts | 40 ------ 7 files changed, 169 insertions(+), 119 deletions(-) rename plugins/catalog-import/src/api/{util.test.ts => AzureDevops.test.ts} (97%) delete mode 100644 plugins/catalog-import/src/api/util.ts diff --git a/plugins/catalog-import/src/api/util.test.ts b/plugins/catalog-import/src/api/AzureDevops.test.ts similarity index 97% rename from plugins/catalog-import/src/api/util.test.ts rename to plugins/catalog-import/src/api/AzureDevops.test.ts index 3a14a2e7ae..4a3dfca42d 100644 --- a/plugins/catalog-import/src/api/util.test.ts +++ b/plugins/catalog-import/src/api/AzureDevops.test.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { parseRepoUrl } from './util'; + +import { parseRepoUrl } from './AzureDevops'; describe('parseRepoUrl', () => { it('parses Azure DevOps Cloud url', async () => { diff --git a/plugins/catalog-import/src/api/AzureDevops.ts b/plugins/catalog-import/src/api/AzureDevops.ts index d028c75129..f7309d01f8 100644 --- a/plugins/catalog-import/src/api/AzureDevops.ts +++ b/plugins/catalog-import/src/api/AzureDevops.ts @@ -17,7 +17,6 @@ import { ScmAuthApi } from '@backstage/integration-react'; import { ConfigApi } from '@backstage/core-plugin-api'; import { getBranchName, getCatalogFilename } from '../components/helpers'; import { createAzurePullRequest } from './AzureRepoApiClient'; -import { parseRepoUrl } from './util'; export interface AzureRepoParts { tenantUrl: string; @@ -77,3 +76,29 @@ export async function submitAzurePrToRepo( 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 index 5024b8cda4..a8fbe9db76 100644 --- a/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.test.ts @@ -154,6 +154,7 @@ function mockPrEndpoint() { describe('RepoApiClient', () => { const server = setupServer(); setupRequestMockHandlers(server); + const testToken = new Date().toString(); const sut = new RepoApiClient({ project: 'project', tenantUrl: 'https://dev.azure.com/acme', @@ -168,25 +169,28 @@ describe('RepoApiClient', () => { }); describe('getRepository', () => { it('should get an existing repository', async () => { - await expect(sut.getRepository('success')).resolves.toEqual({ + 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')).rejects.toThrow( + 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', - }); + const foundRef = await sut.getDefaultBranch( + { + name: 'success', + defaultBranch: 'refs/heads/main', + id: '01', + }, + testToken, + ); expect(foundRef).toEqual({ name: 'refs/heads/main', objectId: '0000000000000000000000000000000000000000', @@ -194,37 +198,43 @@ describe('RepoApiClient', () => { }); it('should throw when the repository does not exist', async () => { - const promise = sut.getDefaultBranch({ - name: 'fail', - defaultBranch: 'refs/heads/main', - id: '01', - }); + 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', - }); + 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 sourceBranch: AzureRef; let options: NewBranchOptions; let expectedResult: AzureRefUpdate; beforeEach(() => { - sourceBranch = { - name: 'refs/heads/main', - objectId: '0000000000000000000000000000000000000000', - }; options = { + repoName: 'name', title: 'title', + sourceBranch: { + name: 'refs/heads/main', + objectId: '0000000000000000000000000000000000000000', + }, branchName: 'backstage-integration', fileName: 'catalog-info.yaml', fileContent: 'This is a test', @@ -238,29 +248,43 @@ describe('RepoApiClient', () => { }); it('should create a new branch', async () => { await expect( - sut.pushNewBranch('success', sourceBranch, options), + sut.pushNewBranch( + { + ...options, + repoName: 'success', + }, + testToken, + ), ).resolves.toEqual(expectedResult); }); it('should throw when api call fails', async () => { await expect( - sut.pushNewBranch('error', sourceBranch, options), + sut.pushNewBranch( + { + ...options, + repoName: 'error', + }, + testToken, + ), ).rejects.toThrow(new Error('internal error')); }); }); describe('createPullRequest', () => { - const sourceBranchName = 'refs/heads/main'; - const targetBranchName = 'refs/heads/backstage-integration'; 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( - 'success', - sourceBranchName, - targetBranchName, - options, + { + ...options, + repoName: 'success', + }, + testToken, ), ).resolves.toEqual({ pullRequestId: 'PR01', @@ -272,12 +296,7 @@ describe('RepoApiClient', () => { }); it('should throw when api call fails', async () => { await expect( - sut.createPullRequest( - 'error', - sourceBranchName, - targetBranchName, - options, - ), + sut.createPullRequest({ ...options, repoName: 'error' }, testToken), ).rejects.toThrow(new Error('internal error')); }); }); @@ -297,6 +316,7 @@ describe('createAzurePullRequest', () => { }); 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', @@ -306,7 +326,8 @@ describe('createAzurePullRequest', () => { fileContent: 'content', branchName: 'backstage-integration', description: 'Test Description', - } as any; + token: testToken, + }; const repo: AzureRepo = { name: options.repository, defaultBranch: 'ref/heads/main', @@ -337,18 +358,34 @@ describe('createAzurePullRequest', () => { 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.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( - repo.name, - options.branchName, - defaultBranch.name, - options, + expectedPrOptions, + testToken, ); }); }); diff --git a/plugins/catalog-import/src/api/AzureRepoApiClient.ts b/plugins/catalog-import/src/api/AzureRepoApiClient.ts index a743159ea8..56be626f5e 100644 --- a/plugins/catalog-import/src/api/AzureRepoApiClient.ts +++ b/plugins/catalog-import/src/api/AzureRepoApiClient.ts @@ -95,7 +95,6 @@ const apiVersions = '6.0'; export interface RepoApiClientOptions { project: string; tenantUrl: string; - token: string; } export interface NewBranchOptions { @@ -103,9 +102,14 @@ export interface NewBranchOptions { fileName: string; title: string; branchName: string; + repoName: string; + sourceBranch: AzureRef; } export interface CreatePrOptions { + repoName: string; + sourceName: string; + targetName: string; description: string; title: string; } @@ -134,11 +138,12 @@ export class RepoApiClient { 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 ${this._options.token}`, + Authorization: `Bearer ${token}`, }, }); if (!result.ok) { @@ -151,12 +156,13 @@ export class RepoApiClient { 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 ${this._options.token}`, + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), @@ -167,16 +173,20 @@ export class RepoApiClient { return await result.json(); } - async getRepository(repositoryName: string): Promise { - return this.get(`/${repositoryName}`, apiVersions); + async getRepository( + repositoryName: string, + token: string, + ): Promise { + return this.get(`/${repositoryName}`, apiVersions, undefined, token); } - async getDefaultBranch(repo: AzureRepo): Promise { + 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( @@ -187,10 +197,10 @@ export class RepoApiClient { } async pushNewBranch( - repoName: string, - sourceBranch: AzureRef, options: NewBranchOptions, + token: string, ): Promise { + const { sourceBranch, repoName } = options; const push: AzurePush = { refUpdates: [ { @@ -220,16 +230,16 @@ export class RepoApiClient { `/${repoName}/pushes`, apiVersions, push, + token, ); return result.refUpdates[0]; } async createPullRequest( - repoName: string, - sourceName: string, - targetName: string, options: CreatePrOptions, + token: string, ): Promise { + const { repoName, sourceName, targetName } = options; const payload: CreateAzurePr = { title: options.title, description: options.description, @@ -241,6 +251,7 @@ export class RepoApiClient { `/${repoName}/pullrequests`, apiVersions, payload, + token, ); } } @@ -249,18 +260,33 @@ 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(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, - ); + 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 d4e4eeed54..315b59ea67 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -43,6 +43,7 @@ jest.mock('@octokit/rest', () => { return octokit; } } + return { Octokit }; }); @@ -299,7 +300,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.', ), ); }); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index fe7fc5c59e..9745bd4a85 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -91,9 +91,9 @@ export class CatalogImportClient implements CatalogImportApi { } const supportedIntegrations = ['github', 'azure']; const foundIntegration = this.scmIntegrationsApi.byUrl(url); - const iSupported = supportedIntegrations.find( - it => it === foundIntegration?.type, - ); + const iSupported = + !!foundIntegration && + supportedIntegrations.find(it => it === foundIntegration.type); if (!iSupported) { const catalogFilename = getCatalogFilename(this.configApi); @@ -103,7 +103,7 @@ export class CatalogImportClient implements CatalogImportApi { ); } 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.`, ); } @@ -146,7 +146,7 @@ export class CatalogImportClient implements CatalogImportApi { return { type: 'repository', - integrationType: foundIntegration!.type, + integrationType: foundIntegration.type, url: url, generatedEntities: analyzation.generateEntities.map(x => x.entity), }; diff --git a/plugins/catalog-import/src/api/util.ts b/plugins/catalog-import/src/api/util.ts deleted file mode 100644 index dc6b4ae2a6..0000000000 --- a/plugins/catalog-import/src/api/util.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 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 }; -}