From bbd0e354ffe4fa65b50a450ae7f2828c62b8befc Mon Sep 17 00:00:00 2001 From: "Kim S. Ly" Date: Tue, 15 Dec 2020 00:48:03 -0500 Subject: [PATCH] Implement readTree on BitBucketUrlReader - Add getBitbucketDefaultBranch --- .../src/reading/BitbucketUrlReader.test.ts | 15 +-- .../src/reading/BitbucketUrlReader.ts | 28 ++-- packages/integration/package.json | 1 + .../integration/src/bitbucket/core.test.ts | 123 ++++++++++++++++-- packages/integration/src/bitbucket/core.ts | 51 +++++++- packages/integration/src/bitbucket/index.ts | 1 + 6 files changed, 177 insertions(+), 42 deletions(-) diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 03d85e813c..e327770114 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -127,7 +127,7 @@ describe('BitbucketUrlReader', () => { ); const response = await processor.readTree( - 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', ); const files = await response.files(); @@ -138,19 +138,6 @@ describe('BitbucketUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); - it('must specify a branch', async () => { - const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, - { treeResponseFactory }, - ); - - await expect( - processor.readTree('https://bitbucket.org/backstage/mock'), - ).rejects.toThrow( - 'Bitbucket URL must contain branch to be able to fetch tree', - ); - }); - it('returns the wanted files from an archive with a subpath', async () => { worker.use( rest.get( diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index c92174f1e4..4367424423 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -16,6 +16,7 @@ import { BitbucketIntegrationConfig, + getBitbucketDefaultBranch, getBitbucketDownloadUrl, getBitbucketFileFetchUrl, getBitbucketRequestOptions, @@ -24,7 +25,7 @@ import { import fetch from 'cross-fetch'; import parseGitUri from 'git-url-parse'; import { Readable } from 'stream'; -import { InputError, NotFoundError } from '../errors'; +import { NotFoundError } from '../errors'; import { ReadTreeResponseFactory } from './tree'; import { ReaderFactory, @@ -101,19 +102,13 @@ export class BitbucketUrlReader implements UrlReader { options?: ReadTreeOptions, ): Promise { const gitUrl: parseGitUri.GitUrl = parseGitUri(url); - const { name: repoName, owner: project, ref, resource, filepath } = gitUrl; + const { name: repoName, owner: project, resource, filepath } = gitUrl; const isHosted = resource === 'bitbucket.org'; - if (isHosted && !ref) { - // TODO(freben): We should add support for defaulting to the default branch - throw new InputError( - 'Bitbucket URL must contain branch to be able to fetch tree', - ); - } - + const downloadUrl = await getBitbucketDownloadUrl(url, this.config); const response = await fetch( - getBitbucketDownloadUrl(url, this.config), + downloadUrl, getBitbucketRequestOptions(this.config), ); if (!response.ok) { @@ -126,7 +121,7 @@ export class BitbucketUrlReader implements UrlReader { let folderPath = `${project}-${repoName}`; if (isHosted) { - const lastCommitShortHash = await this.getLastCommitShortHash(gitUrl); + const lastCommitShortHash = await this.getLastCommitShortHash(url); folderPath = `${project}-${repoName}-${lastCommitShortHash}`; } @@ -146,12 +141,13 @@ export class BitbucketUrlReader implements UrlReader { return `bitbucket{host=${host},authed=${authed}}`; } - private async getLastCommitShortHash( - gitUrl: parseGitUri.GitUrl, - ): Promise { - const { name: repoName, owner: project, ref } = gitUrl; + private async getLastCommitShortHash(url: string): Promise { + const { name: repoName, owner: project, ref } = parseGitUri(url); - const branch = ref ? ref : 'master'; + let branch = ref; + if (!branch) { + branch = await getBitbucketDefaultBranch(url, this.config); + } const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; const commitsResponse = await fetch( diff --git a/packages/integration/package.json b/packages/integration/package.json index ccda8ea025..78112de311 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@backstage/cli": "^0.4.1", + "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 1af6ff3d56..c6fe5ddf6c 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -14,14 +14,21 @@ * limitations under the License. */ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { msw } from '@backstage/test-utils'; import { BitbucketIntegrationConfig } from './config'; import { + getBitbucketDefaultBranch, getBitbucketDownloadUrl, getBitbucketFileFetchUrl, getBitbucketRequestOptions, } from './core'; describe('bitbucket core', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + describe('getBitbucketRequestOptions', () => { it('inserts a token when needed', () => { const withToken: BitbucketIntegrationConfig = { @@ -103,40 +110,84 @@ describe('bitbucket core', () => { }); describe('getBitbucketDownloadUrl', () => { - it('add path param if a path is specified', () => { + 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/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); const config: BitbucketIntegrationConfig = { host: 'bitbucket.mycompany.net', apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', }; - const result = getBitbucketDownloadUrl( + const result = await getBitbucketDownloadUrl( '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=zip&prefix=backstage-mock&path=docs', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&at=main&prefix=backstage-mock&path=docs', ); }); - it('do not add path param if no path is specified', () => { + it('do not add path param if no path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + // TODO: Change URL to https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default when git-url-parse bug is fixed + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/projects/backstage/repos/mock/repos/browse/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); const config: BitbucketIntegrationConfig = { host: 'bitbucket.mycompany.net', apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', }; - const result = getBitbucketDownloadUrl( - 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse', + const result = await getBitbucketDownloadUrl( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse?at=main', config, ); expect(new URL(result).searchParams.get('format')).toEqual('zip'); + expect(new URL(result).searchParams.get('at')).toEqual('main'); expect(new URL(result).searchParams.get('prefix')).not.toBeNull(); expect(new URL(result).searchParams.get('path')).toBeNull(); }); - it('do not add path param if the repository is hosted on bitbucket.org', () => { + it('get by branch for Bitbucket Server', async () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const result = await getBitbucketDownloadUrl( + '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=zip&at=some-branch&prefix=backstage-mock&path=docs', + ); + }); + + it('do not add path param for Bitbucket Cloud', async () => { const config: BitbucketIntegrationConfig = { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0', }; - const result = getBitbucketDownloadUrl( + const result = await getBitbucketDownloadUrl( 'https://bitbucket.org/backstage/mock/src/master', config, ); @@ -145,4 +196,60 @@ describe('bitbucket core', () => { ); }); }); + + describe('getBitbucketDefaultBranch', () => { + 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: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.org/backstage/mock/src/main', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + + 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/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); }); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index 0d6dd392c7..ae61df497d 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -14,19 +14,58 @@ * limitations under the License. */ +import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; import { BitbucketIntegrationConfig } 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 + */ +export async function getBitbucketDefaultBranch( + url: string, + config: BitbucketIntegrationConfig, +): Promise { + const { name: repoName, owner: project, resource } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + const branchUrl = isHosted + ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + + const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + if (!response.ok) { + const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; + throw new Error(message); + } + + let defaultBranch; + if (isHosted) { + const repoInfo = await response.json(); + defaultBranch = repoInfo.mainbranch.name; + } else { + const { displayId } = await response.json(); + defaultBranch = displayId; + } + if (!defaultBranch) { + throw new Error(`Failed to read default branch from ${branchUrl}`); + } + 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 */ -export function getBitbucketDownloadUrl( +export async function getBitbucketDownloadUrl( url: string, config: BitbucketIntegrationConfig, -): string { +): Promise { const { name: repoName, owner: project, @@ -38,13 +77,17 @@ export function getBitbucketDownloadUrl( const isHosted = resource === 'bitbucket.org'; + let branch = ref; + if (!branch) { + branch = await getBitbucketDefaultBranch(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)}` : ''; const archiveUrl = isHosted - ? `${protocol}://${resource}/${project}/${repoName}/get/${ref}.zip` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=zip&prefix=${project}-${repoName}${path}`; + ? `${protocol}://${resource}/${project}/${repoName}/get/${branch}.zip` + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/archive?format=zip&at=${branch}&prefix=${project}-${repoName}${path}`; return archiveUrl; } diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index d164182aee..9df4d3d3fe 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -20,6 +20,7 @@ export { } from './config'; export type { BitbucketIntegrationConfig } from './config'; export { + getBitbucketDefaultBranch, getBitbucketDownloadUrl, getBitbucketFileFetchUrl, getBitbucketRequestOptions,