Modifies component registration to use existing file if present

Creates additional check on catalog import form to check for an existing catalog-info.yaml file if repo URL put in.
Registers an existing file instead of creating pull request in case catalog file is present.
Defaults to the root-most catalog-info.yaml file if multiple defined in the repository.
This commit is contained in:
Jussi Hallila
2021-01-12 17:29:44 +01:00
parent ded5005799
commit 19355928c0
6 changed files with 186 additions and 17 deletions
@@ -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<void>;
generateEntityDefinitions(options: {
repo: string;
@@ -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');
});
});
});
@@ -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,
@@ -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<string, string>) => {
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) {
@@ -77,8 +77,9 @@ export const ImportComponentPage = ({
</Typography>
<Typography variant="h6">GitHub Repo</Typography>
<Typography variant="body2" paragraph>
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 (<code>catalog-info.yaml</code>) will be
opened for you.
</Typography>
@@ -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) =>