diff --git a/.changeset/lucky-schools-bathe.md b/.changeset/lucky-schools-bathe.md new file mode 100644 index 0000000000..3134c8c05e --- /dev/null +++ b/.changeset/lucky-schools-bathe.md @@ -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. diff --git a/.changeset/silly-deers-pay.md b/.changeset/silly-deers-pay.md new file mode 100644 index 0000000000..0b7521b5ad --- /dev/null +++ b/.changeset/silly-deers-pay.md @@ -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. diff --git a/app-config.yaml b/app-config.yaml index 97c22090cd..465c7b0049 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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 diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index 44e427a5d0..749af97422 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -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', + ); + }); }); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index dddf6ed5d0..c3de569990 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -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); + } } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 3f9e165b90..122a06cfe3 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -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', + ); + }); }); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 1413b7b343..870c8053e4 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -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; + } } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 3f130a393c..51b49f6374 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -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', + ); + }); }); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index 10b69877e4..0b529f6305 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -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(); + } } diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 9056517f32..3bb4b4d3fc 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -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'); + }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index c60ab462c9..34bebebd9e 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -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}/`; + }, + ); +} diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 6faae3a5e7..5cc6d410e3 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -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'); + }); }); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 131825edb5..2fd9f54ffe 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -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}/`); } diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index c63a323dc3..4aeaa34f7e 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -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 = (options: { diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts index cfd98e9e6a..9c543cdcd4 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.test.ts @@ -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', }, }, }); diff --git a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts index 1d941e0040..f892055a6a 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AnnotateLocationEntityProcessor.ts @@ -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 { - 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, diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts index 00b6af94e5..9884fa1927 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.test.ts @@ -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(); diff --git a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts index b7b3df7b6e..b71e5c1cbb 100644 --- a/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/PlaceholderProcessor.ts @@ -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}`, ); } } diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index a75c7da6e8..126a138de4 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -311,7 +311,7 @@ export class CatalogBuilder { new UrlReaderProcessor({ reader, logger }), new CodeOwnersProcessor({ reader, logger }), new LocationEntityProcessor({ integrations }), - new AnnotateLocationEntityProcessor(), + new AnnotateLocationEntityProcessor({ integrations }), ); } diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index a61bc6fa68..19cb4a7f33 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 57b6b419e0..926deec325 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -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(' GitHub', () => { - it('renders info and "view source" link', async () => { +describe('', () => { + 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( + + + + + , + ); + + 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(' GitHub', () => { lifecycle: 'production', }, }; - const { getByText, getByTitle } = render( - - - , + const apis = ApiRegistry.with( + configApiRef, + new ConfigReader({ + integrations: { + github: [ + { + host: 'github.com', + token: '...', + }, + ], + }, + }), + ); + + const { getByText } = await renderInTestApp( + + + + + , ); - 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( + + + + + , + ); const editButton = getByTitle('Edit Metadata'); window.open = jest.fn(); @@ -62,19 +166,13 @@ describe(' GitHub', () => { '_blank', ); }); -}); -describe(' 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(' GitLab', () => { lifecycle: 'production', }, }; - const { getByText, getByTitle } = render( - - - , - ); + 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( + + + + + , ); - }); -}); - -describe(' 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( - - - , - ); - 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(' 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( - - - , - ); - 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'); }); }); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index ff411f80d4..c8bac651ce 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -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 = { - github: , -}; - -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: , + href: entitySourceLocation?.locationTargetUrl, }; const viewInTechDocs: IconLinkVerticalProps = { label: 'View TechDocs', @@ -133,9 +107,10 @@ export function AboutCard({ variant }: AboutCardProps) { action={ { - window.open(codeLink.edithref || '#', '_blank'); + window.open(entityMetadataEditUrl ?? '#', '_blank'); }} > diff --git a/plugins/catalog/src/components/AboutCard/ScmIntegrationIcon.tsx b/plugins/catalog/src/components/AboutCard/ScmIntegrationIcon.tsx new file mode 100644 index 0000000000..c96401135e --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/ScmIntegrationIcon.tsx @@ -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 ; + default: + return ; + } +}; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 5a8040cd5b..dc80622bbe 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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['actions'] = [ ({ entity }) => { - const url = findViewUrl(entity); + const url = getEntityMetadataViewUrl(entity); return { icon: () => , 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: () => , tooltip: 'Edit', + disabled: !url, onClick: () => { if (!url) return; window.open(url, '_blank'); diff --git a/plugins/catalog/src/components/actions.ts b/plugins/catalog/src/components/actions.ts deleted file mode 100644 index 72e1bf67ec..0000000000 --- a/plugins/catalog/src/components/actions.ts +++ /dev/null @@ -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; -}; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/utils/getEntityMetadataUrl.ts similarity index 54% rename from plugins/catalog/src/data/utils.ts rename to plugins/catalog/src/utils/getEntityMetadataUrl.ts index 60b9144599..a28fdb26d7 100644 --- a/plugins/catalog/src/data/utils.ts +++ b/plugins/catalog/src/utils/getEntityMetadataUrl.ts @@ -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]; } diff --git a/plugins/catalog/src/utils/getEntitySourceLocation.ts b/plugins/catalog/src/utils/getEntitySourceLocation.ts new file mode 100644 index 0000000000..5b957a850a --- /dev/null +++ b/plugins/catalog/src/utils/getEntitySourceLocation.ts @@ -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; + } +} diff --git a/plugins/catalog/src/utils/index.ts b/plugins/catalog/src/utils/index.ts new file mode 100644 index 0000000000..bba42dac7e --- /dev/null +++ b/plugins/catalog/src/utils/index.ts @@ -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';