Merge pull request #3121 from RoadieHQ/mcalus3/add-catalog-import-plugin

Mcalus3/add catalog import plugin
This commit is contained in:
Fredrik Adelöw
2020-11-30 11:30:49 +01:00
committed by GitHub
37 changed files with 1380 additions and 16 deletions
+28
View File
@@ -0,0 +1,28 @@
---
'@backstage/plugin-catalog-backend': minor
'@backstage/plugin-catalog-import': minor
'@backstage/catalog-model': patch
'@backstage/plugin-scaffolder': patch
---
Add Analyze location endpoint to catalog backend. Add catalog-import plugin and replace import-component with it. To start using Analyze location endpoint, you have add it to the `createRouter` function options in the `\backstage\packages\backend\src\plugins\catalog.ts` file:
```ts
export default async function createPlugin(env: PluginEnvironment) {
const builder = new CatalogBuilder(env);
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
} = await builder.build();
return await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer, //<--
logger: env.logger,
});
}
```
+1
View File
@@ -9,6 +9,7 @@
"@backstage/core": "^0.3.2",
"@backstage/plugin-api-docs": "^0.3.0",
"@backstage/plugin-catalog": "^0.2.4",
"@backstage/plugin-catalog-import": "^0.2.0",
"@backstage/plugin-circleci": "^0.2.2",
"@backstage/plugin-cloudbuild": "^0.2.2",
"@backstage/plugin-cost-insights": "^0.4.1",
+5
View File
@@ -34,6 +34,7 @@ import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
import { Router as SettingsRouter } from '@backstage/plugin-user-settings';
import { Router as ImportComponentRouter } from '@backstage/plugin-catalog-import';
import { Route, Routes, Navigate } from 'react-router';
import { EntityPage } from './components/catalog/EntityPage';
@@ -67,6 +68,10 @@ const catalogRouteRef = createRouteRef({
const AppRoutes = () => (
<Routes>
<Navigate key="/" to="/catalog" />
<Route
path="/catalog-import/*"
element={<ImportComponentRouter catalogRouteRef={catalogRouteRef} />}
/>
<Route
path={`${catalogRouteRef.path}/*`}
element={<CatalogRouter EntityPage={EntityPage} />}
+1
View File
@@ -37,6 +37,7 @@ export { plugin as Kubernetes } from '@backstage/plugin-kubernetes';
export { plugin as Cloudbuild } from '@backstage/plugin-cloudbuild';
export { plugin as CostInsights } from '@backstage/plugin-cost-insights';
export { plugin as GitHubInsights } from '@roadiehq/backstage-plugin-github-insights';
export { plugin as CatalogImport } from '@backstage/plugin-catalog-import';
export { plugin as UserSettings } from '@backstage/plugin-user-settings';
export { plugin as Buildkite } from '@roadiehq/backstage-plugin-buildkite';
export { plugin as Search } from '@backstage/plugin-search';
+2
View File
@@ -28,6 +28,7 @@ export default async function createPlugin(env: PluginEnvironment) {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = await builder.build();
useHotCleanup(
@@ -39,6 +40,7 @@ export default async function createPlugin(env: PluginEnvironment) {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
logger: env.logger,
});
}
+5 -1
View File
@@ -15,5 +15,9 @@
*/
export type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
export {
locationSchema,
locationSpecSchema,
analyzeLocationSchema,
} from './validation';
export { LOCATION_ANNOTATION } from './annotation';
@@ -34,3 +34,10 @@ export const locationSchema = yup
})
.noUnknown()
.required();
export const analyzeLocationSchema = yup
.object<{ location: LocationSpec }>({
location: locationSpecSchema,
})
.noUnknown()
.required();
@@ -0,0 +1,52 @@
/*
* 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 { Logger } from 'winston';
import parseGitUri from 'git-url-parse';
import {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
LocationAnalyzer,
} from './types';
export class RepoLocationAnalyzer implements LocationAnalyzer {
private readonly logger: Logger;
constructor(logger: Logger) {
this.logger = logger;
}
async analyzeLocation(
request: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse> {
const { owner, name, source } = parseGitUri(request.location.target);
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: name,
// Probably won't handle properly self-hosted git providers with custom url
annotations: { [`${source}/project-slug`]: `${owner}/${name}` },
},
spec: { type: 'other', lifecycle: 'unknown' },
};
this.logger.debug(`entity created for ${request.location.target}`);
return {
existingEntityFiles: [],
generateEntities: [{ entity, fields: [] }],
};
}
}
@@ -16,12 +16,5 @@
export { HigherOrderOperations } from './HigherOrderOperations';
export { LocationReaders } from './LocationReaders';
export type {
AddLocationResult,
HigherOrderOperation,
LocationReader,
ReadLocationEntity,
ReadLocationError,
ReadLocationResult,
} from './types';
export * from './types';
export * from './processors';
+74 -1
View File
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import type {
import {
Entity,
EntityRelationSpec,
Location,
LocationSpec,
} from '@backstage/catalog-model';
import { RecursivePartial } from '../util/RecursivePartial';
//
// HigherOrderOperation
@@ -68,3 +69,75 @@ export type ReadLocationError = {
location: LocationSpec;
error: Error;
};
//
// LocationAnalyzer
//
export type LocationAnalyzer = {
/**
* Generates an entity configuration for given git repository. It's used for
* importing new component to the backstage app.
*
* @param location Git repository to analyze and generate config for.
*/
analyzeLocation(
location: AnalyzeLocationRequest,
): Promise<AnalyzeLocationResponse>;
};
export type AnalyzeLocationRequest = {
location: LocationSpec;
};
export type AnalyzeLocationResponse = {
existingEntityFiles: AnalyzeLocationExistingEntity[];
generateEntities: AnalyzeLocationGenerateEntity[];
};
// If the folder pointed to already contained catalog info yaml files, they are
// read and emitted like this so that the frontend can inform the user that it
// located them and can make sure to register them as well if they weren't
// already
type AnalyzeLocationExistingEntity = {
location: LocationSpec;
isRegistered: boolean;
entity: Entity;
};
// This is some form of representation of what the analyzer could deduce.
// We should probably have a chat about how this can best be conveyed to
// the frontend. It'll probably contain a (possibly incomplete) entity, plus
// enough info for the frontend to know what form data to show to the user
// for overriding/completing the info.
type AnalyzeLocationGenerateEntity = {
// Some form of partial representation of the entity
entity: RecursivePartial<Entity>;
// Lists the suggestions that the user may want to override
fields: AnalyzeLocationEntityField[];
};
// This is where I get really vague. Something like this perhaps? Or it could be
// something like a json-schema that contains enough info for the frontend to
// be able to present a form and explanations
type AnalyzeLocationEntityField = {
// e.g. "spec.owner"? The frontend needs to know how to "inject" the field into the
// entity again if the user wants to change it
field: string;
// The outcome of the analysis for this particular field
state:
| 'analysisSuggestedValue'
| 'analysisSuggestedNoValue'
| 'needsUserInput';
// If the analysis did suggest a value, this is where it would be. Not sure if we want
// to limit this to strings or if we want it to be any JsonValue
value: string | null;
// A text to show to the user to inform about the choices made. Like, it could say
// "Found a CODEOWNERS file that covers this target, so we suggest leaving this
// field empty; which would currently make it owned by X" where X is taken from the
// codeowners file.
description: string;
};
@@ -54,11 +54,13 @@ import {
UrlReaderProcessor,
} from '../ingestion';
import { CatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer';
import {
jsonPlaceholderResolver,
textPlaceholderResolver,
yamlPlaceholderResolver,
} from '../ingestion/processors/PlaceholderProcessor';
import { LocationAnalyzer } from '../ingestion/types';
export type CatalogEnvironment = {
logger: Logger;
@@ -202,6 +204,7 @@ export class CatalogBuilder {
entitiesCatalog: EntitiesCatalog;
locationsCatalog: LocationsCatalog;
higherOrderOperation: HigherOrderOperation;
locationAnalyzer: LocationAnalyzer;
}> {
const { config, database, logger } = this.env;
@@ -229,11 +232,13 @@ export class CatalogBuilder {
locationReader,
logger,
);
const locationAnalyzer = new RepoLocationAnalyzer(logger);
return {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
};
}
+21 -4
View File
@@ -15,29 +15,38 @@
*/
import { errorHandler } from '@backstage/backend-common';
import {
locationSpecSchema,
analyzeLocationSchema,
} from '@backstage/catalog-model';
import type { Entity } from '@backstage/catalog-model';
import { locationSpecSchema } from '@backstage/catalog-model';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import yn from 'yn';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion/types';
import { EntityFilters } from './EntityFilters';
import { LocationAnalyzer, HigherOrderOperation } from '../ingestion/types';
import { translateQueryToFieldMapper } from './filterQuery';
import { EntityFilters } from './EntityFilters';
import { requireRequestBody, validateRequestBody } from './util';
export interface RouterOptions {
entitiesCatalog?: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
locationAnalyzer?: LocationAnalyzer;
logger: Logger;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { entitiesCatalog, locationsCatalog, higherOrderOperation } = options;
const {
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
locationAnalyzer,
} = options;
const router = Router();
router.use(express.json());
@@ -127,6 +136,14 @@ export async function createRouter(
});
}
if (locationAnalyzer) {
router.post('/analyze-location', async (req, res) => {
const input = await validateRequestBody(req, analyzeLocationSchema);
const output = await locationAnalyzer.analyzeLocation(input);
res.status(200).send(output);
});
}
router.use(errorHandler());
return router;
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+21
View File
@@ -0,0 +1,21 @@
# Catalog import plugin
Welcome to the catalog-import plugin!
This plugin allows you to bootstrap a component-config YAML file for your repository and open a pull request to add it.
When installed it is accessible on [localhost:3000/catalog-import](localhost:3000/catalog-import).
<img src="./src/assets/catalog-import-screenshot.png" />
## Running
Just run the backstage.
```
yarn start && yarn --cwd packages/backend start
```
## Usage
Pretty straightforward, navigate to [localhost:3000/catalog-import](localhost:3000/catalog-import) and enter your repo's URL.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@backstage/plugin-catalog-import",
"version": "0.2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.3.0",
"@backstage/core": "^0.3.2",
"@backstage/plugin-catalog": "^0.2.0",
"@backstage/plugin-catalog-backend": "^0.2.2",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@octokit/rest": "^18.0.6",
"git-url-parse": "^11.4.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^6.6.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"yaml": "^1.10.0"
},
"devDependencies": {
"@backstage/cli": "^0.3.2",
"@backstage/dev-utils": "^0.1.4",
"@backstage/test-utils": "^0.1.3",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,35 @@
/*
* 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 { createApiRef } from '@backstage/core';
import { PartialEntity } from '../util/types';
export const catalogImportApiRef = createApiRef<CatalogImportApi>({
id: 'plugin.catalogimport.service',
description: 'Used by the catalog import plugin to make requests',
});
export interface CatalogImportApi {
submitPrToRepo(options: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }>;
createRepositoryLocation(options: { location: string }): Promise<void>;
generateEntityDefinitions(options: {
repo: string;
}): Promise<PartialEntity[]>;
}
@@ -0,0 +1,192 @@
/*
* 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 { Octokit } from '@octokit/rest';
import { DiscoveryApi, OAuthApi } from '@backstage/core';
import { CatalogImportApi } from './CatalogImportApi';
import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-backend';
import { PartialEntity } from '../util/types';
export class CatalogImportClient implements CatalogImportApi {
private readonly discoveryApi: DiscoveryApi;
private readonly githubAuthApi: OAuthApi;
constructor(options: {
discoveryApi: DiscoveryApi;
githubAuthApi: OAuthApi;
}) {
this.discoveryApi = options.discoveryApi;
this.githubAuthApi = options.githubAuthApi;
}
async generateEntityDefinitions({
repo,
}: {
repo: string;
}): Promise<PartialEntity[]> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/analyze-location`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
location: { type: 'github', target: repo },
}),
},
).catch(e => {
throw new Error(`Failed to generate entity definitions, ${e.message}`);
});
if (!response.ok) {
throw new Error(
`Failed to generate entity definitions. Received http response ${response.status}: ${response.statusText}`,
);
}
const payload = (await response.json()) as AnalyzeLocationResponse;
return payload.generateEntities.map(x => x.entity);
}
async createRepositoryLocation({
location,
}: {
location: string;
}): Promise<void> {
const response = await fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/locations`,
{
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({
type: 'github',
target: location,
presence: 'optional',
}),
},
);
if (!response.ok) {
throw new Error(
`Received http response ${response.status}: ${response.statusText}`,
);
}
}
async submitPrToRepo({
owner,
repo,
fileContent,
}: {
owner: string;
repo: string;
fileContent: string;
}): Promise<{ link: string; location: string }> {
const token = await this.githubAuthApi.getAccessToken(['repo']);
const octo = new Octokit({
auth: token,
});
const branchName = 'backstage-integration';
const fileName = 'catalog-info.yaml';
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: `Add ${fileName} config file`,
content: btoa(fileContent),
branch: branchName,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a commit with ${fileName} file added`,
e,
),
);
});
const pullRequestRespone = await octo.pulls
.create({
owner,
repo,
title: `Add ${fileName} config file`,
head: branchName,
base: repoData.data.default_branch,
})
.catch(e => {
throw new Error(
formatHttpErrorMessage(
`Couldn't create a pull request for ${branchName} branch`,
e,
),
);
});
return {
link: pullRequestRespone.data.html_url,
location: `https://github.com/${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}`;
}
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export * from './CatalogImportApi';
export * from './CatalogImportClient';
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,197 @@
/*
* 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 React, { useCallback, useState } from 'react';
import {
Button,
CircularProgress,
Grid,
Link,
List,
ListItem,
Typography,
Divider,
} from '@material-ui/core';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import {
errorApiRef,
RouteRef,
StructuredMetadataTable,
useApi,
} from '@backstage/core';
import parseGitUri from 'git-url-parse';
import { PartialEntity } from '../util/types';
import { generatePath, resolvePath } from 'react-router';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Link as RouterLink } from 'react-router-dom';
import * as YAML from 'yaml';
const getEntityCatalogPath = ({
entity,
catalogRouteRef,
}: {
entity: PartialEntity;
catalogRouteRef: RouteRef;
}) => {
const relativeEntityPathInsideCatalog = generatePath(
entityRoute.path,
entityRouteParams(entity as Entity),
);
const resolvedAbsolutePath = resolvePath(
relativeEntityPathInsideCatalog,
catalogRouteRef.path,
)?.pathname;
return resolvedAbsolutePath;
};
type Props = {
nextStep: (options?: { reset: boolean }) => void;
configFile: ConfigSpec;
savePRLink: (PRLink: string) => void;
catalogRouteRef: RouteRef;
};
const ComponentConfigDisplay = ({
nextStep,
configFile,
savePRLink,
catalogRouteRef,
}: Props) => {
const [errorOccured, setErrorOccured] = useState(false);
const [submitting, setSubmitting] = useState(false);
const errorApi = useApi(errorApiRef);
const { submitPrToRepo, addLocation } = useGithubRepos();
const onNext = useCallback(async () => {
try {
setSubmitting(true);
if (!parseGitUri(configFile.location).filepathtype) {
const result = await submitPrToRepo(configFile);
savePRLink(result.link);
setSubmitting(false);
nextStep();
} else {
addLocation(configFile.location);
setSubmitting(false);
nextStep();
}
} catch (e) {
setErrorOccured(true);
setSubmitting(false);
errorApi.post(e);
}
}, [submitPrToRepo, configFile, nextStep, savePRLink, errorApi, addLocation]);
return (
<Grid container direction="column" spacing={1}>
{!parseGitUri(configFile.location).filepathtype ? (
<Typography>
Following config object will be submitted in a pull request to the
repository{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>{' '}
and added as a new location to the backend
</Typography>
) : (
<Typography>
Following config object will be added as a new location to the backend{' '}
<Link
href={configFile.location}
target="_blank"
rel="noopener noreferrer"
>
{configFile.location}
</Link>
</Typography>
)}
<Grid item>
{!parseGitUri(configFile.location).filepathtype ? (
<pre>{YAML.stringify(configFile.config)}</pre>
) : (
<List>
{configFile.config.map((entity: any, index: number) => {
const entityPath = getEntityCatalogPath({
entity,
catalogRouteRef,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < configFile.config.length - 1 && (
<Divider component="li" />
)}
</React.Fragment>
);
})}
</List>
)}
</Grid>
<Grid item container spacing={1}>
{submitting ? (
<Grid item>
<CircularProgress size="2rem" />
</Grid>
) : null}
<Grid item>
<Button
disabled={submitting}
variant="contained"
color="primary"
onClick={onNext}
>
Next
</Button>
{errorOccured ? (
<Button
style={{ marginLeft: '8px' }}
variant="outlined"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Start again
</Button>
) : null}
</Grid>
</Grid>
</Grid>
);
};
export default ComponentConfigDisplay;
@@ -0,0 +1,131 @@
/*
* 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 { errorApiRef, useApi } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import React from 'react';
import { useForm } from 'react-hook-form';
import { useMountedState } from 'react-use';
import parseGitUri from 'git-url-parse';
import { ComponentIdValidators } from '../util/validate';
import { useGithubRepos } from '../util/useGithubRepos';
import { ConfigSpec } from './ImportComponentPage';
import { catalogApiRef } from '@backstage/plugin-catalog';
const useStyles = makeStyles<BackstageTheme>(theme => ({
form: {
alignItems: 'flex-start',
display: 'flex',
flexFlow: 'column nowrap',
},
submit: {
marginTop: theme.spacing(1),
},
}));
type Props = {
nextStep: () => void;
saveConfig: (configFile: ConfigSpec) => void;
};
export const RegisterComponentForm = ({ nextStep, saveConfig }: Props) => {
const { register, handleSubmit, errors, formState } = useForm({
mode: 'onChange',
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.isDirty;
const catalogApi = useApi(catalogApiRef);
const isMounted = useMountedState();
const errorApi = useApi(errorApiRef);
const { generateEntityDefinitions } = useGithubRepos();
const onSubmit = async (formData: Record<string, string>) => {
const { componentLocation: target } = formData;
try {
if (!isMounted()) return;
const type = !parseGitUri(target).filepathtype ? 'repo' : 'file';
if (type === 'repo') {
saveConfig({
type,
location: target,
config: await generateEntityDefinitions(target),
});
} else {
const data = await catalogApi.addLocation({ target });
saveConfig({
type,
location: data.location.target,
config: data.entities,
});
}
nextStep();
} catch (e) {
errorApi.post(e);
}
};
return (
<form
autoComplete="off"
onSubmit={handleSubmit(onSubmit)}
className={classes.form}
>
<FormControl>
<TextField
id="registerComponentInput"
variant="outlined"
label="Repository URL"
error={hasErrors}
placeholder="https://github.com/spotify/backstage"
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the repository in GitHub to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
})}
/>
{errors.componentLocation && (
<FormHelperText error={hasErrors} id="register-component-helper-text">
{errors.componentLocation.message}
</FormHelperText>
)}
</FormControl>
<Button
variant="contained"
color="primary"
type="submit"
disabled={!dirty || hasErrors}
className={classes.submit}
>
Next
</Button>
</form>
);
};
@@ -0,0 +1,111 @@
/*
* 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 React, { useState } from 'react';
import { Grid } from '@material-ui/core';
import {
InfoCard,
Page,
Content,
Header,
SupportButton,
ContentHeader,
RouteRef,
} from '@backstage/core';
import { RegisterComponentForm } from './ImportComponentForm';
import ImportStepper from './ImportStepper';
import ComponentConfigDisplay from './ComponentConfigDisplay';
import { ImportFinished } from './ImportFinished';
import { PartialEntity } from '../util/types';
export type ConfigSpec = {
type: 'repo' | 'file';
location: string;
config: PartialEntity[];
};
export const ImportComponentPage = ({
catalogRouteRef,
}: {
catalogRouteRef: RouteRef;
}) => {
const [activeStep, setActiveStep] = useState(0);
const [configFile, setConfigFile] = useState<ConfigSpec>({
type: 'repo',
location: '',
config: [],
});
const [endLink, setEndLink] = useState<string>('');
const nextStep = (options?: { reset: boolean }) => {
setActiveStep(step => (options?.reset ? 0 : step + 1));
};
return (
<Page themeId="home">
<Header title="Register an existing component" />
<Content>
<ContentHeader title="Start tracking your component on backstage">
<SupportButton>
Start tracking your component in Backstage. TODO: Add more
information about what this is.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard>
<ImportStepper
steps={[
{
step: 'Insert GitHub repo URL or Entity File URL',
content: (
<RegisterComponentForm
nextStep={nextStep}
saveConfig={setConfigFile}
/>
),
},
{
step: 'Review',
content: (
<ComponentConfigDisplay
nextStep={nextStep}
configFile={configFile}
savePRLink={setEndLink}
catalogRouteRef={catalogRouteRef}
/>
),
},
{
step: 'Finish',
content: (
<ImportFinished
nextStep={nextStep}
PRLink={endLink}
type={configFile.type}
/>
),
},
]}
activeStep={activeStep}
nextStep={nextStep}
/>
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -0,0 +1,58 @@
/*
* 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 React from 'react';
import { Alert } from '@material-ui/lab';
import { Button, Grid, Link } from '@material-ui/core';
type Props = {
type: 'repo' | 'file';
nextStep: (options?: { reset: boolean }) => void;
PRLink: string;
};
export const ImportFinished = ({ nextStep, PRLink, type }: Props) => {
return (
<Grid container direction="column" spacing={1}>
<Grid item>
<Alert severity="success">
{type === 'repo'
? 'Pull requests have been successfully opened. You can start again to import more repositories'
: 'Entity added to catalog successfully'}
</Alert>
</Grid>
<Grid item>
{type === 'repo' ? (
<Link
href={PRLink}
style={{ marginRight: '8px' }}
target="_blank"
rel="noopener noreferrer"
>
View pull request on GitHub
</Link>
) : null}
<Button
variant="contained"
color="primary"
onClick={() => nextStep({ reset: true })}
>
Register another
</Button>
</Grid>
</Grid>
);
};
@@ -0,0 +1,42 @@
/*
* 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 React from 'react';
import Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel';
import { StepContent } from '@material-ui/core';
type Props = {
steps: { step: string; content: React.ReactNode }[];
activeStep: number;
nextStep: (options?: { reset: boolean }) => void;
};
export default function ImportStepper({ steps, activeStep }: Props) {
return (
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ step }) => {
const stepProps: { completed?: boolean } = {};
return (
<Step key={step} {...stepProps}>
<StepLabel>{step}</StepLabel>
<StepContent>{steps[activeStep].content}</StepContent>
</Step>
);
})}
</Stepper>
);
}
@@ -0,0 +1,28 @@
/*
* 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 { RouteRef } from '@backstage/core';
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { ImportComponentPage } from './ImportComponentPage';
export const Router = ({ catalogRouteRef }: { catalogRouteRef: RouteRef }) => (
<Routes>
<Route
element={<ImportComponentPage catalogRouteRef={catalogRouteRef} />}
/>
</Routes>
);
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { plugin } from './plugin';
export { Router } from './components/Router';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('catalog-import', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+42
View File
@@ -0,0 +1,42 @@
/*
* 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 {
createApiFactory,
createPlugin,
createRouteRef,
discoveryApiRef,
githubAuthApiRef,
} from '@backstage/core';
import { catalogImportApiRef } from './api/CatalogImportApi';
import { CatalogImportClient } from './api/CatalogImportClient';
export const rootRouteRef = createRouteRef({
path: '',
title: 'catalog-import',
});
export const plugin = createPlugin({
id: 'catalog-import',
apis: [
createApiFactory({
api: catalogImportApiRef,
deps: { discoveryApi: discoveryApiRef, githubAuthApi: githubAuthApiRef },
factory: ({ discoveryApi, githubAuthApi }) =>
new CatalogImportClient({ discoveryApi, githubAuthApi }),
}),
],
});
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 '@testing-library/jest-dom';
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
export type PartialEntity = RecursivePartial<Entity>;
@@ -0,0 +1,57 @@
/*
* 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 * as YAML from 'yaml';
import { useApi } from '@backstage/core';
import { catalogImportApiRef } from '../api/CatalogImportApi';
import { ConfigSpec } from '../components/ImportComponentPage';
export function useGithubRepos() {
const api = useApi(catalogImportApiRef);
const submitPrToRepo = async (selectedRepo: ConfigSpec) => {
const [ownerName, repoName] = selectedRepo.location.split('/').slice(-2);
const submitPRResponse = await api
.submitPrToRepo({
owner: ownerName,
repo: repoName,
fileContent: selectedRepo.config
.map(entity => `---\n${YAML.stringify(entity)}`)
.join('\n'),
})
.catch(e => {
throw new Error(`Failed to submit PR to repo:\n${e.message}`);
});
await api
.createRepositoryLocation({
location: submitPRResponse.location,
})
.catch(e => {
throw new Error(`Failed to create repository location:\n${e.message}`);
});
return submitPRResponse;
};
return {
submitPrToRepo,
generateEntityDefinitions: (repo: string) =>
api.generateEntityDefinitions({ repo }),
addLocation: (location: string) =>
api.createRepositoryLocation({ location }),
};
}
@@ -0,0 +1,33 @@
/*
* 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 { ComponentIdValidators } from './validate';
describe('ComponentIdValidators', () => {
describe('httpsValidator validator', () => {
const errorMessage = 'Must start with https://.';
test.each([
[true, 'https://example.com'],
[errorMessage, 'http://example.com'],
[errorMessage, 'example.com'],
[errorMessage, 'www.example.com'],
[errorMessage, ''],
[errorMessage, undefined],
])('should return %p for %s', (expected: string | boolean, arg: any) => {
expect(ComponentIdValidators.httpsValidator(arg)).toBe(expected);
});
});
});
@@ -0,0 +1,21 @@
/*
* 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.
*/
export const ComponentIdValidators = {
httpsValidator: (value: any) =>
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
};
@@ -83,7 +83,7 @@ export const ScaffolderPage = () => {
variant="contained"
color="primary"
component={RouterLink}
to="/register-component"
to="/catalog-import"
>
Register Existing Component
</Button>
+1 -1
View File
@@ -46,7 +46,7 @@ const getFilesToLint = () => {
.toString()
.split('\n')
.filter(function (el) {
return el != '';
return el !== '';
});
};
+25
View File
@@ -3515,6 +3515,14 @@
"@octokit/types" "^5.4.1"
deprecation "^2.3.1"
"@octokit/plugin-rest-endpoint-methods@4.2.0":
version "4.2.0"
resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.2.0.tgz#c5a0691b3aba5d8b4ef5dffd6af3649608f167ba"
integrity sha512-1/qn1q1C1hGz6W/iEDm9DoyNoG/xdFDt78E3eZ5hHeUfJTLJgyAMdj9chL/cNBHjcjd+FH5aO1x0VCqR2RE0mw==
dependencies:
"@octokit/types" "^5.5.0"
deprecation "^2.3.1"
"@octokit/request-error@^1.0.2":
version "1.2.1"
resolved "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
@@ -3579,6 +3587,16 @@
"@octokit/plugin-request-log" "^1.0.0"
"@octokit/plugin-rest-endpoint-methods" "4.1.4"
"@octokit/rest@^18.0.6":
version "18.0.6"
resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.6.tgz#76c274f1a68f40741a131768ef483f041e7b98b6"
integrity sha512-ES4lZBKPJMX/yUoQjAZiyFjei9pJ4lTTfb9k7OtYoUzKPDLl/M8jiHqt6qeSauyU4eZGLw0sgP1WiQl9FYeM5w==
dependencies:
"@octokit/core" "^3.0.0"
"@octokit/plugin-paginate-rest" "^2.2.0"
"@octokit/plugin-request-log" "^1.0.0"
"@octokit/plugin-rest-endpoint-methods" "4.2.0"
"@octokit/types@^2.0.0", "@octokit/types@^2.0.1":
version "2.5.0"
resolved "https://registry.npmjs.org/@octokit/types/-/types-2.5.0.tgz#f1bbd147e662ae2c79717d518aac686e58257773"
@@ -3600,6 +3618,13 @@
dependencies:
"@types/node" ">= 8"
"@octokit/types@^5.5.0":
version "5.5.0"
resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b"
integrity sha512-UZ1pErDue6bZNjYOotCNveTXArOMZQFG6hKJfOnGnulVCMcVVi7YIIuuR4WfBhjo7zgpmzn/BkPDnUXtNx+PcQ==
dependencies:
"@types/node" ">= 8"
"@open-draft/until@^1.0.3":
version "1.0.3"
resolved "https://registry.npmjs.org/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca"