Merge pull request #4702 from SDA-SE/feat/annotations-view-edit-source

Move logic for generating URLs for the view, edit and source links of catalog entities from the catalog frontend into the backend
This commit is contained in:
Oliver Sand
2021-03-08 12:40:19 +01:00
committed by GitHub
28 changed files with 674 additions and 331 deletions
@@ -15,6 +15,8 @@
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
describe('AnnotateLocationEntityProcessor', () => {
@@ -30,14 +32,17 @@ describe('AnnotateLocationEntityProcessor', () => {
const location: LocationSpec = {
type: 'url',
target: 'my-location',
target:
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
};
const originLocation: LocationSpec = {
type: 'url',
target: 'my-origin-location',
target:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
};
const processor = new AnnotateLocationEntityProcessor();
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
@@ -52,8 +57,110 @@ describe('AnnotateLocationEntityProcessor', () => {
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'url:my-location',
'backstage.io/managed-by-origin-location': 'url:my-origin-location',
'backstage.io/managed-by-location':
'url:https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
'backstage.io/view-url':
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/edit-url':
'https://github.com/backstage/backstage/edit/master/packages/app/catalog-info.yaml',
'backstage.io/source-location':
'url:https://github.com/backstage/backstage/tree/master/packages/app/',
},
},
});
});
it('does not override existing annotations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/view-url': 'https://example.com/view',
'backstage.io/edit-url': 'https://example.com/edit',
'backstage.io/source-location': 'url:https://example.com/source',
},
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
};
const originLocation: LocationSpec = {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
};
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
'backstage.io/view-url': 'https://example.com/view',
'backstage.io/edit-url': 'https://example.com/edit',
'backstage.io/source-location': 'url:https://example.com/source',
},
},
});
});
it('does not output view, edit or source location annotations for non url type locations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'file',
target: './test.yaml',
};
const originLocation: LocationSpec = {
type: 'file',
target: './test.yaml',
};
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'file:./test.yaml',
'backstage.io/managed-by-origin-location': 'file:./test.yaml',
},
},
});
@@ -15,31 +15,71 @@
*/
import {
EDIT_URL_ANNOTATION,
Entity,
LocationSpec,
LOCATION_ANNOTATION,
ORIGIN_LOCATION_ANNOTATION,
SOURCE_LOCATION_ANNOTATION,
stringifyLocationReference,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { identity, merge, pickBy } from 'lodash';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
type Options = {
integrations: ScmIntegrationRegistry;
};
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async preProcessEntity(
entity: Entity,
location: LocationSpec,
_: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity> {
return lodash.merge(
const { integrations } = this.options;
let viewUrl;
let editUrl;
let sourceLocation;
if (location.type === 'url') {
const scmIntegration = integrations.byUrl(location.target);
viewUrl = location.target;
editUrl = scmIntegration?.resolveEditUrl(location.target);
const sourceUrl = scmIntegration?.resolveUrl({
url: './',
base: location.target,
});
if (sourceUrl) {
sourceLocation = stringifyLocationReference({
type: 'url',
target: sourceUrl,
});
}
}
return merge(
{
metadata: {
annotations: {
[LOCATION_ANNOTATION]: stringifyLocationReference(location),
[ORIGIN_LOCATION_ANNOTATION]: stringifyLocationReference(
originLocation,
),
},
annotations: pickBy(
{
[LOCATION_ANNOTATION]: stringifyLocationReference(location),
[ORIGIN_LOCATION_ANNOTATION]: stringifyLocationReference(
originLocation,
),
[VIEW_URL_ANNOTATION]: viewUrl,
[EDIT_URL_ANNOTATION]: editUrl,
[SOURCE_LOCATION_ANNOTATION]: sourceLocation,
},
identity,
),
},
},
entity,
@@ -331,7 +331,7 @@ describe('PlaceholderProcessor', () => {
},
),
).rejects.toThrow(
'Placeholder $text could not form an URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
'Placeholder $text could not form a URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
);
expect(read).not.toBeCalled();
@@ -203,7 +203,7 @@ function relativeUrl({ key, value, baseUrl }: ResolverParams): string {
// path traversal attacks and access to any file on the host system. Implementing this
// would require additional security measures.
throw new Error(
`Placeholder \$${key} could not form an URL out of ${baseUrl} and ${value}`,
`Placeholder \$${key} could not form a URL out of ${baseUrl} and ${value}`,
);
}
}
@@ -311,7 +311,7 @@ export class CatalogBuilder {
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader, logger }),
new LocationEntityProcessor({ integrations }),
new AnnotateLocationEntityProcessor(),
new AnnotateLocationEntityProcessor({ integrations }),
);
}
+1
View File
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.6",
"@backstage/catalog-model": "^0.7.3",
"@backstage/core": "^0.7.0",
"@backstage/integration": "^0.5.0",
"@backstage/plugin-catalog-react": "^0.1.1",
"@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
@@ -14,25 +14,74 @@
* limitations under the License.
*/
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
SOURCE_LOCATION_ANNOTATION,
EDIT_URL_ANNOTATION,
} from '@backstage/catalog-model';
import { render, act, fireEvent } from '@testing-library/react';
ApiProvider,
ApiRegistry,
configApiRef,
ConfigReader,
} from '@backstage/core';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { AboutCard } from './AboutCard';
describe('<AboutCard /> GitHub', () => {
it('renders info and "view source" link', async () => {
describe('<AboutCard />', () => {
it('renders info', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
description: 'This is the decription',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
relations: [
{
type: RELATION_OWNED_BY,
target: {
kind: 'user',
name: 'guest',
namespace: 'default',
},
},
],
};
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {},
}),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('user:guest')).toBeInTheDocument();
expect(getByText('production')).toBeInTheDocument();
expect(getByText('This is the decription')).toBeInTheDocument();
});
it('renders "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/backstage/backstage/blob/master/software.yaml',
'backstage.io/source-location':
'url:https://github.com/backstage/backstage/blob/master/software.yaml',
},
},
spec: {
@@ -41,16 +90,71 @@ describe('<AboutCard /> GitHub', () => {
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://github.com/backstage/backstage/blob/master/software.yaml',
);
});
it('renders "edit metadata" button', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/edit-url':
'https://github.com/backstage/backstage/edit/master/software.yaml',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
);
const { getByTitle } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
@@ -62,19 +166,13 @@ describe('<AboutCard /> GitHub', () => {
'_blank',
);
});
});
describe('<AboutCard /> GitLab', () => {
it('renders info and "view source" link', async () => {
it('renders without "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'gitlab:https://gitlab.com/backstage/backstage/-/blob/master/software.yaml',
},
},
spec: {
owner: 'guest',
@@ -82,108 +180,15 @@ describe('<AboutCard /> GitLab', () => {
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
const apis = ApiRegistry.with(configApiRef, new ConfigReader({}));
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://gitlab.com/backstage/backstage/-/blob/master/software.yaml',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(
`https://gitlab.com/backstage/backstage/-/edit/master/software.yaml`,
'_blank',
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
});
});
describe('<AboutCard /> BitBucket', () => {
it('renders info and "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'bitbucket:https://bitbucket.org/backstage/backstage/src/master/software.yaml',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://bitbucket.org/backstage/backstage/src/master/software.yaml',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(
`https://bitbucket.org/backstage/backstage/src/master/software.yaml?mode=edit&spa=0&at=master`,
'_blank',
);
});
});
describe('<AboutCard /> custom links', () => {
it('renders info and "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'bitbucket:https://bitbucket.org/backstage/backstage/src/master/software.yaml',
[EDIT_URL_ANNOTATION]: 'https://another.place',
[SOURCE_LOCATION_ANNOTATION]:
'url:https://another.place/backstage.git',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://another.place/backstage.git',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(`https://another.place`, '_blank');
expect(getByText('View Source').closest('a')).not.toHaveAttribute('href');
});
});
@@ -16,13 +16,17 @@
import {
Entity,
LocationSpec,
ENTITY_DEFAULT_NAMESPACE,
SOURCE_LOCATION_ANNOTATION,
RELATION_CONSUMES_API,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { HeaderIconLinkRow, IconLinkVerticalProps } from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
configApiRef,
HeaderIconLinkRow,
IconLinkVerticalProps,
useApi,
} from '@backstage/core';
import { getEntityRelations, useEntity } from '@backstage/plugin-catalog-react';
import {
Card,
CardContent,
@@ -34,11 +38,10 @@ import {
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import ExtensionIcon from '@material-ui/icons/Extension';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
import { findLocationForEntityMeta, parseLocation } from '../../data/utils';
import { findEditUrl, determineUrlType } from '../actions';
import { getEntityMetadataEditUrl, getEntitySourceLocation } from '../../utils';
import { AboutContent } from './AboutContent';
import { ScmIntegrationIcon } from './ScmIntegrationIcon';
const useStyles = makeStyles({
gridItemCard: {
@@ -52,47 +55,6 @@ const useStyles = makeStyles({
},
});
const iconMap: Record<string, React.ReactNode> = {
github: <GitHubIcon />,
};
type CodeLinkInfo = {
icon?: React.ReactNode;
edithref?: string;
href?: string;
};
function getSourceLocationForEntity(
entity: Entity,
location?: LocationSpec,
): LocationSpec | undefined {
const annotation = entity.metadata?.annotations?.[SOURCE_LOCATION_ANNOTATION];
const parsed = annotation && parseLocation(annotation);
return parsed || location;
}
function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
const location = findLocationForEntityMeta(entity?.metadata);
const editUrl = findEditUrl(entity);
let sourceLocation = getSourceLocationForEntity(entity, location);
if (location) {
sourceLocation = sourceLocation || location;
const type =
sourceLocation.type === 'url'
? determineUrlType(sourceLocation.target)
: sourceLocation.type;
return {
edithref: editUrl,
icon: iconMap[type],
href: sourceLocation.target,
};
}
return { edithref: editUrl, href: sourceLocation?.target };
}
type AboutCardProps = {
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
@@ -102,13 +64,25 @@ type AboutCardProps = {
export function AboutCard({ variant }: AboutCardProps) {
const classes = useStyles();
const { entity } = useEntity();
const codeLink = getCodeLinkInfo(entity);
// TODO: Also support RELATION_CONSUMES_API here
const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
const configApi = useApi(configApiRef);
const entitySourceLocation = getEntitySourceLocation(entity, configApi);
const entityMetadataEditUrl = getEntityMetadataEditUrl(entity);
const providesApiRelations = getEntityRelations(
entity,
RELATION_PROVIDES_API,
);
const consumesApiRelations = getEntityRelations(
entity,
RELATION_CONSUMES_API,
);
const hasApis =
providesApiRelations.length > 0 || consumesApiRelations.length > 0;
const viewInSource: IconLinkVerticalProps = {
label: 'View Source',
href: codeLink.href,
icon: codeLink.icon,
disabled: !entitySourceLocation,
icon: <ScmIntegrationIcon type={entitySourceLocation?.integrationType} />,
href: entitySourceLocation?.locationTargetUrl,
};
const viewInTechDocs: IconLinkVerticalProps = {
label: 'View TechDocs',
@@ -133,9 +107,10 @@ export function AboutCard({ variant }: AboutCardProps) {
action={
<IconButton
aria-label="Edit"
disabled={!entityMetadataEditUrl}
title="Edit Metadata"
onClick={() => {
window.open(codeLink.edithref || '#', '_blank');
window.open(entityMetadataEditUrl ?? '#', '_blank');
}}
>
<EditIcon />
@@ -0,0 +1,31 @@
/*
* 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 CodeIcon from '@material-ui/icons/Code';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
export const ScmIntegrationIcon = ({ type }: { type?: string }) => {
// TODO: In the future we might want to support more types here as a GitLab or
// Bitbucket icons were requested here in the past, or even use the icon
// customization feature of the app. But material UI react doesn't provide more.
switch (type) {
case 'github':
return <GitHubIcon />;
default:
return <CodeIcon />;
}
};
@@ -21,11 +21,11 @@ import {
} from '@backstage/catalog-model';
import {
CodeSnippet,
OverflowTooltip,
Table,
TableColumn,
TableProps,
WarningPanel,
OverflowTooltip,
} from '@backstage/core';
import {
EntityRefLink,
@@ -38,7 +38,10 @@ import { Chip } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import OpenInNew from '@material-ui/icons/OpenInNew';
import React from 'react';
import { findViewUrl, findEditUrl } from '../actions';
import {
getEntityMetadataEditUrl,
getEntityMetadataViewUrl,
} from '../../utils';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
@@ -152,10 +155,11 @@ export const CatalogTable = ({
const actions: TableProps<EntityRow>['actions'] = [
({ entity }) => {
const url = findViewUrl(entity);
const url = getEntityMetadataViewUrl(entity);
return {
icon: () => <OpenInNew fontSize="small" />,
tooltip: 'View',
disabled: !url,
onClick: () => {
if (!url) return;
window.open(url, '_blank');
@@ -163,10 +167,11 @@ export const CatalogTable = ({
};
},
({ entity }) => {
const url = findEditUrl(entity);
const url = getEntityMetadataEditUrl(entity);
return {
icon: () => <Edit fontSize="small" />,
tooltip: 'Edit',
disabled: !url,
onClick: () => {
if (!url) return;
window.open(url, '_blank');
-102
View File
@@ -1,102 +0,0 @@
/*
* 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 {
LocationSpec,
Entity,
EDIT_URL_ANNOTATION,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
import { findLocationForEntityMeta } from '../data/utils';
import parseGitUrl from 'git-url-parse';
/**
* Creates the edit link for components yaml file
* @see LocationSpec
* @param location The LocationSpec being used to determine entity SCM location
* @returns string representing the edit location based on SCM path
*/
export const createEditLink = (location: LocationSpec): string | undefined => {
try {
const urlData = parseGitUrl(location.target);
const url = new URL(location.target);
switch (location.type) {
case 'github':
case 'gitlab':
return location.target.replace('/blob/', '/edit/');
case 'bitbucket':
url.searchParams.set('mode', 'edit');
url.searchParams.set('spa', '0');
url.searchParams.set('at', urlData.ref);
return url.toString();
case 'url':
if (
urlData.source === 'github.com' ||
urlData.source === 'gitlab.com/'
) {
return location.target.replace('/blob/', '/edit/');
} else if (urlData.source === 'bitbucket.org') {
url.searchParams.set('mode', 'edit');
url.searchParams.set('spa', '0');
url.searchParams.set('at', urlData.ref);
return url.toString();
}
return location.target;
default:
return location.target;
}
} catch {
return undefined;
}
};
/**
* Determines type based on passed in url. This is used to set the icon associated with the type of entity
* @param url
* @returns string representing type of icon to be used
*/
export const determineUrlType = (url: string): string => {
const urlData = parseGitUrl(url);
if (urlData.source === 'github.com') {
return 'github';
} else if (urlData.source === 'bitbucket.org') {
return 'bitbucket';
} else if (urlData.source === 'gitlab.com') {
return 'gitlab';
}
return 'url';
};
export const findEditUrl = ({ metadata }: Entity): string | undefined => {
const annotations = metadata.annotations || {};
const editUrl = annotations[EDIT_URL_ANNOTATION];
if (editUrl) return editUrl;
const location = findLocationForEntityMeta(metadata);
return location && createEditLink(location);
};
export const findViewUrl = ({ metadata }: Entity): string | undefined => {
const annotations = metadata.annotations || {};
const location = findLocationForEntityMeta(metadata);
return annotations[VIEW_URL_ANNOTATION] || location?.target;
};
@@ -15,31 +15,15 @@
*/
import {
EntityMeta,
LocationSpec,
LOCATION_ANNOTATION,
parseLocationReference,
EDIT_URL_ANNOTATION,
Entity,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
export function findLocationForEntityMeta(
meta: EntityMeta,
): LocationSpec | undefined {
if (!meta) {
return undefined;
}
const annotation = meta.annotations?.[LOCATION_ANNOTATION];
if (!annotation) {
return undefined;
}
return parseLocation(annotation);
export function getEntityMetadataViewUrl(entity: Entity): string | undefined {
return entity.metadata.annotations?.[VIEW_URL_ANNOTATION];
}
export function parseLocation(reference: string): LocationSpec | undefined {
try {
return parseLocationReference(reference);
} catch {
return undefined;
}
export function getEntityMetadataEditUrl(entity: Entity): string | undefined {
return entity.metadata.annotations?.[EDIT_URL_ANNOTATION];
}
@@ -0,0 +1,53 @@
/*
* 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,
parseLocationReference,
SOURCE_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { ConfigApi } from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
export type EntitySourceLocation = {
locationTargetUrl: string;
integrationType?: string;
};
export function getEntitySourceLocation(
entity: Entity,
config: ConfigApi,
): EntitySourceLocation | undefined {
const sourceLocation =
entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];
if (!sourceLocation) {
return undefined;
}
try {
const sourceLocationRef = parseLocationReference(sourceLocation);
const scmIntegrations = ScmIntegrations.fromConfig(config);
const integration = scmIntegrations.byUrl(sourceLocationRef.target);
return {
locationTargetUrl: sourceLocationRef.target,
integrationType: integration?.type,
};
} catch {
return undefined;
}
}
+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.
*/
export {
getEntityMetadataEditUrl,
getEntityMetadataViewUrl,
} from './getEntityMetadataUrl';
export { getEntitySourceLocation } from './getEntitySourceLocation';