From 1b4e1e23062318ec6d1435a692f33ec843180af3 Mon Sep 17 00:00:00 2001 From: Patrick Jungermann Date: Thu, 7 Apr 2022 16:16:53 +0200 Subject: [PATCH] feat: split integrations.bitbucket -> bitbucketCloud / bitbucketServer Split `integrations.bitbucket` into `integrations.bitbucketCloud` and `integrations.bitbucketServer` while staying backwards compatible for now (== `BitbucketIntegration` loads from the new configs, too, if the old is not used). Relates-to: #9923 Signed-off-by: Patrick Jungermann --- .changeset/moody-suns-smell.md | 37 +++ docs/integrations/bitbucket/locations.md | 57 +++-- packages/integration-react/dev/DevPage.tsx | 8 + .../src/api/ScmIntegrationsApi.test.ts | 2 +- packages/integration/api-report.md | 155 ++++++++++++- packages/integration/config.d.ts | 38 ++- .../integration/src/ScmIntegrations.test.ts | 42 +++- packages/integration/src/ScmIntegrations.ts | 20 ++ .../bitbucket/BitbucketIntegration.test.ts | 63 +++-- .../src/bitbucket/BitbucketIntegration.ts | 10 +- packages/integration/src/bitbucket/config.ts | 3 + packages/integration/src/bitbucket/core.ts | 4 + .../BitbucketCloudIntegration.test.ts | 72 ++++++ .../BitbucketCloudIntegration.ts | 85 +++++++ .../src/bitbucketCloud/config.test.ts | 138 +++++++++++ .../integration/src/bitbucketCloud/config.ts | 98 ++++++++ .../src/bitbucketCloud/core.test.ts | 131 +++++++++++ .../integration/src/bitbucketCloud/core.ts | 140 +++++++++++ .../integration/src/bitbucketCloud/index.ts | 28 +++ .../BitbucketServerIntegration.test.ts | 74 ++++++ .../BitbucketServerIntegration.ts | 89 +++++++ .../src/bitbucketServer/config.test.ts | 148 ++++++++++++ .../integration/src/bitbucketServer/config.ts | 95 ++++++++ .../src/bitbucketServer/core.test.ts | 217 ++++++++++++++++++ .../integration/src/bitbucketServer/core.ts | 145 ++++++++++++ .../integration/src/bitbucketServer/index.ts | 28 +++ packages/integration/src/helpers.test.ts | 10 +- packages/integration/src/index.ts | 4 +- packages/integration/src/registry.ts | 7 + 29 files changed, 1890 insertions(+), 58 deletions(-) create mode 100644 .changeset/moody-suns-smell.md create mode 100644 packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts create mode 100644 packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts create mode 100644 packages/integration/src/bitbucketCloud/config.test.ts create mode 100644 packages/integration/src/bitbucketCloud/config.ts create mode 100644 packages/integration/src/bitbucketCloud/core.test.ts create mode 100644 packages/integration/src/bitbucketCloud/core.ts create mode 100644 packages/integration/src/bitbucketCloud/index.ts create mode 100644 packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts create mode 100644 packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts create mode 100644 packages/integration/src/bitbucketServer/config.test.ts create mode 100644 packages/integration/src/bitbucketServer/config.ts create mode 100644 packages/integration/src/bitbucketServer/core.test.ts create mode 100644 packages/integration/src/bitbucketServer/core.ts create mode 100644 packages/integration/src/bitbucketServer/index.ts diff --git a/.changeset/moody-suns-smell.md b/.changeset/moody-suns-smell.md new file mode 100644 index 0000000000..b051335ad7 --- /dev/null +++ b/.changeset/moody-suns-smell.md @@ -0,0 +1,37 @@ +--- +'@backstage/integration': minor +'@backstage/integration-react': minor +--- + +Split `bitbucket` integration into `bitbucketCloud` and `bitbucketServer` +(backwards compatible). + +In order to migrate to the new integration configs, +move your configs from `integrations.bitbucket` +to `integrations.bitbucketCloud` or `integrations.bitbucketServer`. + +Migration example: + +**Before:** + +```yaml +integrations: + bitbucket: + - host: bitbucket.org + username: bitbucket_user + appPassword: app-password + - host: bitbucket-server.company.com + token: my-token +``` + +**After:** + +```yaml +integrations: + bitbucketCloud: + - username: bitbucket_user + appPassword: app-password + bitbucketServer: + - host: bitbucket-server.company.com + token: my-token +``` diff --git a/docs/integrations/bitbucket/locations.md b/docs/integrations/bitbucket/locations.md index 67c4dcac33..c0c6afc7a3 100644 --- a/docs/integrations/bitbucket/locations.md +++ b/docs/integrations/bitbucket/locations.md @@ -6,38 +6,51 @@ sidebar_label: Locations description: Integrating source code stored in Bitbucket into the Backstage catalog --- -The Bitbucket integration supports loading catalog entities from bitbucket.org -or a self-hosted Bitbucket. Entities can be added to +The Bitbucket integration supports loading catalog entities from bitbucket.org (Bitbucket Cloud) +or Bitbucket Server. Entities can be added to [static catalog configuration](../../features/software-catalog/configuration.md), or registered with the [catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) plugin. +## Bitbucket Cloud + ```yaml integrations: - bitbucket: - - host: bitbucket.org - token: ${BITBUCKET_TOKEN} + bitbucketCloud: + - username: ${BITBUCKET_CLOUD_USERNAME} + appPassword: ${BITBUCKET_CLOUD_PASSWORD} ``` -> Note: A public Bitbucket provider is added automatically at startup for -> convenience, so you only need to list it if you want to supply a -> [token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html). +> Note: A public Bitbucket Cloud provider is added automatically at startup for +> convenience, so you only need to list it if you want to supply credentials. -Directly under the `bitbucket` key is a list of provider configurations, where -you can list the Bitbucket providers you want to fetch data from. Each entry is -a structure with up to four elements: +Directly under the `bitbucketCloud` key is a list of provider configurations, where +you can list the Bitbucket Cloud providers you want to fetch data from. +In the case of Bitbucket Cloud, you will have up to one entry. -- `host`: The host of the Bitbucket instance, e.g. `bitbucket.company.com`. -- `token` (optional): An personal access token as expected by Bitbucket. Either - an access token **or** a username + appPassword may be supplied. -- `username` (optional): The Bitbucket username to use in API requests. If +This one entry will have the following elements: + +- `username`: The Bitbucket Cloud username to use in API requests. If neither a username nor token are supplied, anonymous access will be used. -- `appPassword` (optional): The password for the Bitbucket user. Only needed - when using `username` instead of `token`. -- `apiBaseUrl` (optional): The URL of the Bitbucket API. For self-hosted - installations, it is commonly at `https:///rest/api/1.0`. For - bitbucket.org, this configuration is not needed as it can be inferred. +- `appPassword`: The app password for the Bitbucket Cloud user. -> Note: If you are using Bitbucket server you MUST set the username as well as -> the token or appPassword. +## Bitbucket Server + +```yaml +integrations: + bitbucketServer: + - host: bitbucket.company.com + token: ${BITBUCKET_SERVER_TOKEN} +``` + +Directly under the `bitbucketServer` key is a list of provider configurations, where +you can list the Bitbucket Server providers you want to fetch data from. Each entry is +a structure with the following elements: + +- `host`: The host of the Bitbucket Server instance, e.g. `bitbucket.company.com`. +- `token` (optional): + An [personal access token](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html) + as expected by Bitbucket Server. +- `apiBaseUrl` (optional): The URL of the Bitbucket Server API. For self-hosted + installations, it is commonly at `https:///rest/api/1.0`. diff --git a/packages/integration-react/dev/DevPage.tsx b/packages/integration-react/dev/DevPage.tsx index 07afbcd660..8ff594d80d 100644 --- a/packages/integration-react/dev/DevPage.tsx +++ b/packages/integration-react/dev/DevPage.tsx @@ -53,6 +53,14 @@ export const DevPage = () => { Bitbucket + + Bitbucket Cloud + + + + Bitbucket Server + + GitHub diff --git a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts index aa291c2815..fb53bbdb12 100644 --- a/packages/integration-react/src/api/ScmIntegrationsApi.test.ts +++ b/packages/integration-react/src/api/ScmIntegrationsApi.test.ts @@ -26,6 +26,6 @@ describe('scmIntegrationsApiRef', () => { it('should be instantiated', () => { const i = ScmIntegrationsApi.fromConfig(new ConfigReader({})); - expect(i.list().length).toBe(5); // The default ones + expect(i.list().length).toBe(6); // The default ones }); }); diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 190278c99f..b99a55647c 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -66,6 +66,35 @@ export type AzureIntegrationConfig = { }; // @public +export class BitbucketCloudIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketCloudIntegrationConfig); + // (undocumented) + get config(): BitbucketCloudIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type BitbucketCloudIntegrationConfig = { + host: string; + apiBaseUrl: string; + username?: string; + appPassword?: string; +}; + +// @public @deprecated export class BitbucketIntegration implements ScmIntegration { constructor(integrationConfig: BitbucketIntegrationConfig); // (undocumented) @@ -86,7 +115,7 @@ export class BitbucketIntegration implements ScmIntegration { get type(): string; } -// @public +// @public @deprecated export type BitbucketIntegrationConfig = { host: string; apiBaseUrl: string; @@ -95,6 +124,34 @@ export type BitbucketIntegrationConfig = { appPassword?: string; }; +// @public +export class BitbucketServerIntegration implements ScmIntegration { + constructor(integrationConfig: BitbucketServerIntegrationConfig); + // (undocumented) + get config(): BitbucketServerIntegrationConfig; + // (undocumented) + static factory: ScmIntegrationsFactory; + // (undocumented) + resolveEditUrl(url: string): string; + // (undocumented) + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string; + // (undocumented) + get title(): string; + // (undocumented) + get type(): string; +} + +// @public +export type BitbucketServerIntegrationConfig = { + host: string; + apiBaseUrl: string; + token?: string; +}; + // @public export class DefaultGithubCredentialsProvider implements GithubCredentialsProvider @@ -161,30 +218,80 @@ export function getAzureRequestOptions( }; // @public +export function getBitbucketCloudDefaultBranch( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise; + +// @public +export function getBitbucketCloudDownloadUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise; + +// @public +export function getBitbucketCloudFileFetchUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): string; + +// @public +export function getBitbucketCloudRequestOptions( + config: BitbucketCloudIntegrationConfig, +): { + headers: Record; +}; + +// @public @deprecated export function getBitbucketDefaultBranch( url: string, config: BitbucketIntegrationConfig, ): Promise; -// @public +// @public @deprecated export function getBitbucketDownloadUrl( url: string, config: BitbucketIntegrationConfig, ): Promise; -// @public +// @public @deprecated export function getBitbucketFileFetchUrl( url: string, config: BitbucketIntegrationConfig, ): string; -// @public +// @public @deprecated export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, ): { headers: Record; }; +// @public +export function getBitbucketServerDefaultBranch( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise; + +// @public +export function getBitbucketServerDownloadUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise; + +// @public +export function getBitbucketServerFileFetchUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): string; + +// @public +export function getBitbucketServerRequestOptions( + config: BitbucketServerIntegrationConfig, +): { + headers: Record; +}; + // @public export function getGerritFileContentsApiUrl( config: GerritIntegrationConfig, @@ -332,9 +439,13 @@ export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; // (undocumented) azure: ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) bitbucket: ScmIntegrationsGroup; // (undocumented) + bitbucketCloud: ScmIntegrationsGroup; + // (undocumented) + bitbucketServer: ScmIntegrationsGroup; + // (undocumented) gerrit: ScmIntegrationsGroup; // (undocumented) github: ScmIntegrationsGroup; @@ -366,15 +477,35 @@ export function readAzureIntegrationConfigs( ): AzureIntegrationConfig[]; // @public +export function readBitbucketCloudIntegrationConfig( + config: Config, +): BitbucketCloudIntegrationConfig; + +// @public +export function readBitbucketCloudIntegrationConfigs( + configs: Config[], +): BitbucketCloudIntegrationConfig[]; + +// @public @deprecated export function readBitbucketIntegrationConfig( config: Config, ): BitbucketIntegrationConfig; -// @public +// @public @deprecated export function readBitbucketIntegrationConfigs( configs: Config[], ): BitbucketIntegrationConfig[]; +// @public +export function readBitbucketServerIntegrationConfig( + config: Config, +): BitbucketServerIntegrationConfig; + +// @public +export function readBitbucketServerIntegrationConfigs( + configs: Config[], +): BitbucketServerIntegrationConfig[]; + // @public export function readGerritIntegrationConfig( config: Config, @@ -441,9 +572,13 @@ export interface ScmIntegrationRegistry awsS3: ScmIntegrationsGroup; // (undocumented) azure: ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) bitbucket: ScmIntegrationsGroup; // (undocumented) + bitbucketCloud: ScmIntegrationsGroup; + // (undocumented) + bitbucketServer: ScmIntegrationsGroup; + // (undocumented) gerrit: ScmIntegrationsGroup; // (undocumented) github: ScmIntegrationsGroup; @@ -464,9 +599,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry { get awsS3(): ScmIntegrationsGroup; // (undocumented) get azure(): ScmIntegrationsGroup; - // (undocumented) + // @deprecated (undocumented) get bitbucket(): ScmIntegrationsGroup; // (undocumented) + get bitbucketCloud(): ScmIntegrationsGroup; + // (undocumented) + get bitbucketServer(): ScmIntegrationsGroup; + // (undocumented) byHost(host: string): ScmIntegration | undefined; // (undocumented) byUrl(url: string | URL): ScmIntegration | undefined; diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index e2d26be28e..e4aff81c6f 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -31,7 +31,10 @@ export interface Config { token?: string; }>; - /** Integration configuration for Bitbucket */ + /** + * Integration configuration for Bitbucket + * @deprecated replaced by bitbucketCloud and bitbucketServer + */ bitbucket?: Array<{ /** * The hostname of the given Bitbucket instance @@ -60,6 +63,39 @@ export interface Config { appPassword?: string; }>; + /** Integration configuration for Bitbucket Cloud */ + bitbucketCloud?: Array<{ + /** + * The username to use for authenticated requests. + * @visibility secret + */ + username: string; + /** + * Bitbucket Cloud app password used to authenticate requests. + * @visibility secret + */ + appPassword: string; + }>; + + /** Integration configuration for Bitbucket Server */ + bitbucketServer?: Array<{ + /** + * The hostname of the given Bitbucket Server instance + * @visibility frontend + */ + host: string; + /** + * Token used to authenticate requests. + * @visibility secret + */ + token?: string; + /** + * The base url for the Bitbucket Server API, for example https:///rest/api/1.0 + * @visibility frontend + */ + apiBaseUrl?: string; + }>; + /** Integration configuration for Gerrit */ gerrit?: Array<{ /** diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index 2e6f2f1472..d9fcaf36f0 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -17,8 +17,16 @@ import { AwsS3IntegrationConfig } from './awsS3'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegrationConfig } from './azure'; import { AzureIntegration } from './azure/AzureIntegration'; +import { + BitbucketCloudIntegration, + BitbucketCloudIntegrationConfig, +} from './bitbucketCloud'; import { BitbucketIntegrationConfig } from './bitbucket'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { + BitbucketServerIntegration, + BitbucketServerIntegrationConfig, +} from './bitbucketServer'; import { GerritIntegrationConfig } from './gerrit'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegrationConfig } from './github'; @@ -41,6 +49,14 @@ describe('ScmIntegrations', () => { host: 'bitbucket.local', } as BitbucketIntegrationConfig); + const bitbucketCloud = new BitbucketCloudIntegration({ + host: 'bitbucket.org', + } as BitbucketCloudIntegrationConfig); + + const bitbucketServer = new BitbucketServerIntegration({ + host: 'bitbucket-server.local', + } as BitbucketServerIntegrationConfig); + const gerrit = new GerritIntegration({ host: 'gerrit.local', } as GerritIntegrationConfig); @@ -57,6 +73,11 @@ describe('ScmIntegrations', () => { awsS3: basicIntegrations([awsS3], item => item.config.host), azure: basicIntegrations([azure], item => item.config.host), bitbucket: basicIntegrations([bitbucket], item => item.config.host), + bitbucketCloud: basicIntegrations([bitbucketCloud], item => item.title), + bitbucketServer: basicIntegrations( + [bitbucketServer], + item => item.config.host, + ), gerrit: basicIntegrations([gerrit], item => item.config.host), github: basicIntegrations([github], item => item.config.host), gitlab: basicIntegrations([gitlab], item => item.config.host), @@ -66,6 +87,12 @@ describe('ScmIntegrations', () => { expect(i.awsS3.byUrl('https://awss3.local')).toBe(awsS3); expect(i.azure.byUrl('https://azure.local')).toBe(azure); expect(i.bitbucket.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.bitbucketCloud.byUrl('https://bitbucket.org')).toBe( + bitbucketCloud, + ); + expect(i.bitbucketServer.byUrl('https://bitbucket-server.local')).toBe( + bitbucketServer, + ); expect(i.gerrit.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.github.byUrl('https://github.local')).toBe(github); expect(i.gitlab.byUrl('https://gitlab.local')).toBe(gitlab); @@ -73,7 +100,16 @@ describe('ScmIntegrations', () => { it('can list', () => { expect(i.list()).toEqual( - expect.arrayContaining([awsS3, azure, bitbucket, gerrit, github, gitlab]), + expect.arrayContaining([ + awsS3, + azure, + bitbucket, + bitbucketCloud, + bitbucketServer, + gerrit, + github, + gitlab, + ]), ); }); @@ -81,6 +117,8 @@ describe('ScmIntegrations', () => { expect(i.byUrl('https://awss3.local')).toBe(awsS3); expect(i.byUrl('https://azure.local')).toBe(azure); expect(i.byUrl('https://bitbucket.local')).toBe(bitbucket); + expect(i.byUrl('https://bitbucket.org')).toBe(bitbucketCloud); + expect(i.byUrl('https://bitbucket-server.local')).toBe(bitbucketServer); expect(i.byUrl('https://gerrit.local')).toBe(gerrit); expect(i.byUrl('https://github.local')).toBe(github); expect(i.byUrl('https://gitlab.local')).toBe(gitlab); @@ -88,6 +126,8 @@ describe('ScmIntegrations', () => { expect(i.byHost('awss3.local')).toBe(awsS3); expect(i.byHost('azure.local')).toBe(azure); expect(i.byHost('bitbucket.local')).toBe(bitbucket); + expect(i.byHost('bitbucket.org')).toBe(bitbucketCloud); + expect(i.byHost('bitbucket-server.local')).toBe(bitbucketServer); expect(i.byHost('gerrit.local')).toBe(gerrit); expect(i.byHost('github.local')).toBe(github); expect(i.byHost('gitlab.local')).toBe(gitlab); diff --git a/packages/integration/src/ScmIntegrations.ts b/packages/integration/src/ScmIntegrations.ts index 8638b814ec..3e712cc012 100644 --- a/packages/integration/src/ScmIntegrations.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -17,7 +17,9 @@ import { Config } from '@backstage/config'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; @@ -33,7 +35,12 @@ import { ScmIntegrationRegistry } from './registry'; export interface IntegrationsByType { awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; + /** + * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` + */ bitbucket: ScmIntegrationsGroup; + bitbucketCloud: ScmIntegrationsGroup; + bitbucketServer: ScmIntegrationsGroup; gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup; @@ -52,6 +59,8 @@ export class ScmIntegrations implements ScmIntegrationRegistry { awsS3: AwsS3Integration.factory({ config }), azure: AzureIntegration.factory({ config }), bitbucket: BitbucketIntegration.factory({ config }), + bitbucketCloud: BitbucketCloudIntegration.factory({ config }), + bitbucketServer: BitbucketServerIntegration.factory({ config }), gerrit: GerritIntegration.factory({ config }), github: GitHubIntegration.factory({ config }), gitlab: GitLabIntegration.factory({ config }), @@ -70,10 +79,21 @@ export class ScmIntegrations implements ScmIntegrationRegistry { return this.byType.azure; } + /** + * @deprecated in favor of `bitbucketCloud()` and `bitbucketServer()` + */ get bitbucket(): ScmIntegrationsGroup { return this.byType.bitbucket; } + get bitbucketCloud(): ScmIntegrationsGroup { + return this.byType.bitbucketCloud; + } + + get bitbucketServer(): ScmIntegrationsGroup { + return this.byType.bitbucketServer; + } + get gerrit(): ScmIntegrationsGroup { return this.byType.gerrit; } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 6bbe1be27b..575e1c9b6f 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -18,25 +18,52 @@ import { ConfigReader } from '@backstage/config'; import { BitbucketIntegration } from './BitbucketIntegration'; describe('BitbucketIntegration', () => { - it('has a working factory', () => { - const integrations = BitbucketIntegration.factory({ - config: new ConfigReader({ - integrations: { - bitbucket: [ - { - host: 'h.com', - apiBaseUrl: 'a', - token: 't', - username: 'u', - appPassword: 'p', - }, - ], - }, - }), + describe('factory', () => { + it('works', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucket: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('h.com'); + expect(integrations.list()[1].config.host).toBe('bitbucket.org'); + }); + + it('falls back to bitbucketCloud+bitbucketServer', () => { + const integrations = BitbucketIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + bitbucketServer: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(2); // including default + expect(integrations.list()[0].config.host).toBe('bitbucket.org'); + expect(integrations.list()[1].config.host).toBe('h.com'); }); - expect(integrations.list().length).toBe(2); // including default - expect(integrations.list()[0].config.host).toBe('h.com'); - expect(integrations.list()[1].config.host).toBe('bitbucket.org'); }); it('returns the basics', () => { diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index 0162463e3c..d92532b54a 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -26,13 +26,21 @@ import { * A Bitbucket based integration. * * @public + * @deprecated replaced by the integrations bitbucketCloud and bitbucketServer. */ export class BitbucketIntegration implements ScmIntegration { static factory: ScmIntegrationsFactory = ({ config, }) => { const configs = readBitbucketIntegrationConfigs( - config.getOptionalConfigArray('integrations.bitbucket') ?? [], + config.getOptionalConfigArray('integrations.bitbucket') ?? [ + // if integrations.bitbucket was not used assume the use was migrated to the new configs + // and backport for the deprecated integration to be usable for other parts of the system + // until these got migrated + ...(config.getOptionalConfigArray('integrations.bitbucketCloud') ?? []), + ...(config.getOptionalConfigArray('integrations.bitbucketServer') ?? + []), + ], ); return basicIntegrations( configs.map(c => new BitbucketIntegration(c)), diff --git a/packages/integration/src/bitbucket/config.ts b/packages/integration/src/bitbucket/config.ts index 44a2f0cc1f..11839094da 100644 --- a/packages/integration/src/bitbucket/config.ts +++ b/packages/integration/src/bitbucket/config.ts @@ -25,6 +25,7 @@ const BITBUCKET_API_BASE_URL = 'https://api.bitbucket.org/2.0'; * The configuration parameters for a single Bitbucket API provider. * * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export type BitbucketIntegrationConfig = { /** @@ -68,6 +69,7 @@ export type BitbucketIntegrationConfig = { * * @param config - The config object of a single integration * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function readBitbucketIntegrationConfig( config: Config, @@ -107,6 +109,7 @@ export function readBitbucketIntegrationConfig( * * @param configs - All of the integration config objects * @public + * @deprecated bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function readBitbucketIntegrationConfigs( configs: Config[], diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index da10152bf8..f6018ad27a 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -24,6 +24,7 @@ import { BitbucketIntegrationConfig } from './config'; * @param url - A URL pointing to a path * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export async function getBitbucketDefaultBranch( url: string, @@ -75,6 +76,7 @@ export async function getBitbucketDefaultBranch( * @param url - A URL pointing to a path * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export async function getBitbucketDownloadUrl( url: string, @@ -119,6 +121,7 @@ export async function getBitbucketDownloadUrl( * @param url - A URL pointing to a file * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function getBitbucketFileFetchUrl( url: string, @@ -155,6 +158,7 @@ export function getBitbucketFileFetchUrl( * * @param config - The relevant provider config * @public + * @deprecated no longer in use, bitbucket integration replaced by integrations bitbucketCloud and bitbucketServer. */ export function getBitbucketRequestOptions( config: BitbucketIntegrationConfig, diff --git a/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts new file mode 100644 index 0000000000..4516b78f90 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/config'; +import { BitbucketCloudIntegration } from './BitbucketCloudIntegration'; + +describe('BitbucketCloudIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketCloudIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); + expect(integrations.list()[0].config.username).toBe('u'); + expect(integrations.list()[0].config.appPassword).toBe('p'); + }); + + it('returns the basics', () => { + const integration = new BitbucketCloudIntegration({ + host: 'bitbucket.org', + } as any); + expect(integration.type).toBe('bitbucketCloud'); + expect(integration.title).toBe('bitbucket.org'); + }); + + it('resolves url line number correctly', () => { + const integration = new BitbucketCloudIntegration({} 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#lines-14', + ); + }); + + it('resolve edit URL', () => { + const integration = new BitbucketCloudIntegration({} 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&at=master', + ); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts new file mode 100644 index 0000000000..d05c1fd4a0 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/BitbucketCloudIntegration.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 parseGitUrl from 'git-url-parse'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; + +/** + * A Bitbucket Cloud based integration. + * + * @public + */ +export class BitbucketCloudIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketCloudIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucketCloud') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketCloudIntegration(c)), + i => i.config.host, + ); + }; + + constructor( + private readonly integrationConfig: BitbucketCloudIntegrationConfig, + ) {} + + get type(): string { + return 'bitbucketCloud'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketCloudIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const resolved = defaultScmResolveUrl(options); + + // Bitbucket Cloud line numbers use the syntax #lines-42, rather than #L42 + if (options.lineNumber) { + const url = new URL(resolved); + + url.hash = `lines-${options.lineNumber}`; + return url.toString(); + } + + return resolved; + } + + resolveEditUrl(url: string): string { + const urlData = parseGitUrl(url); + const editUrl = new URL(url); + + editUrl.searchParams.set('mode', 'edit'); + editUrl.searchParams.set('at', urlData.ref); + return editUrl.toString(); + } +} diff --git a/packages/integration/src/bitbucketCloud/config.test.ts b/packages/integration/src/bitbucketCloud/config.test.ts new file mode 100644 index 0000000000..9f7a66e2bf --- /dev/null +++ b/packages/integration/src/bitbucketCloud/config.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + BitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; + +describe('readBitbucketCloudIntegrationConfig', () => { + function buildConfig(data: Partial): Config { + return new ConfigReader(data); + } + + async function buildFrontendConfig( + data: Partial, + ): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { bitbucketCloud: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader(processed[0].data as any); + } + + it('reads all values', () => { + const output = readBitbucketCloudIntegrationConfig( + buildConfig({ + username: 'u', + appPassword: 'p', + }), + ); + expect(output).toEqual({ + apiBaseUrl: 'https://api.bitbucket.org/2.0', + appPassword: 'p', + host: 'bitbucket.org', + username: 'u', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + username: 'u', + appPassword: 'p', + }; + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, username: 7 }), + ), + ).toThrow(/username/); + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ ...valid, appPassword: 7 }), + ), + ).toThrow(/appPassword/); + }); + + it('credentials hidden on the frontend', async () => { + const frontendConfig = await buildFrontendConfig({ + appPassword: 'p', + username: 'u', + }); + expect( + readBitbucketCloudIntegrationConfigs( + frontendConfig.getOptionalConfigArray('integrations.bitbucketCloud') ?? + [], + ), + ).toEqual([ + { + apiBaseUrl: 'https://api.bitbucket.org/2.0', + host: 'bitbucket.org', + }, + ]); + }); +}); + +describe('readBitbucketCloudIntegrationConfigs', () => { + function buildConfig( + data: Partial[], + ): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readBitbucketCloudIntegrationConfigs( + buildConfig([ + { + username: 'u', + appPassword: 'p', + }, + ]), + ); + expect(output).toContainEqual({ + apiBaseUrl: 'https://api.bitbucket.org/2.0', + appPassword: 'p', + host: 'bitbucket.org', + username: 'u', + }); + }); + + it('adds a default Bitbucket Cloud entry when missing', () => { + const output = readBitbucketCloudIntegrationConfigs(buildConfig([])); + expect(output).toEqual([ + { + apiBaseUrl: 'https://api.bitbucket.org/2.0', + host: 'bitbucket.org', + }, + ]); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts new file mode 100644 index 0000000000..710957900e --- /dev/null +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config } from '@backstage/config'; + +const BITBUCKET_CLOUD_HOST = 'bitbucket.org'; +const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0'; + +/** + * The configuration parameters for a single Bitbucket Cloud API provider. + * + * @public + */ +export type BitbucketCloudIntegrationConfig = { + /** + * Constant. bitbucket.org + */ + host: string; + + /** + * Constant. https://api.bitbucket.org/2.0 + */ + apiBaseUrl: string; + + /** + * The username to use for requests to Bitbucket Cloud (bitbucket.org). + */ + username?: string; + + /** + * Authentication with Bitbucket Cloud (bitbucket.org) is done using app passwords. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/ + */ + appPassword?: string; +}; + +/** + * Reads a single Bitbucket Cloud integration config. + * + * @param config - The config object of a single integration + * @public + */ +export function readBitbucketCloudIntegrationConfig( + config: Config, +): BitbucketCloudIntegrationConfig { + const host = BITBUCKET_CLOUD_HOST; + const apiBaseUrl = BITBUCKET_CLOUD_API_BASE_URL; + // If config is provided, we assume authenticated access is desired + // (as the anonymous one is provided by default). + const username = config.getString('username'); + const appPassword = config.getString('appPassword'); + + return { + host, + apiBaseUrl, + username, + appPassword, + }; +} + +/** + * Reads a set of Bitbucket Cloud integration configs, + * and inserts one for public Bitbucket Cloud if none specified. + * + * @param configs - All of the integration config objects + * @public + */ +export function readBitbucketCloudIntegrationConfigs( + configs: Config[], +): BitbucketCloudIntegrationConfig[] { + // First read all the explicit integrations + const result = configs.map(readBitbucketCloudIntegrationConfig); + + // If no explicit bitbucket.org integration was added, + // put one in the list as a convenience + if (result.length === 0) { + result.push({ + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + }); + } + + return result; +} diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts new file mode 100644 index 0000000000..5ce2b74862 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { BitbucketCloudIntegrationConfig } from './config'; +import { + getBitbucketCloudDefaultBranch, + getBitbucketCloudDownloadUrl, + getBitbucketCloudFileFetchUrl, + getBitbucketCloudRequestOptions, +} from './core'; + +describe('bitbucketCloud core', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('getBitbucketCloudRequestOptions', () => { + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + ( + getBitbucketCloudRequestOptions(withUsernameAndPassword) + .headers as any + ).Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + ( + getBitbucketCloudRequestOptions(withoutUsernameAndPassword) + .headers as any + ).Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketCloudFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketCloudIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect(() => getBitbucketCloudFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getBitbucketCloudFileFetchUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, + ), + ).toEqual( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ); + }); + }); + + describe('getBitbucketCloudDownloadUrl', () => { + it('do not add path param for Bitbucket Cloud', async () => { + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const result = await getBitbucketCloudDownloadUrl( + 'https://bitbucket.org/backstage/mock/src/master', + config, + ); + expect(result).toEqual( + 'https://bitbucket.org/backstage/mock/get/master.tar.gz', + ); + }); + }); + + describe('getBitbucketCloudDefaultBranch', () => { + it('return default branch for Bitbucket Cloud', async () => { + const repoInfoResponse = { + mainbranch: { + name: 'main', + }, + }; + worker.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/backstage/mock', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(repoInfoResponse), + ), + ), + ); + const config: BitbucketCloudIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const defaultBranch = await getBitbucketCloudDefaultBranch( + 'https://bitbucket.org/backstage/mock/src/main', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); +}); diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts new file mode 100644 index 0000000000..37feb5dad6 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { BitbucketCloudIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketCloudDefaultBranch( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise { + const { name: repoName, owner: project } = parseGitUrl(url); + + const branchUrl = `${config.apiBaseUrl}/repositories/${project}/${repoName}`; + const response = await fetch( + branchUrl, + getBitbucketCloudRequestOptions(config), + ); + + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + const repoInfo = await response.json(); + const defaultBranch = repoInfo.mainbranch.name; + if (!defaultBranch) { + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketCloudDownloadUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): Promise { + const { + name: repoName, + owner: project, + ref, + protocol, + resource, + } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketCloudDefaultBranch(url, config); + } + return `${protocol}://${resource}/${project}/${repoName}/get/${branch}.tar.gz`; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * @remarks + * + * Converts + * from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + * to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + * + * @param url - A URL pointing to a file + * @param config - The relevant provider config + * @public + */ +export function getBitbucketCloudFileFetchUrl( + url: string, + config: BitbucketCloudIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if (!owner || !name || (filepathtype !== 'src' && filepathtype !== 'raw')) { + throw new Error('Invalid Bitbucket Cloud URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (!ref) { + throw new Error('Invalid Bitbucket Cloud URL or file path'); + } + return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config - The relevant provider config + * @public + */ +export function getBitbucketCloudRequestOptions( + config: BitbucketCloudIntegrationConfig, +): { headers: Record } { + const headers: Record = {}; + + if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucketCloud/index.ts b/packages/integration/src/bitbucketCloud/index.ts new file mode 100644 index 0000000000..7119d3ea90 --- /dev/null +++ b/packages/integration/src/bitbucketCloud/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { BitbucketCloudIntegration } from './BitbucketCloudIntegration'; +export { + readBitbucketCloudIntegrationConfig, + readBitbucketCloudIntegrationConfigs, +} from './config'; +export type { BitbucketCloudIntegrationConfig } from './config'; +export { + getBitbucketCloudDefaultBranch, + getBitbucketCloudDownloadUrl, + getBitbucketCloudFileFetchUrl, + getBitbucketCloudRequestOptions, +} from './core'; diff --git a/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts new file mode 100644 index 0000000000..24d7935e72 --- /dev/null +++ b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { ConfigReader } from '@backstage/config'; +import { BitbucketServerIntegration } from './BitbucketServerIntegration'; + +describe('BitbucketServerIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketServerIntegration.factory({ + config: new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + }, + ], + }, + }), + }); + expect(integrations.list().length).toBe(1); + expect(integrations.list()[0].config.host).toBe('h.com'); + }); + + it('returns the basics', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + expect(integration.type).toBe('bitbucketServer'); + expect(integration.title).toBe('h.com'); + }); + + it('resolves url line number correctly', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + + expect( + integration.resolveUrl({ + url: './a.yaml', + base: 'https://h.com/my-owner/my-project/src/master/README.md', + lineNumber: 14, + }), + ).toBe('https://h.com/my-owner/my-project/src/master/a.yaml#a.yaml-14'); + }); + + it('resolve edit URL', () => { + const integration = new BitbucketServerIntegration({ + host: 'h.com', + } as any); + + expect( + integration.resolveEditUrl( + 'https://h.com/my-owner/my-project/src/master/README.md', + ), + ).toBe( + 'https://h.com/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master', + ); + }); +}); diff --git a/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts new file mode 100644 index 0000000000..10055aeddc --- /dev/null +++ b/packages/integration/src/bitbucketServer/BitbucketServerIntegration.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 parseGitUrl from 'git-url-parse'; +import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; +import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + BitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; + +/** + * A Bitbucket Server based integration. + * + * @public + */ +export class BitbucketServerIntegration implements ScmIntegration { + static factory: ScmIntegrationsFactory = ({ + config, + }) => { + const configs = readBitbucketServerIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucketServer') ?? [], + ); + return basicIntegrations( + configs.map(c => new BitbucketServerIntegration(c)), + i => i.config.host, + ); + }; + + constructor( + private readonly integrationConfig: BitbucketServerIntegrationConfig, + ) {} + + get type(): string { + return 'bitbucketServer'; + } + + get title(): string { + return this.integrationConfig.host; + } + + get config(): BitbucketServerIntegrationConfig { + return this.integrationConfig; + } + + resolveUrl(options: { + url: string; + base: string; + lineNumber?: number; + }): string { + const resolved = defaultScmResolveUrl(options); + + // Bitbucket Server 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 { + 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/bitbucketServer/config.test.ts b/packages/integration/src/bitbucketServer/config.test.ts new file mode 100644 index 0000000000..83c28f5e9d --- /dev/null +++ b/packages/integration/src/bitbucketServer/config.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config, ConfigReader } from '@backstage/config'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { + BitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; + +describe('readBitbucketServerIntegrationConfig', () => { + function buildConfig( + data: Partial, + ): Config { + return new ConfigReader(data); + } + + async function buildFrontendConfig( + data: Partial, + ): Promise { + const fullSchema = await loadConfigSchema({ + dependencies: ['@backstage/integration'], + }); + const serializedSchema = fullSchema.serialize() as { + schemas: { value: { properties?: { integrations?: object } } }[]; + }; + const schema = await loadConfigSchema({ + serialized: { + ...serializedSchema, // only include schemas that apply to integrations + schemas: serializedSchema.schemas.filter( + s => s.value?.properties?.integrations, + ), + }, + }); + const processed = schema.process( + [{ data: { integrations: { bitbucketServer: [data] } }, context: 'app' }], + { visibility: ['frontend'] }, + ); + return new ConfigReader( + (processed[0].data as any).integrations.bitbucketServer[0], + ); + } + + it('reads all values', () => { + const output = readBitbucketServerIntegrationConfig( + buildConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }), + ); + expect(output).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }); + }); + + it('rejects funky configs', () => { + const valid: any = { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }; + expect(() => + readBitbucketServerIntegrationConfig(buildConfig({ ...valid, host: 7 })), + ).toThrow(/host/); + expect(() => + readBitbucketServerIntegrationConfig( + buildConfig({ ...valid, apiBaseUrl: 7 }), + ), + ).toThrow(/apiBaseUrl/); + expect(() => + readBitbucketServerIntegrationConfig(buildConfig({ ...valid, token: 7 })), + ).toThrow(/token/); + }); + + it('works on the frontend', async () => { + expect( + readBitbucketServerIntegrationConfig( + await buildFrontendConfig({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }), + ), + ).toEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + }); + }); +}); + +describe('readBitbucketServerIntegrationConfigs', () => { + function buildConfig( + data: Partial[], + ): Config[] { + return data.map(item => new ConfigReader(item)); + } + + it('reads all values', () => { + const output = readBitbucketServerIntegrationConfigs( + buildConfig([ + { + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }, + ]), + ); + expect(output).toContainEqual({ + host: 'a.com', + apiBaseUrl: 'https://a.com/api', + token: 't', + }); + }); + + it('adds no default Bitbucket Server entry when missing', () => { + const output = readBitbucketServerIntegrationConfigs(buildConfig([])); + expect(output).toEqual([]); + }); + + it('injects the correct Bitbucket Server API base URL when missing', () => { + const output = readBitbucketServerIntegrationConfigs( + buildConfig([{ host: 'bitbucket.company.com' }]), + ); + expect(output).toEqual([ + { + host: 'bitbucket.company.com', + apiBaseUrl: 'https://bitbucket.company.com/rest/api/1.0', + }, + ]); + }); +}); diff --git a/packages/integration/src/bitbucketServer/config.ts b/packages/integration/src/bitbucketServer/config.ts new file mode 100644 index 0000000000..ec7930616c --- /dev/null +++ b/packages/integration/src/bitbucketServer/config.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Config } from '@backstage/config'; +import { trimEnd } from 'lodash'; +import { isValidHost } from '../helpers'; + +/** + * The configuration parameters for a single Bitbucket Server API provider. + * + * @public + */ +export type BitbucketServerIntegrationConfig = { + /** + * The host of the target that this matches on, e.g. "bitbucket.company.com" + */ + host: string; + + /** + * The base URL of the API of this provider, e.g. "https:///rest/api/1.0", + * with no trailing slash. + * + * The API will always be preferred if both its base URL and a token are + * present. + */ + apiBaseUrl: string; + + /** + * The authorization token to use for requests to a Bitbucket Server provider. + * + * See https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html + * + * If no token is specified, anonymous access is used. + */ + token?: string; +}; + +/** + * Reads a single Bitbucket Server integration config. + * + * @param config - The config object of a single integration + * @public + */ +export function readBitbucketServerIntegrationConfig( + config: Config, +): BitbucketServerIntegrationConfig { + const host = config.getString('host'); + let apiBaseUrl = config.getOptionalString('apiBaseUrl'); + const token = config.getOptionalString('token'); + + if (!isValidHost(host)) { + throw new Error( + `Invalid Bitbucket Server integration config, '${host}' is not a valid host`, + ); + } + + if (apiBaseUrl) { + apiBaseUrl = trimEnd(apiBaseUrl, '/'); + } else { + apiBaseUrl = `https://${host}/rest/api/1.0`; + } + + return { + host, + apiBaseUrl, + token, + }; +} + +/** + * Reads a set of Bitbucket Server integration configs. + * + * @param configs - All of the integration config objects + * @public + */ +export function readBitbucketServerIntegrationConfigs( + configs: Config[], +): BitbucketServerIntegrationConfig[] { + // Read all the explicit integrations + // No default integration will be added + return configs.map(readBitbucketServerIntegrationConfig); +} diff --git a/packages/integration/src/bitbucketServer/core.test.ts b/packages/integration/src/bitbucketServer/core.test.ts new file mode 100644 index 0000000000..1c8f5d259c --- /dev/null +++ b/packages/integration/src/bitbucketServer/core.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { BitbucketServerIntegrationConfig } from './config'; +import { + getBitbucketServerDefaultBranch, + getBitbucketServerDownloadUrl, + getBitbucketServerFileFetchUrl, + getBitbucketServerRequestOptions, +} from './core'; + +describe('bitbucketServer core', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + describe('getBitbucketServerRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketServerRequestOptions(withToken).headers as any) + .Authorization, + ).toEqual('Bearer A'); + expect( + (getBitbucketServerRequestOptions(withoutToken).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketServerFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketServerIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect(() => getBitbucketServerFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Server', () => { + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getBitbucketServerFileFetchUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=', + ); + }); + }); + + describe('getBitbucketServerDownloadUrl', () => { + it('add path param if a path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param if no path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse', + config, + ); + + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=main&prefix=backstage-mock', + ); + }); + + it('get by branch for Bitbucket Server', async () => { + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketServerDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', + config, + ); + expect(result).toEqual( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=tgz&at=some-branch&prefix=backstage-mock&path=docs', + ); + }); + }); + + describe('getBitbucketServerDefaultBranch', () => { + it('return default branch for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketServerDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(404), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketServerIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketServerDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); +}); diff --git a/packages/integration/src/bitbucketServer/core.ts b/packages/integration/src/bitbucketServer/core.ts new file mode 100644 index 0000000000..80dbc0443f --- /dev/null +++ b/packages/integration/src/bitbucketServer/core.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 fetch from 'cross-fetch'; +import parseGitUrl from 'git-url-parse'; +import { BitbucketServerIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a path on a provider, returns the default branch. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketServerDefaultBranch( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise { + const { name: repoName, owner: project } = parseGitUrl(url); + + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 + let branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; + + let response = await fetch( + branchUrl, + getBitbucketServerRequestOptions(config), + ); + + if (response.status === 404) { + // First try the new format, and then if it gets specifically a 404 it should try the old format + // (to support old Atlassian Bitbucket Server v5.11.1 format ) + branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + response = await fetch(branchUrl, getBitbucketServerRequestOptions(config)); + } + + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + const { displayId } = await response.json(); + const defaultBranch = displayId; + if (!defaultBranch) { + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); + } + return defaultBranch; +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url - A URL pointing to a path + * @param config - The relevant provider config + * @public + */ +export async function getBitbucketServerDownloadUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): Promise { + const { name: repoName, owner: project, ref, filepath } = parseGitUrl(url); + + let branch = ref; + if (!branch) { + branch = await getBitbucketServerDefaultBranch(url, config); + } + // path will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will download the docs folder and everything below it + const path = filepath ? `&path=${encodeURIComponent(filepath)}` : ''; + return `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=tgz&at=${branch}&prefix=${project}-${repoName}${path}`; +} + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * @remarks + * + * Converts + * from: https://bitbucket.company.com/projectname/reponame/src/main/file.yaml + * to: https://bitbucket.company.com/rest/api/1.0/project/projectname/reponame/raw/file.yaml?at=main + * + * @param url - A URL pointing to a file + * @param config - The relevant provider config + * @public + */ +export function getBitbucketServerFileFetchUrl( + url: string, + config: BitbucketServerIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket Server URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config - The relevant provider config + * @public + */ +export function getBitbucketServerRequestOptions( + config: BitbucketServerIntegrationConfig, +): { headers: Record } { + const headers: Record = {}; + + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucketServer/index.ts b/packages/integration/src/bitbucketServer/index.ts new file mode 100644 index 0000000000..23683ddbff --- /dev/null +++ b/packages/integration/src/bitbucketServer/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { BitbucketServerIntegration } from './BitbucketServerIntegration'; +export { + readBitbucketServerIntegrationConfig, + readBitbucketServerIntegrationConfigs, +} from './config'; +export type { BitbucketServerIntegrationConfig } from './config'; +export { + getBitbucketServerDefaultBranch, + getBitbucketServerDownloadUrl, + getBitbucketServerFileFetchUrl, + getBitbucketServerRequestOptions, +} from './core'; diff --git a/packages/integration/src/helpers.test.ts b/packages/integration/src/helpers.test.ts index 60a2789f05..06feb4104e 100644 --- a/packages/integration/src/helpers.test.ts +++ b/packages/integration/src/helpers.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BitbucketIntegration } from './bitbucket'; +import { BitbucketServerIntegration } from './bitbucketServer'; import { basicIntegrations, defaultScmResolveUrl, @@ -24,11 +24,11 @@ import { describe('basicIntegrations', () => { describe('byUrl', () => { it('handles hosts without a port', () => { - const integration = new BitbucketIntegration({ + const integration = new BitbucketServerIntegration({ host: 'host.com', apiBaseUrl: 'a', }); - const integrations = basicIntegrations( + const integrations = basicIntegrations( [integration], i => i.config.host, ); @@ -36,11 +36,11 @@ describe('basicIntegrations', () => { expect(integrations.byUrl('https://host.com:8080/a')).toBeUndefined(); }); it('handles hosts with a port', () => { - const integration = new BitbucketIntegration({ + const integration = new BitbucketServerIntegration({ host: 'host.com:8080', apiBaseUrl: 'a', }); - const integrations = basicIntegrations( + const integrations = basicIntegrations( [integration], i => i.config.host, ); diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index cf0eeca51a..388b874275 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -20,13 +20,15 @@ * @packageDocumentation */ +export * from './awsS3'; export * from './azure'; export * from './bitbucket'; +export * from './bitbucketCloud'; +export * from './bitbucketServer'; export * from './gerrit'; export * from './github'; export * from './gitlab'; export * from './googleGcs'; -export * from './awsS3'; export { defaultScmResolveUrl } from './helpers'; export { ScmIntegrations } from './ScmIntegrations'; export type { IntegrationsByType } from './ScmIntegrations'; diff --git a/packages/integration/src/registry.ts b/packages/integration/src/registry.ts index 5ab5d049fb..f4f759cfe9 100644 --- a/packages/integration/src/registry.ts +++ b/packages/integration/src/registry.ts @@ -17,7 +17,9 @@ import { ScmIntegration, ScmIntegrationsGroup } from './types'; import { AwsS3Integration } from './awsS3/AwsS3Integration'; import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketCloudIntegration } from './bitbucketCloud/BitbucketCloudIntegration'; import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { BitbucketServerIntegration } from './bitbucketServer/BitbucketServerIntegration'; import { GerritIntegration } from './gerrit/GerritIntegration'; import { GitHubIntegration } from './github/GitHubIntegration'; import { GitLabIntegration } from './gitlab/GitLabIntegration'; @@ -31,7 +33,12 @@ export interface ScmIntegrationRegistry extends ScmIntegrationsGroup { awsS3: ScmIntegrationsGroup; azure: ScmIntegrationsGroup; + /** + * @deprecated in favor of `bitbucketCloud` and `bitbucketServer` + */ bitbucket: ScmIntegrationsGroup; + bitbucketCloud: ScmIntegrationsGroup; + bitbucketServer: ScmIntegrationsGroup; gerrit: ScmIntegrationsGroup; github: ScmIntegrationsGroup; gitlab: ScmIntegrationsGroup;