diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 99526a3eaa..5abb0e3e53 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -30,6 +30,11 @@ export interface CatalogImportApi { fileContent: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }>; + checkForExistingCatalogInfo(options: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }>; createRepositoryLocation(options: { location: string }): Promise; generateEntityDefinitions(options: { repo: string; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts new file mode 100644 index 0000000000..0e0cf4b323 --- /dev/null +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { CatalogImportClient } from './CatalogImportClient'; + +jest.mock('@octokit/rest', () => ({ + Octokit: jest.fn().mockImplementation(() => { + return { + repos: { + get: () => + Promise.resolve({ + data: { + default_branch: 'main', + }, + }), + }, + search: { + code: () => + Promise.resolve({ + data: { + total_count: 2, + items: [ + { path: 'simple/path/catalog-info.yaml' }, + { path: 'co/mple/x/path/catalog-info.yaml' }, + { path: 'catalog-info.yaml' }, + ], + }, + }), + }, + }; + }), +})); + +describe('CatalogImportClient', () => { + describe('checkForExistingCatalogInfo', () => { + const cic = new CatalogImportClient({ + discoveryApi: { getBaseUrl: () => Promise.resolve('base') }, + githubAuthApi: { getAccessToken: (_, __) => Promise.resolve('token') }, + configApi: {} as any, + }); + it('should return the closest-to-root catalog-info from multiple responses', async () => { + const respo = await cic.checkForExistingCatalogInfo({ + owner: 'test-user', + repo: 'rest-repo', + githubIntegrationConfig: { host: 'https://github.com' }, + }); + expect(respo.exists).toBe(true); + expect(respo.url).toBe('blob/main/catalog-info.yaml'); + }); + }); +}); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 0e28042db3..af429f748a 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -91,6 +91,54 @@ export class CatalogImportClient implements CatalogImportApi { } } + async checkForExistingCatalogInfo({ + owner, + repo, + githubIntegrationConfig, + }: { + owner: string; + repo: string; + githubIntegrationConfig: GitHubIntegrationConfig; + }): Promise<{ exists: boolean; url?: string }> { + const token = await this.githubAuthApi.getAccessToken(['repo']); + const octo = new Octokit({ + auth: token, + baseUrl: githubIntegrationConfig.apiBaseUrl, + }); + const catalogFileName = 'catalog-info.yaml'; + const query = `repo:${owner}/${repo}+filename:${catalogFileName}`; + + const searchResult = await octo.search.code({ q: query }).catch(e => { + throw new Error( + formatHttpErrorMessage( + "Couldn't search repository for metadata file.", + e, + ), + ); + }); + const exists = searchResult.data.total_count > 0; + if (exists) { + const repoInformation = await octo.repos.get({ owner, repo }).catch(e => { + throw new Error(formatHttpErrorMessage("Couldn't fetch repo data", e)); + }); + const defaultBranch = repoInformation.data.default_branch; + + // Github search sorts returned values with 'best match' using 'multiple factors to boost the most relevant item', + // aka magic. + // Sorting to use the shortest item, closest to the repository root. + const catalogInfoItem = searchResult.data.items + .map(it => it.path) + .sort((a, b) => a.length - b.length)[0]; + return Promise.resolve({ + url: `blob/${defaultBranch}/${catalogInfoItem}`, + exists, + }); + } + return Promise.resolve({ + exists, + }); + } + async submitPrToRepo({ owner, repo, diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index dab5ccbea2..b7dc82c9b1 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -59,27 +59,45 @@ export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => { const isMounted = useMountedState(); const errorApi = useApi(errorApiRef); - const { generateEntityDefinitions } = useGithubRepos(); + const { + generateEntityDefinitions, + checkForExistingCatalogInfo, + } = useGithubRepos(); const onSubmit = async (formData: Record) => { const { componentLocation: target } = formData; - try { - if (!isMounted()) return; - const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; + async function saveCatalogFileConfig(target: string) { + const data = await catalogApi.addLocation({ target }); + saveConfig({ + type: 'file', + location: data.location.target, + config: data.entities, + }); + } - if (type === 'repo') { + async function trySaveRepositoryConfig(target: string) { + const existingCatalog = await checkForExistingCatalogInfo(target); + if (existingCatalog.exists) { + const targetUrl = target.endsWith('/') + ? `${target}${existingCatalog.url}` + : `${target}/${existingCatalog.url}`; + await saveCatalogFileConfig(targetUrl); + } else { saveConfig({ - type, + type: 'repo', location: target, config: await generateEntityDefinitions(target), }); + } + } + + try { + if (!isMounted()) return; + const type = !parseGitUri(target).filepathtype ? 'repo' : 'file'; + if (type === 'repo') { + await trySaveRepositoryConfig(target); } else { - const data = await catalogApi.addLocation({ target }); - saveConfig({ - type, - location: data.location.target, - config: data.entities, - }); + await saveCatalogFileConfig(target); } nextStep(); } catch (e) { diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index 9fdb465dd1..8b3b90003d 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -77,8 +77,9 @@ export const ImportComponentPage = ({ GitHub Repo - If you already have code in a GitHub repository, enter the full - URL to your repo and a new pull request with a sample Backstage + If you already have code in a GitHub repository without + Backstage metadata file set up for it, enter the full URL to + your repo and a new pull request with a sample Backstage metadata Entity File (catalog-info.yaml) will be opened for you. diff --git a/plugins/catalog-import/src/util/useGithubRepos.ts b/plugins/catalog-import/src/util/useGithubRepos.ts index 30e77e18a8..56cf101c35 100644 --- a/plugins/catalog-import/src/util/useGithubRepos.ts +++ b/plugins/catalog-import/src/util/useGithubRepos.ts @@ -28,12 +28,12 @@ export function useGithubRepos() { const api = useApi(catalogImportApiRef); const config = useApi(configApiRef); - const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const getGithubIntegrationConfig = (location: string) => { const { name: repoName, owner: ownerName, resource: hostname, - } = parseGitUri(selectedRepo.location); + } = parseGitUri(location); const configs = readGitHubIntegrationConfigs( config.getOptionalConfigArray('integrations.github') ?? [], @@ -41,9 +41,22 @@ export function useGithubRepos() { const githubIntegrationConfig = configs.find(v => v.host === hostname); if (!githubIntegrationConfig) { throw new Error( - `Unable to locate github-integration for repo-location: ${selectedRepo.location}`, + `Unable to locate github-integration for repo-location: ${location}`, ); } + return { + repoName, + ownerName, + githubIntegrationConfig, + }; + }; + + const submitPrToRepo = async (selectedRepo: ConfigSpec) => { + const { + repoName, + ownerName, + githubIntegrationConfig, + } = getGithubIntegrationConfig(selectedRepo.location); const submitPRResponse = await api .submitPrToRepo({ owner: ownerName, @@ -68,8 +81,28 @@ export function useGithubRepos() { return submitPRResponse; }; + const checkForExistingCatalogInfo = async (location: string) => { + const { + repoName, + ownerName, + githubIntegrationConfig, + } = getGithubIntegrationConfig(location); + return await api + .checkForExistingCatalogInfo({ + owner: ownerName, + repo: repoName, + githubIntegrationConfig, + }) + .catch(e => { + throw new Error( + `Failed to inspect repository for existing catalog-info.yaml:\n${e.message}`, + ); + }); + }; + return { submitPrToRepo, + checkForExistingCatalogInfo, generateEntityDefinitions: (repo: string) => api.generateEntityDefinitions({ repo }), addLocation: (location: string) =>