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/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..52822ca76f 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -49,4 +49,34 @@ 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'); + }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index c60ab462c9..722b5a9f0a 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -47,6 +47,13 @@ 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 defaultScmResolveUrl(options).replace('/blob/', '/tree/'); + } + + resolveEditUrl(url: string): string { + return url.replace('/blob/', '/edit/'); } } diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 6faae3a5e7..395957886b 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -43,4 +43,14 @@ 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'); + }); }); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 131825edb5..7e964d19ee 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -49,4 +49,8 @@ export class GitLabIntegration implements ScmIntegration { resolveUrl(options: { url: string; base: string }): string { return defaultScmResolveUrl(options); } + + resolveEditUrl(url: string): string { + return url.replace('/blob/', '/edit/'); + } } 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: {