From 43e1277796abb064d9670caee7d06c6998f8cc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 4 Dec 2020 08:46:43 +0100 Subject: [PATCH 1/5] WIP: implement readTree for bitbucket --- .../src/reading/AzureUrlReader.test.ts | 4 +- .../src/reading/BitbucketUrlReader.test.ts | 133 +++++++++++++++++- .../src/reading/BitbucketUrlReader.ts | 66 ++++++++- 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index 2c8549f917..616cbaaadc 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -158,9 +158,7 @@ describe('AzureUrlReader', () => { it('returns the wanted files from an archive', async () => { const processor = new AzureUrlReader( - { - host: 'dev.azure.com', - }, + { host: 'dev.azure.com' }, { treeResponseFactory }, ); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 1c0bd39372..76db191909 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -14,15 +14,26 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; +import { msw } from '@backstage/test-utils'; +import fs from 'fs'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import path from 'path'; import { BitbucketUrlReader } from './BitbucketUrlReader'; +import { ReadTreeResponseFactory } from './tree'; + +const treeResponseFactory = ReadTreeResponseFactory.create({ + config: new ConfigReader({}), +}); describe('BitbucketUrlReader', () => { describe('implementation', () => { it('rejects unknown targets', async () => { - const processor = new BitbucketUrlReader({ - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }); + const processor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, + { treeResponseFactory }, + ); await expect( processor.read('https://not.bitbucket.com/apa'), ).rejects.toThrow( @@ -30,4 +41,118 @@ describe('BitbucketUrlReader', () => { ); }); }); + + describe('readTree', () => { + const worker = setupServer(); + msw.setupDefaultHandlers(worker); + + const repoBuffer = fs.readFileSync( + path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + ); + + it('returns the wanted files from an archive', async () => { + worker.use( + rest.get( + 'https://bitbucket.org/backstage/mock/get/master.zip', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.body(repoBuffer), + ), + ), + ); + + const processor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { treeResponseFactory }, + ); + + const response = await processor.readTree( + 'https://bitbucket.org/backstage/mock/src/master', + ); + + const files = await response.files(); + + expect(files.length).toBe(2); + const mkDocsFile = await files[0].content(); + const indexMarkdownFile = await files[1].content(); + + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('uses private bitbucket host', async () => { + worker.use( + rest.get( + 'https://bitbucket.mycompany.net/projects/a/repos/b/archive?format=tgz', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.body(repoBuffer), + ), + ), + ); + + const processor = new BitbucketUrlReader( + { host: 'bitbucket.mycompany.net', apiBaseUrl: 'x' }, + { treeResponseFactory }, + ); + + const response = await processor.readTree( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + + it('must specify a branch', async () => { + const processor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { 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( + 'https://bitbucket.org/backstage/mock/get/master.zip', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/zip'), + ctx.body(repoBuffer), + ), + ), + ); + + const processor = new BitbucketUrlReader( + { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { treeResponseFactory }, + ); + + const response = await processor.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + const files = await response.files(); + + expect(files.length).toBe(1); + const indexMarkdownFile = await files[0].content(); + + expect(indexMarkdownFile.toString()).toBe('# Test\n'); + }); + }); }); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 8c97bf2ee2..00a386d4cc 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -21,8 +21,16 @@ import { readBitbucketIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import { NotFoundError } from '../errors'; -import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; +import parseGitUri from 'git-url-parse'; +import { Readable } from 'stream'; +import { InputError, NotFoundError } from '../errors'; +import { ReadTreeResponseFactory } from './tree'; +import { + ReaderFactory, + ReadTreeOptions, + ReadTreeResponse, + UrlReader, +} from './types'; /** * A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as @@ -30,19 +38,23 @@ import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; */ export class BitbucketUrlReader implements UrlReader { private readonly config: BitbucketIntegrationConfig; + private readonly treeResponseFactory: ReadTreeResponseFactory; - static factory: ReaderFactory = ({ config }) => { + static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const configs = readBitbucketIntegrationConfigs( config.getOptionalConfigArray('integrations.bitbucket') ?? [], ); return configs.map(provider => { - const reader = new BitbucketUrlReader(provider); + const reader = new BitbucketUrlReader(provider, { treeResponseFactory }); const predicate = (url: URL) => url.host === provider.host; return { reader, predicate }; }); }; - constructor(config: BitbucketIntegrationConfig) { + constructor( + config: BitbucketIntegrationConfig, + deps: { treeResponseFactory: ReadTreeResponseFactory }, + ) { const { host, apiBaseUrl, token, username, appPassword } = config; if (!apiBaseUrl) { @@ -58,6 +70,7 @@ export class BitbucketUrlReader implements UrlReader { } this.config = config; + this.treeResponseFactory = deps.treeResponseFactory; } async read(url: string): Promise { @@ -82,8 +95,47 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - readTree(): Promise { - throw new Error('BitbucketUrlReader does not implement readTree'); + async readTree( + url: string, + options?: ReadTreeOptions, + ): Promise { + const { + name: repoName, + owner, + ref, + protocol, + resource, + // filepath, + } = parseGitUri(url); + + 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 archiveUrl = isHosted + ? `${protocol}://${resource}/${owner}/${repoName}/get/${ref}.zip` + : `${protocol}://${resource}/projects/${owner}/repos/${repoName}/archive?format=zip`; + + const response = await fetch(archiveUrl, getApiRequestOptions(this.config)); + if (!response.ok) { + const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + throw new NotFoundError(message); + } + throw new Error(message); + } + + return this.treeResponseFactory.fromZipArchive({ + stream: (response.body as unknown) as Readable, + // TODO: The zip contains the commit hash, not branch name - may need additional api call to get it + // path: `${owner}-${repoName}-4f9778cd49a4/${filepath}`, + filter: options?.filter, + }); } toString() { From ea6f83316730b5ea82370120dd91124aa8bb5fda Mon Sep 17 00:00:00 2001 From: "Kim S. Ly" Date: Mon, 7 Dec 2020 21:16:01 -0500 Subject: [PATCH 2/5] Implement readTree on BitBucketUrlReader --- .changeset/good-needles-clap.md | 5 ++ .../src/reading/BitbucketUrlReader.test.ts | 58 +++++++++++++--- .../src/reading/BitbucketUrlReader.ts | 62 +++++++++++++++--- .../bitbucket-repo-with-commit-hash.zip | Bin 0 -> 777 bytes 4 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 .changeset/good-needles-clap.md create mode 100644 packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip diff --git a/.changeset/good-needles-clap.md b/.changeset/good-needles-clap.md new file mode 100644 index 0000000000..4eb8e1e26f --- /dev/null +++ b/.changeset/good-needles-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': minor +--- + +Implement readTree on BitBucketUrlReader diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 76db191909..d7e4632a69 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -47,7 +47,12 @@ describe('BitbucketUrlReader', () => { msw.setupDefaultHandlers(worker); const repoBuffer = fs.readFileSync( - path.resolve('src', 'reading', '__fixtures__', 'repo.zip'), + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-repo-with-commit-hash.zip', + ), ); it('returns the wanted files from an archive', async () => { @@ -61,10 +66,20 @@ describe('BitbucketUrlReader', () => { 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' }], + }), + ), + ), ); const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, { treeResponseFactory }, ); @@ -75,17 +90,17 @@ describe('BitbucketUrlReader', () => { const files = await response.files(); expect(files.length).toBe(2); - const mkDocsFile = await files[0].content(); - const indexMarkdownFile = await files[1].content(); + const indexMarkdownFile = await files[0].content(); + const mkDocsFile = await files[1].content(); - expect(mkDocsFile.toString()).toBe('site_name: Test\n'); expect(indexMarkdownFile.toString()).toBe('# Test\n'); + expect(mkDocsFile.toString()).toBe('site_name: Test\n'); }); it('uses private bitbucket host', async () => { worker.use( rest.get( - 'https://bitbucket.mycompany.net/projects/a/repos/b/archive?format=tgz', + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/archive?format=tgz', (_, res, ctx) => res( ctx.status(200), @@ -93,15 +108,28 @@ describe('BitbucketUrlReader', () => { ctx.body(repoBuffer), ), ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits/?until=master', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + }), + ), + ), ); const processor = new BitbucketUrlReader( - { host: 'bitbucket.mycompany.net', apiBaseUrl: 'x' }, + { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }, { treeResponseFactory }, ); const response = await processor.readTree( - 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/docs', + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs', ); const files = await response.files(); @@ -114,7 +142,7 @@ describe('BitbucketUrlReader', () => { it('must specify a branch', async () => { const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, { treeResponseFactory }, ); @@ -136,10 +164,20 @@ describe('BitbucketUrlReader', () => { 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' }], + }), + ), + ), ); const processor = new BitbucketUrlReader( - { host: 'bitbucket.org', apiBaseUrl: 'x' }, + { host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' }, { treeResponseFactory }, ); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 00a386d4cc..982422817a 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -99,14 +99,8 @@ export class BitbucketUrlReader implements UrlReader { url: string, options?: ReadTreeOptions, ): Promise { - const { - name: repoName, - owner, - ref, - protocol, - resource, - // filepath, - } = parseGitUri(url); + const gitUrl: parseGitUri.GitUrl = parseGitUri(url); + const { name: repoName, owner, ref, protocol, resource, filepath } = gitUrl; const isHosted = resource === 'bitbucket.org'; @@ -130,10 +124,11 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } + const lastCommitShortHash = await this.getLastCommitShortHash(gitUrl); + return this.treeResponseFactory.fromZipArchive({ stream: (response.body as unknown) as Readable, - // TODO: The zip contains the commit hash, not branch name - may need additional api call to get it - // path: `${owner}-${repoName}-4f9778cd49a4/${filepath}`, + path: `${owner}-${repoName}-${lastCommitShortHash}/${filepath}`, filter: options?.filter, }); } @@ -146,4 +141,51 @@ export class BitbucketUrlReader implements UrlReader { } return `bitbucket{host=${host},authed=${authed}}`; } + + private async getLastCommitShortHash( + gitUrl: parseGitUri.GitUrl, + ): Promise { + const { name: repoName, owner, ref, protocol, resource } = gitUrl; + + const isHosted = resource === 'bitbucket.org'; + + const branch = ref ? ref : 'master'; + const commitsApiUrl = isHosted + ? `${protocol}://${this.config.apiBaseUrl}/repositories/${owner}/${repoName}/commits/${branch}` + : `${protocol}://${this.config.apiBaseUrl}/projects/${owner}/repos/${repoName}/commits/?until=${branch}`; + + const commitsResponse = await fetch( + commitsApiUrl, + getApiRequestOptions(this.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 (isHosted) { + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].hash + ) { + return commits.values[0].hash.substring(0, 12); + } + } else { + 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}`); + } } diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-repo-with-commit-hash.zip new file mode 100644 index 0000000000000000000000000000000000000000..135860afd37297bca75d51d61aba7f631f879049 GIT binary patch literal 777 zcmWIWW@h1H00E1=ErDPLl#pQ%U`R?#&Mq!VOi$I#%}>tOH8e_0GB!z0F*Qp~(+`c{ zVNg~(9pA%$I{qu82txo)lN1S%)#31KG?Aw55pBe%CaOFaQ9!LcRw8 literal 0 HcmV?d00001 From 58b3bbe4e8210352cd7df6a2bc79e20f57a7859d Mon Sep 17 00:00:00 2001 From: "Kim S. Ly" Date: Wed, 9 Dec 2020 02:02:09 -0500 Subject: [PATCH 3/5] Implement readTree on BitBucketUrlReader - Implement getBitbucketDownloadUrl and remove hash for Bitbucket Server --- .../src/reading/BitbucketUrlReader.test.ts | 22 ++++--- .../src/reading/BitbucketUrlReader.ts | 55 +++++++----------- .../__fixtures__/bitbucket-server-repo.zip | Bin 0 -> 673 bytes .../integration/src/bitbucket/core.test.ts | 50 +++++++++++++++- packages/integration/src/bitbucket/core.ts | 32 ++++++++++ packages/integration/src/bitbucket/index.ts | 6 +- 6 files changed, 118 insertions(+), 47 deletions(-) create mode 100644 packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index d7e4632a69..03d85e813c 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -98,24 +98,22 @@ describe('BitbucketUrlReader', () => { }); it('uses private bitbucket host', async () => { + const privateBitbucketRepoBuffer = fs.readFileSync( + path.resolve( + 'src', + 'reading', + '__fixtures__', + 'bitbucket-server-repo.zip', + ), + ); worker.use( rest.get( - 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/archive?format=tgz', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs', (_, res, ctx) => res( ctx.status(200), ctx.set('Content-Type', 'application/zip'), - ctx.body(repoBuffer), - ), - ), - rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits/?until=master', - (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], - }), + ctx.body(privateBitbucketRepoBuffer), ), ), ); diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 982422817a..c92174f1e4 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -16,6 +16,7 @@ import { BitbucketIntegrationConfig, + getBitbucketDownloadUrl, getBitbucketFileFetchUrl, getBitbucketRequestOptions, readBitbucketIntegrationConfigs, @@ -100,7 +101,7 @@ export class BitbucketUrlReader implements UrlReader { options?: ReadTreeOptions, ): Promise { const gitUrl: parseGitUri.GitUrl = parseGitUri(url); - const { name: repoName, owner, ref, protocol, resource, filepath } = gitUrl; + const { name: repoName, owner: project, ref, resource, filepath } = gitUrl; const isHosted = resource === 'bitbucket.org'; @@ -111,11 +112,10 @@ export class BitbucketUrlReader implements UrlReader { ); } - const archiveUrl = isHosted - ? `${protocol}://${resource}/${owner}/${repoName}/get/${ref}.zip` - : `${protocol}://${resource}/projects/${owner}/repos/${repoName}/archive?format=zip`; - - const response = await fetch(archiveUrl, getApiRequestOptions(this.config)); + const response = await fetch( + getBitbucketDownloadUrl(url, this.config), + getBitbucketRequestOptions(this.config), + ); if (!response.ok) { const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; if (response.status === 404) { @@ -124,11 +124,15 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - const lastCommitShortHash = await this.getLastCommitShortHash(gitUrl); + let folderPath = `${project}-${repoName}`; + if (isHosted) { + const lastCommitShortHash = await this.getLastCommitShortHash(gitUrl); + folderPath = `${project}-${repoName}-${lastCommitShortHash}`; + } return this.treeResponseFactory.fromZipArchive({ stream: (response.body as unknown) as Readable, - path: `${owner}-${repoName}-${lastCommitShortHash}/${filepath}`, + path: `${folderPath}/${filepath}`, filter: options?.filter, }); } @@ -145,18 +149,14 @@ export class BitbucketUrlReader implements UrlReader { private async getLastCommitShortHash( gitUrl: parseGitUri.GitUrl, ): Promise { - const { name: repoName, owner, ref, protocol, resource } = gitUrl; - - const isHosted = resource === 'bitbucket.org'; + const { name: repoName, owner: project, ref } = gitUrl; const branch = ref ? ref : 'master'; - const commitsApiUrl = isHosted - ? `${protocol}://${this.config.apiBaseUrl}/repositories/${owner}/${repoName}/commits/${branch}` - : `${protocol}://${this.config.apiBaseUrl}/projects/${owner}/repos/${repoName}/commits/?until=${branch}`; + const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; const commitsResponse = await fetch( commitsApiUrl, - getApiRequestOptions(this.config), + getBitbucketRequestOptions(this.config), ); if (!commitsResponse.ok) { const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; @@ -167,24 +167,13 @@ export class BitbucketUrlReader implements UrlReader { } const commits = await commitsResponse.json(); - if (isHosted) { - if ( - commits && - commits.values && - commits.values.length > 0 && - commits.values[0].hash - ) { - return commits.values[0].hash.substring(0, 12); - } - } else { - if ( - commits && - commits.values && - commits.values.length > 0 && - commits.values[0].id - ) { - return commits.values[0].id.substring(0, 12); - } + 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}`); } diff --git a/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip b/packages/backend-common/src/reading/__fixtures__/bitbucket-server-repo.zip new file mode 100644 index 0000000000000000000000000000000000000000..be6b20d12708da4e910b4540793ba1728a555904 GIT binary patch literal 673 zcmWIWW@h1H00E1=ErDPLl;CF&U`R?#&Mq!VOi$I#%}>tO4~^hq(8{_H-@|`8{wt#h zLjX?ABAA*}@{^0Pnjpf!0fgIHqfxAv!C^vXUP@|(UTzA=ErLL|{Aim2c1v&=ho`RI zLnff3*g2+&oR8oH`hy*aL5_r2u>s8rNlYtpvq8?(tIW-Tng0fAzV$Z`=lnFd!Rbi)j!vn9)r` z1Tu2i$RL@vq_G}{X^0@_fCo9mjfikX4hTsUQy*cPiX6%iBSE1Y;LXYgRLj5ugbs`h K4BkM@zyJVY-kw|l literal 0 HcmV?d00001 diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 8d9f956c6b..1af6ff3d56 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -15,7 +15,11 @@ */ import { BitbucketIntegrationConfig } from './config'; -import { getBitbucketFileFetchUrl, getBitbucketRequestOptions } from './core'; +import { + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; describe('bitbucket core', () => { describe('getBitbucketRequestOptions', () => { @@ -97,4 +101,48 @@ describe('bitbucket core', () => { ); }); }); + + describe('getBitbucketDownloadUrl', () => { + it('add path param if a path is specified', () => { + 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/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', + ); + }); + + it('do not add path param if no path is specified', () => { + 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', + config, + ); + expect(new URL(result).searchParams.get('format')).toEqual('zip'); + 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', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + const result = getBitbucketDownloadUrl( + 'https://bitbucket.org/backstage/mock/src/master', + config, + ); + expect(result).toEqual( + 'https://bitbucket.org/backstage/mock/get/master.zip', + ); + }); + }); }); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index a522e7ce9f..0d6dd392c7 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -17,6 +17,38 @@ import parseGitUrl from 'git-url-parse'; import { BitbucketIntegrationConfig } from './config'; +/** + * 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 + */ +export function getBitbucketDownloadUrl( + url: string, + config: BitbucketIntegrationConfig, +): string { + const { + name: repoName, + owner: project, + ref, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + const isHosted = resource === 'bitbucket.org'; + + // 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}`; + + return archiveUrl; +} + /** * Given a URL pointing to a file on a provider, returns a URL that is suitable * for fetching the contents of the data. diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index b8d37220db..d164182aee 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -19,4 +19,8 @@ export { readBitbucketIntegrationConfigs, } from './config'; export type { BitbucketIntegrationConfig } from './config'; -export { getBitbucketFileFetchUrl, getBitbucketRequestOptions } from './core'; +export { + getBitbucketDownloadUrl, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, +} from './core'; From 1d1c2860f1df9972a6e1b372826953edacfb5c5e Mon Sep 17 00:00:00 2001 From: "Kim S. Ly" Date: Wed, 9 Dec 2020 02:30:33 -0500 Subject: [PATCH 4/5] Implement readTree on BitBucketUrlReader - Modify changeset --- .changeset/good-needles-clap.md | 5 ----- .changeset/grumpy-elephants-press.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 .changeset/good-needles-clap.md create mode 100644 .changeset/grumpy-elephants-press.md diff --git a/.changeset/good-needles-clap.md b/.changeset/good-needles-clap.md deleted file mode 100644 index 4eb8e1e26f..0000000000 --- a/.changeset/good-needles-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': minor ---- - -Implement readTree on BitBucketUrlReader diff --git a/.changeset/grumpy-elephants-press.md b/.changeset/grumpy-elephants-press.md new file mode 100644 index 0000000000..d85d36e609 --- /dev/null +++ b/.changeset/grumpy-elephants-press.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/integration': patch +--- + +Implement readTree on BitBucketUrlReader and getBitbucketDownloadUrl From bbd0e354ffe4fa65b50a450ae7f2828c62b8befc Mon Sep 17 00:00:00 2001 From: "Kim S. Ly" Date: Tue, 15 Dec 2020 00:48:03 -0500 Subject: [PATCH 5/5] 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,