Move logic for generating URLs for the view, edit and source links of catalog entities from the catalog frontend into the backend

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-02-25 18:39:53 +01:00
parent 905cbfc966
commit 93c62c755c
16 changed files with 433 additions and 325 deletions
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
---
Move logic for generating URLs for the view, edit and source links of catalog
entities from the catalog frontend into the backend. This is done using the
existing support for the `backstage.io/view-url`, `backstage.io/edit-url` and
`backstage.io/source-location` annotations that are now filled by the
`AnnotateLocationEntityProcessor`. If these annotations are missing or empty,
the UI disables the related controls.
+1 -1
View File
@@ -207,7 +207,7 @@ catalog:
# groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
locations:
# Add a location here to ingest it, for example from an URL:
# Add a location here to ingest it, for example from a URL:
#
# - type: url
# target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml
@@ -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':
'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': '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': '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,63 @@
*/
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);
sourceLocation = scmIntegration?.resolveUrl({
url: './',
base: location.target,
});
}
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':
'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,29 +16,27 @@
import {
Entity,
LocationSpec,
ENTITY_DEFAULT_NAMESPACE,
SOURCE_LOCATION_ANNOTATION,
RELATION_PROVIDES_API,
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,
CardHeader,
Divider,
IconButton,
makeStyles,
makeStyles
} from '@material-ui/core';
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 +50,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 +59,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 viewInSource: IconLinkVerticalProps = {
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?.type} />,
href: entitySourceLocation?.url,
};
const viewInTechDocs: IconLinkVerticalProps = {
label: 'View TechDocs',
@@ -133,9 +102,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,44 @@
/*
* 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, SOURCE_LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { ConfigApi } from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
export type EntitySourceLocation = {
url: string;
type?: string;
};
export function getEntitySourceLocation(
entity: Entity,
config: ConfigApi,
): EntitySourceLocation | undefined {
const sourceLocation =
entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];
if (!sourceLocation) {
return undefined;
}
const scmIntegrations = ScmIntegrations.fromConfig(config);
const integration = scmIntegrations.byUrl(sourceLocation);
return {
url: sourceLocation,
type: integration?.type,
};
}
+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';