Merge pull request #10736 from Bonial-International-GmbH/PJ_bitbucket_split
Split bitbucket support into bitbucketCloud and bitbucketServer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Add new bitbucket(Cloud/Server) integrations to ScaffolderClient.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Split BitbucketUrlReader into BitbucketCloudUrlReader and BitbucketServerUrlReader. Backwards compatible.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+37
-1
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+480
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+593
@@ -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';
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user