Merge pull request #10736 from Bonial-International-GmbH/PJ_bitbucket_split

Split bitbucket support into bitbucketCloud and bitbucketServer
This commit is contained in:
Johan Haals
2022-04-21 10:37:31 +02:00
committed by GitHub
50 changed files with 4732 additions and 95 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Add new bitbucket(Cloud/Server) integrations to ScaffolderClient.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Split BitbucketUrlReader into BitbucketCloudUrlReader and BitbucketServerUrlReader. Backwards compatible.
+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
```
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Split `publish:bitbucket` into `publish:bitbucketCloud` and `publish:bitbucketServer`.
In order to migrate from the deprecated action, you need to replace the use of action
`publish:bitbucket` in your templates with the use of either `publish:bitbucketCloud`
or `publish:bitbucketServer` - depending on which destination SCM provider you use.
Additionally, these actions will not utilize `integrations.bitbucket` anymore,
but `integrations.bitbucketCloud` or `integrations.bitbucketServer` respectively.
You may or may not have migrated to these already.
As described in a previous changeset, using these two replacement integrations configs
will not compromise use cases which still rely on `integrations.bitbucket` as this was
set up in a backwards compatible way.
Additionally, please mind that the option `enableLFS` is only available (and always was)
for Bitbucket Server use cases and therefore, is not even part of the schema for
`publish:bitbucketCloud` anymore.
+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`.
+47
View File
@@ -10,7 +10,9 @@ import { AbortController as AbortController_2 } from 'node-abort-controller';
import { AbortSignal as AbortSignal_2 } from 'node-abort-controller';
import { AwsS3Integration } from '@backstage/integration';
import { AzureIntegration } from '@backstage/integration';
import { BitbucketCloudIntegration } from '@backstage/integration';
import { BitbucketIntegration } from '@backstage/integration';
import { BitbucketServerIntegration } from '@backstage/integration';
import { Config } from '@backstage/config';
import cors from 'cors';
import Docker from 'dockerode';
@@ -83,9 +85,54 @@ export class AzureUrlReader implements UrlReader {
}
// @public
export class BitbucketCloudUrlReader implements UrlReader {
constructor(
integration: BitbucketCloudIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export class BitbucketServerUrlReader implements UrlReader {
constructor(
integration: BitbucketServerIntegration,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
);
// (undocumented)
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(url: string, options?: SearchOptions): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public @deprecated
export class BitbucketUrlReader implements UrlReader {
constructor(
integration: BitbucketIntegration,
logger: Logger,
deps: {
treeResponseFactory: ReadTreeResponseFactory;
},
@@ -0,0 +1,362 @@
/*
* 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,
readBitbucketCloudIntegrationConfig,
} from '@backstage/integration';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const reader = new BitbucketCloudUrlReader(
new BitbucketCloudIntegration(
readBitbucketCloudIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
username: 'username',
appPassword: 'password',
}),
),
),
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('BitbucketCloudUrlReader', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
setupRequestMockHandlers(worker);
describe('readUrl', () => {
it('should be able to readUrl without ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBeNull();
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'etag-value'),
);
},
),
);
const result = await reader.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
});
it('should be able to readUrl with matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'matching-etag-value',
);
return res(ctx.status(304));
},
),
);
await expect(
reader.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'matching-etag-value' },
),
).rejects.toThrow(NotModifiedError);
});
it('should be able to readUrl without matching ETag', async () => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage-verification/test-template/src/master/template.yaml',
(req, res, ctx) => {
expect(req.headers.get('If-None-Match')).toBe(
'previous-etag-value',
);
return res(
ctx.status(200),
ctx.body('foo'),
ctx.set('ETag', 'new-etag-value'),
);
},
),
);
const result = await reader.readUrl(
'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml',
{ etag: 'previous-etag-value' },
);
const buffer = await result.buffer();
expect(buffer.toString()).toBe('foo');
expect(result.etag).toBe('new-etag-value');
});
});
describe('read', () => {
it('rejects unknown targets', async () => {
await expect(
reader.read('https://not.bitbucket.com/apa'),
).rejects.toThrow(
'Incorrect URL: https://not.bitbucket.com/apa, Error: Invalid Bitbucket Cloud URL or file path',
);
});
});
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('returns the wanted files from an archive', async () => {
const response = await reader.readTree(
'https://bitbucket.org/backstage/mock/src/master',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(2);
const mkDocsFile = await files[0].content();
const indexMarkdownFile = await files[1].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
});
it('creates a directory with the wanted files', async () => {
const response = await reader.readTree(
'https://bitbucket.org/backstage/mock',
);
const dir = await response.dir({ targetDir: tmpDir });
await expect(
fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'),
).resolves.toBe('site_name: Test\n');
await expect(
fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('returns the wanted files from an archive with a subpath', async () => {
const response = await reader.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('creates a directory with the wanted files with a subpath', async () => {
const response = await reader.readTree(
'https://bitbucket.org/backstage/mock/src/master/docs',
);
const dir = await response.dir({ targetDir: tmpDir });
await expect(
fs.readFile(path.join(dir, 'index.md'), 'utf8'),
).resolves.toBe('# Test\n');
});
it('throws a NotModifiedError when given a etag in options', async () => {
const fnBitbucket = async () => {
await reader.readTree('https://bitbucket.org/backstage/mock', {
etag: '12ab34cd56ef',
});
};
await expect(fnBitbucket).rejects.toThrow(NotModifiedError);
});
it('should not throw a NotModifiedError when given an outdated etag in options', async () => {
const response = await reader.readTree(
'https://bitbucket.org/backstage/mock',
{ etag: 'outdatedetag123abc' },
);
expect(response.etag).toBe('12ab34cd56ef');
});
});
describe('search hosted', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
__dirname,
'__fixtures__/bitbucket-repo-with-commit-hash.tar.gz',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.tar.gz',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.tar.gz',
),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await reader.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await reader.search(
'https://bitbucket.org/backstage/mock/src/master/docs/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
reader.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
});
@@ -0,0 +1,234 @@
/*
* 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 { NotFoundError, NotModifiedError } from '@backstage/errors';
import {
BitbucketCloudIntegration,
getBitbucketCloudDefaultBranch,
getBitbucketCloudDownloadUrl,
getBitbucketCloudFileFetchUrl,
getBitbucketCloudRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import fetch, { Response } from 'node-fetch';
import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseFactory,
ReadUrlOptions,
ReadUrlResponse,
SearchOptions,
SearchResponse,
UrlReader,
} from './types';
/**
* Implements a {@link UrlReader} for files from Bitbucket Cloud.
*
* @public
*/
export class BitbucketCloudUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucketCloud.list().map(integration => {
const reader = new BitbucketCloudUrlReader(integration, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly integration: BitbucketCloudIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
const { host, username, appPassword } = integration.config;
if (username && !appPassword) {
throw new Error(
`Bitbucket Cloud integration for '${host}' has configured a username but is missing a required appPassword.`,
);
}
}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
const { etag, signal } = options ?? {};
const bitbucketUrl = getBitbucketCloudFileFetchUrl(
url,
this.integration.config,
);
const requestOptions = getBitbucketCloudRequestOptions(
this.integration.config,
);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), {
headers: {
...requestOptions.headers,
...(etag && { 'If-None-Match': etag }),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
// removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return {
buffer: async () => Buffer.from(await response.arrayBuffer()),
etag: response.headers.get('ETag') ?? undefined,
};
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const { filepath } = parseGitUrl(url);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketCloudDownloadUrl(
url,
this.integration.config,
);
const archiveResponse = await fetch(
downloadUrl,
getBitbucketCloudRequestOptions(this.integration.config),
);
if (!archiveResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`;
if (archiveResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
stream: archiveResponse.body as unknown as Readable,
subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
const { filepath } = parseGitUrl(url);
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = trimEnd(url.replace(filepath, ''), '/');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(path),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
})),
};
}
toString() {
const { host, username, appPassword } = this.integration.config;
const authed = Boolean(username && appPassword);
return `bitbucketCloud{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<string> {
const { name: repoName, owner: project, ref } = parseGitUrl(url);
let branch = ref;
if (!branch) {
branch = await getBitbucketCloudDefaultBranch(
url,
this.integration.config,
);
}
const commitsApiUrl = `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketCloudRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
if (commitsResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commits = await commitsResponse.json();
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].hash
) {
return commits.values[0].hash.substring(0, 12);
}
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
@@ -0,0 +1,184 @@
/*
* 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,
readBitbucketServerIntegrationConfig,
} from '@backstage/integration';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const reader = new BitbucketServerUrlReader(
new BitbucketServerIntegration(
readBitbucketServerIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('BitbucketServerUrlReader', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
setupRequestMockHandlers(worker);
describe('readTree', () => {
const repoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('uses private bitbucket host', async () => {
const response = await reader.readTree(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch',
);
expect(response.etag).toBe('12ab34cd56ef');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
});
describe('search private', () => {
const repoBuffer = fs.readFileSync(
path.resolve(__dirname, '__fixtures__/bitbucket-server-repo.tar.gz'),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.tgz',
),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await reader.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('works in nested folders', async () => {
const result = await reader.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
reader.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
});
@@ -0,0 +1,218 @@
/*
* 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 { NotFoundError, NotModifiedError } from '@backstage/errors';
import {
BitbucketServerIntegration,
getBitbucketServerDownloadUrl,
getBitbucketServerFileFetchUrl,
getBitbucketServerRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import fetch, { Response } from 'node-fetch';
import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
ReadTreeResponseFactory,
ReadUrlOptions,
ReadUrlResponse,
SearchOptions,
SearchResponse,
UrlReader,
} from './types';
/**
* Implements a {@link UrlReader} for files from Bitbucket Server APIs.
*
* @public
*/
export class BitbucketServerUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucketServer.list().map(integration => {
const reader = new BitbucketServerUrlReader(integration, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly integration: BitbucketServerIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {}
async read(url: string): Promise<Buffer> {
const response = await this.readUrl(url);
return response.buffer();
}
async readUrl(
url: string,
options?: ReadUrlOptions,
): Promise<ReadUrlResponse> {
const { etag, signal } = options ?? {};
const bitbucketUrl = getBitbucketServerFileFetchUrl(
url,
this.integration.config,
);
const requestOptions = getBitbucketServerRequestOptions(
this.integration.config,
);
let response: Response;
try {
response = await fetch(bitbucketUrl.toString(), {
headers: {
...requestOptions.headers,
...(etag && { 'If-None-Match': etag }),
},
// TODO(freben): The signal cast is there because pre-3.x versions of
// node-fetch have a very slightly deviating AbortSignal type signature.
// The difference does not affect us in practice however. The cast can be
// removed after we support ESM for CLI dependencies and migrate to
// version 3 of node-fetch.
// https://github.com/backstage/backstage/issues/8242
...(signal && { signal: signal as any }),
});
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
if (response.status === 304) {
throw new NotModifiedError();
}
if (response.ok) {
return {
buffer: async () => Buffer.from(await response.arrayBuffer()),
etag: response.headers.get('ETag') ?? undefined,
};
}
const message = `${url} could not be read as ${bitbucketUrl}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
async readTree(
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
const { filepath } = parseGitUrl(url);
const lastCommitShortHash = await this.getLastCommitShortHash(url);
if (options?.etag && options.etag === lastCommitShortHash) {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketServerDownloadUrl(
url,
this.integration.config,
);
const archiveResponse = await fetch(
downloadUrl,
getBitbucketServerRequestOptions(this.integration.config),
);
if (!archiveResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`;
if (archiveResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
return await this.deps.treeResponseFactory.fromTarArchive({
stream: archiveResponse.body as unknown as Readable,
subpath: filepath,
etag: lastCommitShortHash,
filter: options?.filter,
});
}
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
const { filepath } = parseGitUrl(url);
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = trimEnd(url.replace(filepath, ''), '/');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(path),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
})),
};
}
toString() {
const { host, token } = this.integration.config;
const authed = Boolean(token);
return `bitbucketServer{host=${host},authed=${authed}}`;
}
private async getLastCommitShortHash(url: string): Promise<string> {
const { name: repoName, owner: project } = parseGitUrl(url);
// Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222
const commitsApiUrl = `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketServerRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
if (commitsResponse.status === 404) {
throw new NotFoundError(message);
}
throw new Error(message);
}
const commits = await commitsResponse.json();
if (
commits &&
commits.values &&
commits.values.length > 0 &&
commits.values[0].id
) {
return commits.values[0].id.substring(0, 12);
}
throw new Error(`Failed to read response from ${commitsApiUrl}`);
}
}
@@ -29,38 +29,76 @@ import path from 'path';
import { NotModifiedError } from '@backstage/errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { DefaultReadTreeResponseFactory } from './tree';
import { getVoidLogger } from '../logging';
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
const logger = getVoidLogger();
describe('BitbucketUrlReader.factory', () => {
it('only apply integration configs not inherited from bitbucketCloud or bitbucketServer', () => {
const config = new ConfigReader({
integrations: {
bitbucket: [],
bitbucketCloud: [
{
username: 'username',
appPassword: 'password',
},
],
bitbucketServer: [
{
host: 'bitbucket-server.local',
token: 'test-token',
},
],
},
});
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: config,
});
const tuples = BitbucketUrlReader.factory({
config,
logger,
treeResponseFactory,
});
expect(tuples).toHaveLength(0);
});
});
const bitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('BitbucketUrlReader', () => {
const treeResponseFactory = DefaultReadTreeResponseFactory.create({
config: new ConfigReader({}),
});
const bitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
logger,
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
@@ -28,6 +28,7 @@ import parseGitUrl from 'git-url-parse';
import { trimEnd } from 'lodash';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { Logger } from 'winston';
import {
ReaderFactory,
ReadTreeOptions,
@@ -45,24 +46,38 @@ import {
* as the one exposed by Bitbucket Cloud itself.
*
* @public
* @deprecated in favor of BitbucketCloudUrlReader and BitbucketServerUrlReader
*/
export class BitbucketUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
static factory: ReaderFactory = ({ config, logger, treeResponseFactory }) => {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucket.list().map(integration => {
const reader = new BitbucketUrlReader(integration, {
treeResponseFactory,
return integrations.bitbucket
.list()
.filter(
item =>
!integrations.bitbucketCloud.byHost(item.config.host) &&
!integrations.bitbucketServer.byHost(item.config.host),
)
.map(integration => {
const reader = new BitbucketUrlReader(integration, logger, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly integration: BitbucketIntegration,
logger: Logger,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
const { host, token, username, appPassword } = integration.config;
const replacement =
host === 'bitbucket.org' ? 'bitbucketCloud' : 'bitbucketServer';
logger.warn(
`[Deprecated] Please migrate from "integrations.bitbucket" to "integrations.${replacement}".`,
);
if (!token && username && !appPassword) {
throw new Error(
@@ -19,6 +19,8 @@ import { Config } from '@backstage/config';
import { ReaderFactory, UrlReader } from './types';
import { UrlReaderPredicateMux } from './UrlReaderPredicateMux';
import { AzureUrlReader } from './AzureUrlReader';
import { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
import { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
import { BitbucketUrlReader } from './BitbucketUrlReader';
import { GerritUrlReader } from './GerritUrlReader';
import { GithubUrlReader } from './GithubUrlReader';
@@ -82,6 +84,8 @@ export class UrlReaders {
config,
factories: factories.concat([
AzureUrlReader.factory,
BitbucketCloudUrlReader.factory,
BitbucketServerUrlReader.factory,
BitbucketUrlReader.factory,
GerritUrlReader.factory,
GithubUrlReader.factory,
@@ -15,7 +15,9 @@
*/
export { AzureUrlReader } from './AzureUrlReader';
export { BitbucketCloudUrlReader } from './BitbucketCloudUrlReader';
export { BitbucketUrlReader } from './BitbucketUrlReader';
export { BitbucketServerUrlReader } from './BitbucketServerUrlReader';
export { GerritUrlReader } from './GerritUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
export { GitlabUrlReader } from './GitlabUrlReader';
@@ -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>;
+28 -1
View File
@@ -198,7 +198,7 @@ export function createPublishAzureAction(options: {
gitAuthorEmail?: string | undefined;
}>;
// @public
// @public @deprecated
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
@@ -215,6 +215,33 @@ export function createPublishBitbucketAction(options: {
gitAuthorEmail?: string | undefined;
}>;
// @public
export function createPublishBitbucketCloudAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<{
repoUrl: string;
description?: string | undefined;
defaultBranch?: string | undefined;
repoVisibility?: 'private' | 'public' | undefined;
sourcePath?: string | undefined;
token?: string | undefined;
}>;
// @public
export function createPublishBitbucketServerAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction<{
repoUrl: string;
description?: string | undefined;
defaultBranch?: string | undefined;
repoVisibility?: 'private' | 'public' | undefined;
sourcePath?: string | undefined;
enableLFS?: boolean | undefined;
token?: string | undefined;
}>;
// @public
export function createPublishFileAction(): TemplateAction<{
path: string;
@@ -37,6 +37,8 @@ import {
import {
createPublishAzureAction,
createPublishBitbucketAction,
createPublishBitbucketCloudAction,
createPublishBitbucketServerAction,
createPublishGithubAction,
createPublishGithubPullRequestAction,
createPublishGitlabAction,
@@ -129,6 +131,14 @@ export const createBuiltinActions = (
integrations,
config,
}),
createPublishBitbucketCloudAction({
integrations,
config,
}),
createPublishBitbucketServerAction({
integrations,
config,
}),
createPublishAzureAction({
integrations,
config,
@@ -198,6 +198,7 @@ const performEnableLFS = async (opts: {
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to Bitbucket.
* @public
* @deprecated in favor of createPublishBitbucketCloudAction and createPublishBitbucketServerAction
*/
export function createPublishBitbucketAction(options: {
integrations: ScmIntegrationRegistry;
@@ -292,6 +293,9 @@ export function createPublishBitbucketAction(options: {
},
},
async handler(ctx) {
ctx.logger.warn(
`[Deprecated] Please migrate the use of action "publish:bitbucket" to "publish:bitbucketCloud" or "publish:bitbucketServer".`,
);
const {
repoUrl,
description,
@@ -0,0 +1,480 @@
/*
* Copyright 2021 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.
*/
jest.mock('../helpers');
import { createPublishBitbucketCloudAction } from './bitbucketCloud';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../helpers';
describe('publish:bitbucketCloud', () => {
const config = new ConfigReader({
integrations: {
bitbucketCloud: [
{
username: 'u',
appPassword: 'p',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishBitbucketCloudAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo',
repoVisibility: 'private' as const,
},
workspacePath: 'wsp',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'bitbucket.org?project=project&repo=repo',
},
}),
).rejects.toThrow(/missing workspace/);
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'bitbucket.org?workspace=workspace&repo=repo',
},
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'bitbucket.org?workspace=workspace&project=project',
},
}),
).rejects.toThrow(/missing repo/);
});
it('should throw if there is no integration config provided', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'missing.com?workspace=workspace&project=project&repo=repo',
},
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('should throw if there is no token in the integration config that is returned', async () => {
const configNoCreds = new ConfigReader({
integrations: {
bitbucketCloud: [],
},
});
const integrationsNoCreds = ScmIntegrations.fromConfig(configNoCreds);
const actionNoCreds = createPublishBitbucketCloudAction({
integrations: integrationsNoCreds,
config: configNoCreds,
});
await expect(actionNoCreds.handler(mockContext)).rejects.toThrow(
/Authorization has not been provided for Bitbucket Cloud/,
);
});
it('should call the correct APIs', async () => {
expect.assertions(2);
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Basic dTpw');
expect(req.body).toEqual({
is_private: true,
scm: 'git',
project: { key: 'project' },
});
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/repo',
},
],
},
}),
);
},
),
);
await action.handler(mockContext);
});
it('should work if the token is provided through ctx.input', async () => {
expect.assertions(2);
const token = 'user-token';
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
expect(req.body).toEqual({
is_private: true,
scm: 'git',
project: { key: 'project' },
});
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
token: token,
},
});
});
it('should call initAndPush with the correct values', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
defaultBranch: 'master',
auth: { username: 'u', password: 'p' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the correct default branch', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
defaultBranch: 'main',
auth: { username: 'u', password: 'p' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucketCloud: [
{
username: 'u',
appPassword: 'p',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createPublishBitbucketCloudAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
auth: { username: 'u', password: 'p' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
it('should call initAndPush with the configured defaultCommitMessage', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucketCloud: [
{
username: 'u',
appPassword: 'p',
},
],
},
scaffolder: {
defaultCommitMessage: 'Test commit message',
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createPublishBitbucketCloudAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.org/workspace/cloneurl',
auth: { username: 'u', password: 'p' },
logger: mockContext.logger,
defaultBranch: 'master',
commitMessage: 'Test commit message',
gitAuthorInfo: { email: undefined, name: undefined },
});
});
it('should call outputs with the correct urls', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.org/workspace/cloneurl',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.org/workspace/repo/src/master',
);
});
it('should call outputs with the correct urls with correct default branch', async () => {
server.use(
rest.post(
'https://api.bitbucket.org/2.0/repositories/workspace/repo',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
html: {
href: 'https://bitbucket.org/workspace/repo',
},
clone: [
{
name: 'https',
href: 'https://bitbucket.org/workspace/cloneurl',
},
],
},
}),
),
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.org/workspace/cloneurl',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.org/workspace/repo/src/main',
);
});
});
@@ -0,0 +1,281 @@
/*
* Copyright 2021 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 { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import fetch, { Response, RequestInit } from 'node-fetch';
import { initRepoAndPush } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createRepository = async (opts: {
workspace: string;
project: string;
repo: string;
description?: string;
repoVisibility: 'private' | 'public';
mainBranch: string;
authorization: string;
apiBaseUrl: string;
}) => {
const {
workspace,
project,
repo,
description,
repoVisibility,
mainBranch,
authorization,
apiBaseUrl,
} = opts;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
scm: 'git',
description: description,
is_private: repoVisibility === 'private',
project: { key: project },
}),
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
let response: Response;
try {
response = await fetch(
`${apiBaseUrl}/repositories/${workspace}/${repo}`,
options,
);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status !== 200) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'https') {
remoteUrl = link.href;
}
}
// "mainbranch.name" cannot be set neither at create nor update of the repo
// the first pushed branch will be set as "main branch" then
const repoContentsUrl = `${r.links.html.href}/src/${mainBranch}`;
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: {
username?: string;
appPassword?: string;
token?: string;
}) => {
if (config.username && config.appPassword) {
const buffer = Buffer.from(
`${config.username}:${config.appPassword}`,
'utf8',
);
return `Basic ${buffer.toString('base64')}`;
}
if (config.token) {
return `Bearer ${config.token}`;
}
throw new Error(
`Authorization has not been provided for Bitbucket Cloud. Please add either username + appPassword to the Integrations config or a user login auth token`,
);
};
/**
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to Bitbucket Cloud.
* @public
*/
export function createPublishBitbucketCloudAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
description?: string;
defaultBranch?: string;
repoVisibility?: 'private' | 'public';
sourcePath?: string;
token?: string;
}>({
id: 'publish:bitbucketCloud',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket Cloud.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
repoVisibility: {
title: 'Repository Visibility',
type: 'string',
enum: ['private', 'public'],
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title: 'Source Path',
description:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description:
'The token to use for authorization to BitBucket Cloud',
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
description,
defaultBranch = 'master',
repoVisibility = 'private',
} = ctx.input;
const { workspace, project, repo, host } = parseRepoUrl(
repoUrl,
integrations,
);
if (!workspace) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`,
);
}
if (!project) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`,
);
}
const integrationConfig = integrations.bitbucketCloud.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const authorization = getAuthorizationHeader(
ctx.input.token ? { token: ctx.input.token } : integrationConfig.config,
);
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
const { remoteUrl, repoContentsUrl } = await createRepository({
authorization,
workspace: workspace || '',
project,
repo,
repoVisibility,
mainBranch: defaultBranch,
description,
apiBaseUrl,
});
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
let auth;
if (ctx.input.token) {
auth = {
username: 'x-token-auth',
password: ctx.input.token,
};
} else {
if (
!integrationConfig.config.username ||
!integrationConfig.config.appPassword
) {
throw new Error(
'Credentials for Bitbucket Cloud integration required for this action.',
);
}
auth = {
username: integrationConfig.config.username,
password: integrationConfig.config.appPassword,
};
}
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
auth,
defaultBranch,
logger: ctx.logger,
commitMessage: config.getOptionalString(
'scaffolder.defaultCommitMessage',
),
gitAuthorInfo,
});
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -0,0 +1,593 @@
/*
* Copyright 2021 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.
*/
jest.mock('../helpers');
import { createPublishBitbucketServerAction } from './bitbucketServer';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
import { initRepoAndPush } from '../helpers';
describe('publish:bitbucketServer', () => {
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishBitbucketServerAction({ integrations, config });
const mockContext = {
input: {
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
repoVisibility: 'private' as const,
},
workspacePath: 'wsp',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?repo=repo',
},
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project',
},
}),
).rejects.toThrow(/missing repo/);
});
it('should throw if there is no integration config provided', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'missing.com?project=project&repo=repo',
},
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('should throw if there is no token in the integration config that is returned', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'notoken.bitbucket.com?project=project&repo=repo',
},
}),
).rejects.toThrow(
/Authorization has not been provided for notoken.bitbucket.com/,
);
});
it('should call the correct APIs', async () => {
expect.assertions(2);
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
},
});
});
it('should work if the token is provided through ctx.input', async () => {
expect.assertions(2);
const token = 'user-token';
server.use(
rest.post(
'https://notoken.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'notoken.bitbucket.com?project=project&repo=repo',
token: token,
},
});
});
describe('LFS for hosted bitbucket', () => {
const repoCreationResponse = {
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
};
it('should call the correct APIs to enable LFS if requested and the host is hosted bitbucket', async () => {
expect.assertions(1);
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(_, res, ctx) => {
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(repoCreationResponse),
);
},
),
rest.put(
'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
return res(ctx.status(204));
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
enableLFS: true,
},
});
});
it('should report an error if enabling LFS fails', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(_, res, ctx) => {
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(repoCreationResponse),
);
},
),
rest.put(
'https://hosted.bitbucket.com/rest/git-lfs/admin/projects/project/repos/repo/enabled',
(_, res, ctx) => {
return res(ctx.status(500));
},
),
);
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
enableLFS: true,
},
}),
).rejects.toThrow(/Failed to enable LFS/);
});
});
it('should call initAndPush with the correct values', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
defaultBranch: 'master',
auth: { username: 'x-token-auth', password: 'thing' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the correct default branch', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
defaultBranch: 'main',
auth: { username: 'x-token-auth', password: 'thing' },
logger: mockContext.logger,
gitAuthorInfo: {},
});
});
it('should call initAndPush with the configured defaultAuthor', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
scaffolder: {
defaultAuthor: {
name: 'Test',
email: 'example@example.com',
},
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createPublishBitbucketServerAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
auth: { username: 'x-token-auth', password: 'thing' },
logger: mockContext.logger,
defaultBranch: 'master',
gitAuthorInfo: { name: 'Test', email: 'example@example.com' },
});
});
it('should call initAndPush with the configured defaultCommitMessage', async () => {
const customAuthorConfig = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'notoken.bitbucket.com',
},
],
},
scaffolder: {
defaultCommitMessage: 'Test commit message',
},
});
const customAuthorIntegrations =
ScmIntegrations.fromConfig(customAuthorConfig);
const customAuthorAction = createPublishBitbucketServerAction({
integrations: customAuthorIntegrations,
config: customAuthorConfig,
});
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await customAuthorAction.handler(mockContext);
expect(initRepoAndPush).toHaveBeenCalledWith({
dir: mockContext.workspacePath,
remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo',
auth: { username: 'x-token-auth', password: 'thing' },
logger: mockContext.logger,
defaultBranch: 'master',
commitMessage: 'Test commit message',
gitAuthorInfo: { email: undefined, name: undefined },
});
});
it('should call outputs with the correct urls', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.mycompany.com/scm/project/repo',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.mycompany.com/projects/project/repos/repo',
);
});
it('should call outputs with the correct urls with correct default branch', async () => {
server.use(
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
expect(req.body).toEqual({ public: false, name: 'repo' });
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json({
links: {
self: [
{
href: 'https://bitbucket.mycompany.com/projects/project/repos/repo',
},
],
clone: [
{
name: 'http',
href: 'https://bitbucket.mycompany.com/scm/project/repo',
},
],
},
}),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
defaultBranch: 'main',
},
});
expect(mockContext.output).toHaveBeenCalledWith(
'remoteUrl',
'https://bitbucket.mycompany.com/scm/project/repo',
);
expect(mockContext.output).toHaveBeenCalledWith(
'repoContentsUrl',
'https://bitbucket.mycompany.com/projects/project/repos/repo',
);
});
});
@@ -0,0 +1,265 @@
/*
* Copyright 2021 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 { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import fetch, { Response, RequestInit } from 'node-fetch';
import { initRepoAndPush } from '../helpers';
import { createTemplateAction } from '../../createTemplateAction';
import { getRepoSourceDirectory, parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createRepository = async (opts: {
project: string;
repo: string;
description?: string;
repoVisibility: 'private' | 'public';
authorization: string;
apiBaseUrl: string;
}) => {
const {
project,
repo,
description,
authorization,
repoVisibility,
apiBaseUrl,
} = opts;
let response: Response;
const options: RequestInit = {
method: 'POST',
body: JSON.stringify({
name: repo,
description: description,
public: repoVisibility === 'public',
}),
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
try {
response = await fetch(`${apiBaseUrl}/projects/${project}/repos`, options);
} catch (e) {
throw new Error(`Unable to create repository, ${e}`);
}
if (response.status !== 201) {
throw new Error(
`Unable to create repository, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
let remoteUrl = '';
for (const link of r.links.clone) {
if (link.name === 'http') {
remoteUrl = link.href;
}
}
const repoContentsUrl = `${r.links.self[0].href}`;
return { remoteUrl, repoContentsUrl };
};
const getAuthorizationHeader = (config: { token: string }) => {
return `Bearer ${config.token}`;
};
const performEnableLFS = async (opts: {
authorization: string;
host: string;
project: string;
repo: string;
}) => {
const { authorization, host, project, repo } = opts;
const options: RequestInit = {
method: 'PUT',
headers: {
Authorization: authorization,
},
};
const { ok, status, statusText } = await fetch(
`https://${host}/rest/git-lfs/admin/projects/${project}/repos/${repo}/enabled`,
options,
);
if (!ok)
throw new Error(
`Failed to enable LFS in the repository, ${status}: ${statusText}`,
);
};
/**
* Creates a new action that initializes a git repository of the content in the workspace
* and publishes it to Bitbucket Server.
* @public
*/
export function createPublishBitbucketServerAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations, config } = options;
return createTemplateAction<{
repoUrl: string;
description?: string;
defaultBranch?: string;
repoVisibility?: 'private' | 'public';
sourcePath?: string;
enableLFS?: boolean;
token?: string;
}>({
id: 'publish:bitbucketServer',
description:
'Initializes a git repository of the content in the workspace, and publishes it to Bitbucket Server.',
schema: {
input: {
type: 'object',
required: ['repoUrl'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
description: {
title: 'Repository Description',
type: 'string',
},
repoVisibility: {
title: 'Repository Visibility',
type: 'string',
enum: ['private', 'public'],
},
defaultBranch: {
title: 'Default Branch',
type: 'string',
description: `Sets the default branch on the repository. The default value is 'master'`,
},
sourcePath: {
title: 'Source Path',
description:
'Path within the workspace that will be used as the repository root. If omitted, the entire workspace will be published as the repository.',
type: 'string',
},
enableLFS: {
title: 'Enable LFS?',
description: 'Enable LFS for the repository.',
type: 'boolean',
},
token: {
title: 'Authentication Token',
type: 'string',
description:
'The token to use for authorization to BitBucket Server',
},
},
},
output: {
type: 'object',
properties: {
remoteUrl: {
title: 'A URL to the repository with the provider',
type: 'string',
},
repoContentsUrl: {
title: 'A URL to the root of the repository',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
description,
defaultBranch = 'master',
repoVisibility = 'private',
enableLFS = false,
} = ctx.input;
const { project, repo, host } = parseRepoUrl(repoUrl, integrations);
if (!project) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`,
);
}
const integrationConfig = integrations.bitbucketServer.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const token = ctx.input.token ?? integrationConfig.config.token;
if (!token) {
throw new Error(
`Authorization has not been provided for ${integrationConfig.config.host}. Please add either token to the Integrations config or a user login auth token`,
);
}
const authorization = getAuthorizationHeader({ token });
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
const { remoteUrl, repoContentsUrl } = await createRepository({
authorization,
project,
repo,
repoVisibility,
description,
apiBaseUrl,
});
const gitAuthorInfo = {
name: config.getOptionalString('scaffolder.defaultAuthor.name'),
email: config.getOptionalString('scaffolder.defaultAuthor.email'),
};
const auth = {
username: 'x-token-auth',
password: token,
};
await initRepoAndPush({
dir: getRepoSourceDirectory(ctx.workspacePath, ctx.input.sourcePath),
remoteUrl,
auth,
defaultBranch,
logger: ctx.logger,
commitMessage: config.getOptionalString(
'scaffolder.defaultCommitMessage',
),
gitAuthorInfo,
});
if (enableLFS) {
await performEnableLFS({ authorization, host, project, repo });
}
ctx.output('remoteUrl', remoteUrl);
ctx.output('repoContentsUrl', repoContentsUrl);
},
});
}
@@ -16,6 +16,8 @@
export { createPublishAzureAction } from './azure';
export { createPublishBitbucketAction } from './bitbucket';
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishBitbucketServerAction } from './bitbucketServer';
export { createPublishFileAction } from './file';
export { createPublishGithubAction } from './github';
export { createPublishGithubPullRequestAction } from './githubPullRequest';
+9 -1
View File
@@ -75,7 +75,15 @@ export class ScaffolderClient implements ScaffolderApi {
): Promise<ScaffolderGetIntegrationsListResponse> {
const integrations = [
...this.scmIntegrationsApi.azure.list(),
...this.scmIntegrationsApi.bitbucket.list(),
...this.scmIntegrationsApi.bitbucket
.list()
.filter(
item =>
!this.scmIntegrationsApi.bitbucketCloud.byHost(item.config.host) &&
!this.scmIntegrationsApi.bitbucketServer.byHost(item.config.host),
),
...this.scmIntegrationsApi.bitbucketCloud.list(),
...this.scmIntegrationsApi.bitbucketServer.list(),
...this.scmIntegrationsApi.github.list(),
...this.scmIntegrationsApi.gitlab.list(),
]