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:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Add `resolveEditUrl` to integrations to resolve a URL that can be used to edit
|
||||
a file in the web interfaces of an SCM.
|
||||
@@ -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
@@ -208,7 +208,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
|
||||
|
||||
@@ -88,4 +88,10 @@ describe('ScmIntegrations', () => {
|
||||
}),
|
||||
).toBe('https://absolute.com/path');
|
||||
});
|
||||
|
||||
it('can resolveEditUrl using fallback', () => {
|
||||
expect(i.resolveEditUrl('http://example.com/x/a.yaml')).toBe(
|
||||
'http://example.com/x/a.yaml',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,4 +91,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
|
||||
|
||||
return integration.resolveUrl(options);
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
const integration = this.byUrl(url);
|
||||
if (!integration) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return integration.resolveEditUrl(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,18 @@ describe('AzureIntegration', () => {
|
||||
).toBe('https://dev.azure.com/organization/project/test');
|
||||
});
|
||||
});
|
||||
|
||||
it('resolve edit URL', () => {
|
||||
const integration = new AzureIntegration({ host: 'h.com' } as any);
|
||||
|
||||
// TODO: The Azure integration doesn't support resolving an edit URL yet,
|
||||
// instead we keep the input URL.
|
||||
expect(
|
||||
integration.resolveEditUrl(
|
||||
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
|
||||
),
|
||||
).toBe(
|
||||
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { basicIntegrations } from '../helpers';
|
||||
import { basicIntegrations, isValidUrl } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
|
||||
|
||||
@@ -53,12 +53,8 @@ export class AzureIntegration implements ScmIntegration {
|
||||
const { url, base } = options;
|
||||
|
||||
// If we can parse the url, it is absolute - then return it verbatim
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(url);
|
||||
if (isValidUrl(url)) {
|
||||
return url;
|
||||
} catch {
|
||||
// Ignore intentionally - looks like a relative path
|
||||
}
|
||||
|
||||
const parsed = parseGitUrl(base);
|
||||
@@ -78,4 +74,10 @@ export class AzureIntegration implements ScmIntegration {
|
||||
|
||||
return newUrl.toString();
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
// TODO: Implement edit URL for Azure, fallback to view url as I don't know
|
||||
// how azure works.
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,16 @@ describe('BitbucketIntegration', () => {
|
||||
expect(integration.type).toBe('bitbucket');
|
||||
expect(integration.title).toBe('h.com');
|
||||
});
|
||||
|
||||
it('resolve edit URL', () => {
|
||||
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
|
||||
|
||||
expect(
|
||||
integration.resolveEditUrl(
|
||||
'https://bitbucket.org/my-owner/my-project/src/master/README.md',
|
||||
),
|
||||
).toBe(
|
||||
'https://bitbucket.org/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
|
||||
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
|
||||
import {
|
||||
@@ -51,4 +52,16 @@ export class BitbucketIntegration implements ScmIntegration {
|
||||
resolveUrl(options: { url: string; base: string }): string {
|
||||
return defaultScmResolveUrl(options);
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
const urlData = parseGitUrl(url);
|
||||
const editUrl = new URL(url);
|
||||
|
||||
editUrl.searchParams.set('mode', 'edit');
|
||||
// TODO: Not sure what spa=0 does, at least bitbucket.org doesn't support it
|
||||
// but this is taken over from the initial implementation.
|
||||
editUrl.searchParams.set('spa', '0');
|
||||
editUrl.searchParams.set('at', urlData.ref);
|
||||
return editUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { GitHubIntegration } from './GitHubIntegration';
|
||||
import { GitHubIntegration, replaceUrlType } from './GitHubIntegration';
|
||||
|
||||
describe('GitHubIntegration', () => {
|
||||
it('has a working factory', () => {
|
||||
@@ -49,4 +49,60 @@ describe('GitHubIntegration', () => {
|
||||
expect(integration.title).toBe('h.com');
|
||||
expect(integration.config.host).toBe('h.com');
|
||||
});
|
||||
|
||||
it('resolveUrl', () => {
|
||||
const integration = new GitHubIntegration({ host: 'h.com' });
|
||||
|
||||
expect(
|
||||
integration.resolveUrl({
|
||||
url: '../a.yaml',
|
||||
base:
|
||||
'https://github.com/backstage/backstage/blob/master/test/README.md',
|
||||
}),
|
||||
).toBe('https://github.com/backstage/backstage/tree/master/a.yaml');
|
||||
|
||||
expect(
|
||||
integration.resolveUrl({
|
||||
url: './',
|
||||
base:
|
||||
'https://github.com/backstage/backstage/blob/master/test/README.md',
|
||||
}),
|
||||
).toBe('https://github.com/backstage/backstage/tree/master/test/');
|
||||
});
|
||||
|
||||
it('resolve edit URL', () => {
|
||||
const integration = new GitHubIntegration({ host: 'h.com' });
|
||||
|
||||
expect(
|
||||
integration.resolveEditUrl(
|
||||
'https://github.com/backstage/backstage/blob/master/README.md',
|
||||
),
|
||||
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceUrlType', () => {
|
||||
it('should replace with expected type', () => {
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://github.com/backstage/backstage/blob/master/README.md',
|
||||
'edit',
|
||||
),
|
||||
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://github.com/webmodules/blob/blob/master/test',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://github.com/webmodules/blob/tree/master/test');
|
||||
expect(
|
||||
replaceUrlType('https://github.com/blob/blob/blob/master/test', 'tree'),
|
||||
).toBe('https://github.com/blob/blob/tree/master/test');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://github.com/backstage/backstage/edit/tree/README.md',
|
||||
'blob',
|
||||
),
|
||||
).toBe('https://github.com/backstage/backstage/blob/tree/README.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,25 @@ export class GitHubIntegration implements ScmIntegration {
|
||||
}
|
||||
|
||||
resolveUrl(options: { url: string; base: string }): string {
|
||||
return defaultScmResolveUrl(options);
|
||||
// GitHub uses blob URLs for files and tree urls for directory listings. But
|
||||
// there is a redirect from tree to blob for files, so we can always return
|
||||
// tree urls here.
|
||||
return replaceUrlType(defaultScmResolveUrl(options), 'tree');
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
return replaceUrlType(url, 'edit');
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceUrlType(
|
||||
url: string,
|
||||
type: 'blob' | 'tree' | 'edit',
|
||||
): string {
|
||||
return url.replace(
|
||||
/\/\/([^/]+)\/([^/]+)\/([^/]+)\/(blob|tree|edit)\//,
|
||||
(_, host, owner, repo) => {
|
||||
return `//${host}/${owner}/${repo}/${type}/`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { GitLabIntegration } from './GitLabIntegration';
|
||||
import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
|
||||
|
||||
describe('GitLabIntegration', () => {
|
||||
it('has a working factory', () => {
|
||||
@@ -43,4 +43,43 @@ describe('GitLabIntegration', () => {
|
||||
expect(integration.type).toBe('gitlab');
|
||||
expect(integration.title).toBe('h.com');
|
||||
});
|
||||
|
||||
it('resolve edit URL', () => {
|
||||
const integration = new GitLabIntegration({ host: 'h.com' } as any);
|
||||
|
||||
expect(
|
||||
integration.resolveEditUrl(
|
||||
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
|
||||
),
|
||||
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceUrlType', () => {
|
||||
it('should replace with expected type', () => {
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
|
||||
'edit',
|
||||
),
|
||||
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://gitlab.com/blob/blob/-/blob/develop/test',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
|
||||
expect(
|
||||
replaceUrlType(
|
||||
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
|
||||
'tree',
|
||||
),
|
||||
).toBe('https://gitlab.com/blob/blob/-/tree/develop/README.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,4 +49,15 @@ export class GitLabIntegration implements ScmIntegration {
|
||||
resolveUrl(options: { url: string; base: string }): string {
|
||||
return defaultScmResolveUrl(options);
|
||||
}
|
||||
|
||||
resolveEditUrl(url: string): string {
|
||||
return replaceUrlType(url, 'edit');
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceUrlType(
|
||||
url: string,
|
||||
type: 'blob' | 'tree' | 'edit',
|
||||
): string {
|
||||
return url.replace(/\/\-\/(blob|tree|edit)\//, `/-/${type}/`);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,19 @@ export interface ScmIntegration {
|
||||
* @param options.base The base URL onto which this resolution happens
|
||||
*/
|
||||
resolveUrl(options: { url: string; base: string }): string;
|
||||
|
||||
/**
|
||||
* Resolves the edit URL for a file within the SCM system.
|
||||
*
|
||||
* Most SCM systems have a web interface that allows viewing and editing files
|
||||
* in the repository. The returned URL directly jumps into the edit mode for
|
||||
* the file.
|
||||
* If this is not possible, the integration can fall back to a URL to view
|
||||
* the file in the web interface.
|
||||
*
|
||||
* @param url The absolute URL to the file that should be edited.
|
||||
*/
|
||||
resolveEditUrl(url: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +116,19 @@ export interface ScmIntegrationRegistry
|
||||
* @param options.base The base URL onto which this resolution happens
|
||||
*/
|
||||
resolveUrl(options: { url: string; base: string }): string;
|
||||
|
||||
/**
|
||||
* Resolves the edit URL for a file within the SCM system.
|
||||
*
|
||||
* Most SCM systems have a web interface that allows viewing and editing files
|
||||
* in the repository. The returned URL directly jumps into the edit mode for
|
||||
* the file.
|
||||
* If this is not possible, the integration can fall back to a URL to view
|
||||
* the file in the web interface.
|
||||
*
|
||||
* @param url The absolute URL to the file that should be edited.
|
||||
*/
|
||||
resolveEditUrl(url: string): string;
|
||||
}
|
||||
|
||||
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {
|
||||
|
||||
+112
-5
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+48
-8
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+7
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user