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 <Patrick.Jungermann@gmail.com>
This commit is contained in:
Patrick Jungermann
2022-04-07 16:16:53 +02:00
parent 7ae5b50b7e
commit 1b4e1e2306
29 changed files with 1890 additions and 58 deletions
+37
View File
@@ -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
```
+35 -22
View File
@@ -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://<host>/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://<host>/rest/api/1.0`.
@@ -53,6 +53,14 @@ export const DevPage = () => {
Bitbucket
</Typography>
<Integrations group={integrations.bitbucket} />
<Typography paragraph variant="h2">
Bitbucket Cloud
</Typography>
<Integrations group={integrations.bitbucketCloud} />
<Typography paragraph variant="h2">
Bitbucket Server
</Typography>
<Integrations group={integrations.bitbucketServer} />
<Typography paragraph variant="h2">
GitHub
</Typography>
@@ -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
});
});
+147 -8
View File
@@ -66,6 +66,35 @@ export type AzureIntegrationConfig = {
};
// @public
export class BitbucketCloudIntegration implements ScmIntegration {
constructor(integrationConfig: BitbucketCloudIntegrationConfig);
// (undocumented)
get config(): BitbucketCloudIntegrationConfig;
// (undocumented)
static factory: ScmIntegrationsFactory<BitbucketCloudIntegration>;
// (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<BitbucketServerIntegration>;
// (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<string>;
// @public
export function getBitbucketCloudDownloadUrl(
url: string,
config: BitbucketCloudIntegrationConfig,
): Promise<string>;
// @public
export function getBitbucketCloudFileFetchUrl(
url: string,
config: BitbucketCloudIntegrationConfig,
): string;
// @public
export function getBitbucketCloudRequestOptions(
config: BitbucketCloudIntegrationConfig,
): {
headers: Record<string, string>;
};
// @public @deprecated
export function getBitbucketDefaultBranch(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string>;
// @public
// @public @deprecated
export function getBitbucketDownloadUrl(
url: string,
config: BitbucketIntegrationConfig,
): Promise<string>;
// @public
// @public @deprecated
export function getBitbucketFileFetchUrl(
url: string,
config: BitbucketIntegrationConfig,
): string;
// @public
// @public @deprecated
export function getBitbucketRequestOptions(
config: BitbucketIntegrationConfig,
): {
headers: Record<string, string>;
};
// @public
export function getBitbucketServerDefaultBranch(
url: string,
config: BitbucketServerIntegrationConfig,
): Promise<string>;
// @public
export function getBitbucketServerDownloadUrl(
url: string,
config: BitbucketServerIntegrationConfig,
): Promise<string>;
// @public
export function getBitbucketServerFileFetchUrl(
url: string,
config: BitbucketServerIntegrationConfig,
): string;
// @public
export function getBitbucketServerRequestOptions(
config: BitbucketServerIntegrationConfig,
): {
headers: Record<string, string>;
};
// @public
export function getGerritFileContentsApiUrl(
config: GerritIntegrationConfig,
@@ -332,9 +439,13 @@ export interface IntegrationsByType {
awsS3: ScmIntegrationsGroup<AwsS3Integration>;
// (undocumented)
azure: ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
// @deprecated (undocumented)
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
// (undocumented)
gerrit: ScmIntegrationsGroup<GerritIntegration>;
// (undocumented)
github: ScmIntegrationsGroup<GitHubIntegration>;
@@ -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<AwsS3Integration>;
// (undocumented)
azure: ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
// @deprecated (undocumented)
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
// (undocumented)
gerrit: ScmIntegrationsGroup<GerritIntegration>;
// (undocumented)
github: ScmIntegrationsGroup<GitHubIntegration>;
@@ -464,9 +599,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
get awsS3(): ScmIntegrationsGroup<AwsS3Integration>;
// (undocumented)
get azure(): ScmIntegrationsGroup<AzureIntegration>;
// (undocumented)
// @deprecated (undocumented)
get bitbucket(): ScmIntegrationsGroup<BitbucketIntegration>;
// (undocumented)
get bitbucketCloud(): ScmIntegrationsGroup<BitbucketCloudIntegration>;
// (undocumented)
get bitbucketServer(): ScmIntegrationsGroup<BitbucketServerIntegration>;
// (undocumented)
byHost(host: string): ScmIntegration | undefined;
// (undocumented)
byUrl(url: string | URL): ScmIntegration | undefined;
+37 -1
View File
@@ -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://<host>/rest/api/1.0
* @visibility frontend
*/
apiBaseUrl?: string;
}>;
/** Integration configuration for Gerrit */
gerrit?: Array<{
/**
@@ -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);
@@ -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<AwsS3Integration>;
azure: ScmIntegrationsGroup<AzureIntegration>;
/**
* @deprecated in favor of `bitbucketCloud` and `bitbucketServer`
*/
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
gerrit: ScmIntegrationsGroup<GerritIntegration>;
github: ScmIntegrationsGroup<GitHubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
@@ -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<BitbucketIntegration> {
return this.byType.bitbucket;
}
get bitbucketCloud(): ScmIntegrationsGroup<BitbucketCloudIntegration> {
return this.byType.bitbucketCloud;
}
get bitbucketServer(): ScmIntegrationsGroup<BitbucketServerIntegration> {
return this.byType.bitbucketServer;
}
get gerrit(): ScmIntegrationsGroup<GerritIntegration> {
return this.byType.gerrit;
}
@@ -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', () => {
@@ -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<BitbucketIntegration> = ({
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)),
@@ -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[],
@@ -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,
@@ -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',
);
});
});
@@ -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<BitbucketCloudIntegration> = ({
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();
}
}
@@ -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<BitbucketCloudIntegrationConfig>): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<BitbucketCloudIntegrationConfig>,
): Promise<Config> {
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<BitbucketCloudIntegrationConfig>[],
): 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',
},
]);
});
});
@@ -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;
}
@@ -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');
});
});
});
@@ -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<string> {
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<string> {
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<string, string> } {
const headers: Record<string, string> = {};
if (config.username && config.appPassword) {
const buffer = Buffer.from(
`${config.username}:${config.appPassword}`,
'utf8',
);
headers.Authorization = `Basic ${buffer.toString('base64')}`;
}
return {
headers,
};
}
@@ -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';
@@ -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',
);
});
});
@@ -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<BitbucketServerIntegration> = ({
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();
}
}
@@ -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<BitbucketServerIntegrationConfig>,
): Config {
return new ConfigReader(data);
}
async function buildFrontendConfig(
data: Partial<BitbucketServerIntegrationConfig>,
): Promise<Config> {
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<BitbucketServerIntegrationConfig>[],
): 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',
},
]);
});
});
@@ -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://<host>/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);
}
@@ -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');
});
});
});
@@ -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<string> {
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<string> {
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<string, string> } {
const headers: Record<string, string> = {};
if (config.token) {
headers.Authorization = `Bearer ${config.token}`;
}
return {
headers,
};
}
@@ -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';
+5 -5
View File
@@ -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<BitbucketIntegration>(
const integrations = basicIntegrations<BitbucketServerIntegration>(
[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<BitbucketIntegration>(
const integrations = basicIntegrations<BitbucketServerIntegration>(
[integration],
i => i.config.host,
);
+3 -1
View File
@@ -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';
+7
View File
@@ -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<ScmIntegration> {
awsS3: ScmIntegrationsGroup<AwsS3Integration>;
azure: ScmIntegrationsGroup<AzureIntegration>;
/**
* @deprecated in favor of `bitbucketCloud` and `bitbucketServer`
*/
bitbucket: ScmIntegrationsGroup<BitbucketIntegration>;
bitbucketCloud: ScmIntegrationsGroup<BitbucketCloudIntegration>;
bitbucketServer: ScmIntegrationsGroup<BitbucketServerIntegration>;
gerrit: ScmIntegrationsGroup<GerritIntegration>;
github: ScmIntegrationsGroup<GitHubIntegration>;
gitlab: ScmIntegrationsGroup<GitLabIntegration>;