diff --git a/.changeset/smart-parents-beg.md b/.changeset/smart-parents-beg.md new file mode 100644 index 0000000000..126dee9d0c --- /dev/null +++ b/.changeset/smart-parents-beg.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Add option to `resolveUrl` that allows for linking to a specific line number when resolving a file URL. diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index c3de569990..0cba8e17c9 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -83,7 +83,11 @@ export class ScmIntegrations implements ScmIntegrationRegistry { .find(Boolean); } - resolveUrl(options: { url: string; base: string }): string { + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { const integration = this.byUrl(options.base); if (!integration) { return defaultScmResolveUrl(options); diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index 122a06cfe3..1702602cf3 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -63,9 +63,10 @@ describe('AzureIntegration', () => { url: '/a.yaml', base: 'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml', + lineNumber: 14, }), ).toBe( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml', + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml&line=14&lineEnd=15&lineStartColumn=1&lineEndColumn=1', ); expect( diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 870c8053e4..50f4757620 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -49,7 +49,11 @@ export class AzureIntegration implements ScmIntegration { * * Example base URL: https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml */ - resolveUrl(options: { url: string; base: string }): string { + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { const { url, base } = options; // If we can parse the url, it is absolute - then return it verbatim @@ -72,6 +76,13 @@ export class AzureIntegration implements ScmIntegration { const newUrl = new URL(base); newUrl.searchParams.set('path', updatedPath); + if (options.lineNumber) { + newUrl.searchParams.set('line', String(options.lineNumber)); + newUrl.searchParams.set('lineEnd', String(options.lineNumber + 1)); + newUrl.searchParams.set('lineStartColumn', '1'); + newUrl.searchParams.set('lineEndColumn', '1'); + } + return newUrl.toString(); } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 51b49f6374..350bb1d63c 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -45,6 +45,20 @@ describe('BitbucketIntegration', () => { expect(integration.title).toBe('h.com'); }); + it('resolves url line number correctly', () => { + const integration = new BitbucketIntegration({ host: 'h.com' } as any); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://bitbucket.org/my-owner/my-project/src/master/README.md', + lineNumber: 14, + }), + ).toBe( + 'https://bitbucket.org/my-owner/my-project/src/master/a.yaml#a.yaml-14', + ); + }); + it('resolve edit URL', () => { const integration = new BitbucketIntegration({ host: 'h.com' } as any); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index 0b529f6305..1e07507bf2 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -49,8 +49,23 @@ export class BitbucketIntegration implements ScmIntegration { return this.integrationConfig; } - resolveUrl(options: { url: string; base: string }): string { - return defaultScmResolveUrl(options); + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const resolved = defaultScmResolveUrl(options); + + // Bitbucket line numbers use the syntax #example.txt-42, rather than #L42 + if (options.lineNumber) { + const url = new URL(resolved); + + const filename = url.pathname.split('/').slice(-1)[0]; + url.hash = `${filename}-${options.lineNumber}`; + return url.toString(); + } + + return resolved; } resolveEditUrl(url: string): string { diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 3bb4b4d3fc..501ae6c3bf 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -58,8 +58,9 @@ describe('GitHubIntegration', () => { url: '../a.yaml', base: 'https://github.com/backstage/backstage/blob/master/test/README.md', + lineNumber: 17, }), - ).toBe('https://github.com/backstage/backstage/tree/master/a.yaml'); + ).toBe('https://github.com/backstage/backstage/tree/master/a.yaml#L17'); expect( integration.resolveUrl({ diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index 34bebebd9e..d33057b88f 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -46,7 +46,11 @@ export class GitHubIntegration implements ScmIntegration { return this.integrationConfig; } - resolveUrl(options: { url: string; base: string }): string { + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { // 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. diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 2fd9f54ffe..17fb82f010 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -46,7 +46,11 @@ export class GitLabIntegration implements ScmIntegration { return this.integrationConfig; } - resolveUrl(options: { url: string; base: string }): string { + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { return defaultScmResolveUrl(options); } diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts index 2899f2ed98..155b866850 100644 --- a/packages/integration/src/helpers.test.ts +++ b/packages/integration/src/helpers.test.ts @@ -107,6 +107,52 @@ describe('defaultScmResolveUrl', () => { ); }); + it('works in various situations with line numbers', () => { + expect( + defaultScmResolveUrl({ + url: './b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + lineNumber: 11, + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml?at=master#L11', + ); + + expect( + defaultScmResolveUrl({ + url: 'b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + lineNumber: 12, + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml#L12', + ); + + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml', + lineNumber: 13, + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml#L13', + ); + + expect( + defaultScmResolveUrl({ + url: '/other/b.yaml', + base: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master', + lineNumber: 14, + }), + ).toBe( + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml?at=master#L14', + ); + }); + it('works for full urls and throws away query params', () => { expect( defaultScmResolveUrl({ diff --git a/packages/integration/src/helpers.ts b/packages/integration/src/helpers.ts index c09f808011..bf523d4932 100644 --- a/packages/integration/src/helpers.ts +++ b/packages/integration/src/helpers.ts @@ -60,8 +60,9 @@ export function basicIntegrations( export function defaultScmResolveUrl(options: { url: string; base: string; + lineNumber?: number; }): string { - const { url, base } = options; + const { url, base, lineNumber } = options; // If it is a fully qualified URL - then return it verbatim try { @@ -90,5 +91,8 @@ export function defaultScmResolveUrl(options: { } updated.search = new URL(base).search; + if (lineNumber) { + updated.hash = `L${lineNumber}`; + } return updated.toString(); } diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index 4aeaa34f7e..f06bda285a 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -49,8 +49,13 @@ export interface ScmIntegration { * * @param options.url The (absolute or relative) URL or path to resolve * @param options.base The base URL onto which this resolution happens + * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. */ - resolveUrl(options: { url: string; base: string }): string; + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; /** * Resolves the edit URL for a file within the SCM system. @@ -114,8 +119,13 @@ export interface ScmIntegrationRegistry * * @param options.url The (absolute or relative) URL or path to resolve * @param options.base The base URL onto which this resolution happens + * @param options.lineNumber The line number in the target file to link to, starting with 1. Only applicable when linking to files. */ - resolveUrl(options: { url: string; base: string }): string; + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; /** * Resolves the edit URL for a file within the SCM system.