diff --git a/.changeset/warm-bats-jump.md b/.changeset/warm-bats-jump.md new file mode 100644 index 0000000000..1547734434 --- /dev/null +++ b/.changeset/warm-bats-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-bitbucket': minor +--- + +Integrate `@backstage/plugin-bitbucket-cloud-common` as replacement for the `BitbucketCloudClient`. diff --git a/plugins/catalog-backend-module-bitbucket/package.json b/plugins/catalog-backend-module-bitbucket/package.json index 51f5a6e603..4e40fa9400 100644 --- a/plugins/catalog-backend-module-bitbucket/package.json +++ b/plugins/catalog-backend-module-bitbucket/package.json @@ -38,6 +38,7 @@ "@backstage/config": "^1.0.1", "@backstage/errors": "^1.0.0", "@backstage/integration": "^1.2.1-next.0", + "@backstage/plugin-bitbucket-cloud-common": "^0.0.0", "@backstage/plugin-catalog-backend": "^1.2.0-next.0", "@backstage/types": "^1.0.0", "lodash": "^4.17.21", diff --git a/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.test.ts b/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.test.ts index 5f441b787c..ad893e7c87 100644 --- a/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.test.ts +++ b/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.test.ts @@ -16,6 +16,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import { Models } from '@backstage/plugin-bitbucket-cloud-common'; import { LocationSpec, processingResult, @@ -23,7 +24,7 @@ import { import { RequestHandler, rest } from 'msw'; import { setupServer } from 'msw/node'; import { BitbucketDiscoveryProcessor } from './BitbucketDiscoveryProcessor'; -import { BitbucketRepository20, PagedResponse, PagedResponse20 } from './lib'; +import { PagedResponse } from './lib'; const server = setupServer(); @@ -84,14 +85,14 @@ function setupStubs( function setupBitbucketCloudStubs( workspace: string, - repositories: Pick[], + repositories: Pick[], ) { const stubCallerFn = jest.fn(); - function pagedResponse(values: any): PagedResponse20 { + function pagedResponse(values: any): Models.PaginatedRepositories { return { values: values, page: 1, - } as PagedResponse20; + } as Models.PaginatedRepositories; } server.use( @@ -121,15 +122,15 @@ function setupBitbucketCloudStubs( function setupBitbucketCloudSearchStubs( workspace: string, - repositories: Pick[], + repositories: Pick[], catalogPath: string, ) { const stubCallerFn = jest.fn(); - function pagedResponse(values: any): PagedResponse20 { + function pagedResponse(values: any): Models.PaginatedRepositories { return { values: values, page: 1, - } as PagedResponse20; + } as Models.PaginatedRepositories; } server.use( @@ -555,8 +556,14 @@ describe('BitbucketDiscoveryProcessor', () => { it('output all repositories by default', async () => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -590,8 +597,14 @@ describe('BitbucketDiscoveryProcessor', () => { it('uses provided catalog path', async () => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -626,8 +639,14 @@ describe('BitbucketDiscoveryProcessor', () => { it('output all repositories', async () => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -662,8 +681,14 @@ describe('BitbucketDiscoveryProcessor', () => { it('output repositories with wildcards', async () => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -688,9 +713,18 @@ describe('BitbucketDiscoveryProcessor', () => { it('filter unrelated repositories', async () => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-one' }, slug: 'repository-two' }, - { project: { key: 'prj-one' }, slug: 'repository-three' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-two', + }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-three', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -715,7 +749,10 @@ describe('BitbucketDiscoveryProcessor', () => { it('submits query', async () => { const mockCall = setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, ]); const location: LocationSpec = { type: 'bitbucket-discovery', @@ -750,7 +787,10 @@ describe('BitbucketDiscoveryProcessor', () => { ${'https://bitbucket.org/workspaces/myworkspace/projects/prj-one/repos/repository-*/'} `("target '$target' adds default path to catalog", async ({ target }) => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, ]); const location: LocationSpec = { @@ -778,7 +818,10 @@ describe('BitbucketDiscoveryProcessor', () => { ${'https://bitbucket.org/test'} `("target '$target' is rejected", async ({ target }) => { setupBitbucketCloudStubs('myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, ]); const location: LocationSpec = { @@ -813,8 +856,14 @@ describe('BitbucketDiscoveryProcessor', () => { setupBitbucketCloudSearchStubs( 'myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ], 'catalog-info.yaml', ); @@ -852,8 +901,14 @@ describe('BitbucketDiscoveryProcessor', () => { setupBitbucketCloudSearchStubs( 'myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ], 'my/nested/path/catalog.yaml', ); @@ -892,8 +947,14 @@ describe('BitbucketDiscoveryProcessor', () => { setupBitbucketCloudSearchStubs( 'myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ], 'catalog.yaml', ); @@ -932,8 +993,14 @@ describe('BitbucketDiscoveryProcessor', () => { setupBitbucketCloudSearchStubs( 'myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-two' }, slug: 'repository-two' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-two' }, + slug: 'repository-two', + }, ], 'catalog.yaml', ); @@ -962,9 +1029,18 @@ describe('BitbucketDiscoveryProcessor', () => { setupBitbucketCloudSearchStubs( 'myworkspace', [ - { project: { key: 'prj-one' }, slug: 'repository-one' }, - { project: { key: 'prj-one' }, slug: 'repository-two' }, - { project: { key: 'prj-one' }, slug: 'repository-three' }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-two', + }, + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-three', + }, ], 'catalog.yaml', ); @@ -997,7 +1073,12 @@ describe('BitbucketDiscoveryProcessor', () => { `("target '$target' adds default path to catalog", async ({ target }) => { setupBitbucketCloudSearchStubs( 'myworkspace', - [{ project: { key: 'prj-one' }, slug: 'repository-one' }], + [ + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + ], 'catalog-info.yaml', ); @@ -1027,7 +1108,12 @@ describe('BitbucketDiscoveryProcessor', () => { `("target '$target' is rejected", async ({ target }) => { setupBitbucketCloudSearchStubs( 'myworkspace', - [{ project: { key: 'prj-one' }, slug: 'repository-one' }], + [ + { + project: { type: 'project', key: 'prj-one' }, + slug: 'repository-one', + }, + ], 'catalog-info.yaml', ); diff --git a/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.ts b/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.ts index a025eda962..14bf90ee62 100644 --- a/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.ts +++ b/plugins/catalog-backend-module-bitbucket/src/BitbucketDiscoveryProcessor.ts @@ -20,6 +20,10 @@ import { ScmIntegrationRegistry, ScmIntegrations, } from '@backstage/integration'; +import { + BitbucketCloudClient, + Models, +} from '@backstage/plugin-bitbucket-cloud-common'; import { CatalogProcessor, CatalogProcessorEmit, @@ -27,14 +31,11 @@ import { } from '@backstage/plugin-catalog-backend'; import { Logger } from 'winston'; import { - BitbucketCloudClient, BitbucketRepository, - BitbucketRepository20, BitbucketRepositoryParser, BitbucketServerClient, defaultRepositoryParser, paginated, - paginated20, } from './lib'; const DEFAULT_BRANCH = 'master'; @@ -119,9 +120,7 @@ export class BitbucketDiscoveryProcessor implements CatalogProcessor { options: ProcessOptions, ): Promise { const { location, integration, emit } = options; - const client = new BitbucketCloudClient({ - config: integration.config, - }); + const client = BitbucketCloudClient.fromConfig(integration.config); const { searchEnabled } = parseBitbucketCloudUrl(location.target); @@ -226,28 +225,40 @@ export async function searchBitbucketCloudLocations( catalogPath.lastIndexOf('/') + 1, ); - const searchResults = paginated20(options => - client.searchCode( - workspacePath, - `"${catalogFilename}" path:${catalogPath}`, - options, - ), - ); + // load all fields relevant for creating refs later, but not more + const fields = [ + // exclude code/content match details + '-values.content_matches', + // include/add relevant repository details + '+values.file.commit.repository.mainbranch.name', + '+values.file.commit.repository.project.key', + '+values.file.commit.repository.slug', + // remove irrelevant links + '-values.*.links', + '-values.*.*.links', + '-values.*.*.*.links', + // ...except the one we need + '+values.file.commit.repository.links.html.href', + ].join(','); + const query = `"${catalogFilename}" path:${catalogPath}`; + const searchResults = client + .searchCode(workspacePath, query, { fields }) + .iterateResults(); for await (const searchResult of searchResults) { // not a file match, but a code match - if (searchResult.path_matches.length === 0) { + if (searchResult.path_matches!.length === 0) { continue; } - const repository = searchResult.file.commit.repository; + const repository = searchResult.file!.commit!.repository!; if (!matchesPostFilters(repository, projectSearchPath, repoSearchPath)) { continue; } - const repoUrl = repository.links.html.href; + const repoUrl = repository.links!.html!.href; const branch = repository.mainbranch?.name ?? DEFAULT_BRANCH; - const filePath = searchResult.file.path; + const filePath = searchResult.file!.path; const location = `${repoUrl}/src/${branch}/${filePath}`; result.matches.push(location); @@ -268,7 +279,7 @@ export async function readBitbucketCloudLocations( return readBitbucketCloud(client, target).then(result => { const matches = result.matches.map(repository => { const branch = repository.mainbranch?.name ?? DEFAULT_BRANCH; - return `${repository.links.html.href}/src/${branch}${catalogPath}`; + return `${repository.links!.html!.href}/src/${branch}${catalogPath}`; }); return { @@ -281,7 +292,7 @@ export async function readBitbucketCloudLocations( export async function readBitbucketCloud( client: BitbucketCloudClient, target: string, -): Promise> { +): Promise> { const { workspacePath, queryParam: q, @@ -289,13 +300,10 @@ export async function readBitbucketCloud( repoSearchPath, } = parseBitbucketCloudUrl(target); - const repositories = paginated20( - options => client.listRepositoriesByWorkspace(workspacePath, options), - { - q, - }, - ); - const result: Result = { + const repositories = client + .listRepositoriesByWorkspace(workspacePath, { q }) + .iterateResults(); + const result: Result = { scanned: 0, matches: [], }; @@ -310,13 +318,13 @@ export async function readBitbucketCloud( } function matchesPostFilters( - repository: BitbucketRepository20, + repository: Models.Repository, projectSearchPath: RegExp | undefined, repoSearchPath: RegExp | undefined, ): boolean { return ( - (!projectSearchPath || projectSearchPath.test(repository.project.key)) && - (!repoSearchPath || repoSearchPath.test(repository.slug)) + (!projectSearchPath || projectSearchPath.test(repository.project!.key!)) && + (!repoSearchPath || repoSearchPath.test(repository.slug!)) ); } diff --git a/plugins/catalog-backend-module-bitbucket/src/lib/BitbucketCloudClient.ts b/plugins/catalog-backend-module-bitbucket/src/lib/BitbucketCloudClient.ts deleted file mode 100644 index 80826ce154..0000000000 --- a/plugins/catalog-backend-module-bitbucket/src/lib/BitbucketCloudClient.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * 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 { - BitbucketIntegrationConfig, - getBitbucketRequestOptions, -} from '@backstage/integration'; -import fetch from 'node-fetch'; -import { BitbucketRepository20 } from './types'; - -export class BitbucketCloudClient { - private readonly config: BitbucketIntegrationConfig; - - constructor(options: { config: BitbucketIntegrationConfig }) { - this.config = options.config; - } - - async searchCode( - workspace: string, - query: string, - options?: ListOptions20, - ): Promise> { - // load all fields relevant for creating refs later, but not more - const fields = [ - // exclude code/content match details - '-values.content_matches', - // include/add relevant repository details - '+values.file.commit.repository.mainbranch.name', - '+values.file.commit.repository.project.key', - '+values.file.commit.repository.slug', - // remove irrelevant links - '-values.*.links', - '-values.*.*.links', - '-values.*.*.*.links', - // ...except the one we need - '+values.file.commit.repository.links.html.href', - ].join(','); - - return this.pagedRequest( - `${this.config.apiBaseUrl}/workspaces/${encodeURIComponent( - workspace, - )}/search/code`, - { - ...options, - fields: fields, - search_query: query, - }, - ); - } - - async listRepositoriesByWorkspace( - workspace: string, - options?: ListOptions20, - ): Promise> { - return this.pagedRequest( - `${this.config.apiBaseUrl}/repositories/${encodeURIComponent(workspace)}`, - options, - ); - } - - private async pagedRequest( - endpoint: string, - options?: ListOptions20, - ): Promise> { - const request = new URL(endpoint); - for (const key in options) { - if (options[key]) { - request.searchParams.append(key, options[key]!.toString()); - } - } - - const response = await fetch( - request.toString(), - getBitbucketRequestOptions(this.config), - ); - if (!response.ok) { - throw new Error( - `Unexpected response when fetching ${request.toString()}. Expected 200 but got ${ - response.status - } - ${response.statusText}`, - ); - } - return response.json() as Promise>; - } -} - -export type CodeSearchResultItem = { - type: string; - content_match_count: number; - path_matches: Array<{ - text: string; - match?: boolean; - }>; - file: { - path: string; - type: string; - commit: { - repository: BitbucketRepository20; - }; - }; -}; - -export type ListOptions20 = { - [key: string]: string | number | undefined; - page?: number | undefined; - pagelen?: number | undefined; -}; - -export type PagedResponse20 = { - page: number; - pagelen: number; - size: number; - values: T[]; - next: string; -}; - -export async function* paginated20( - request: (options: ListOptions20) => Promise>, - options?: ListOptions20, -) { - const opts = { page: 1, pagelen: 100, ...options }; - let res; - do { - res = await request(opts); - opts.page = opts.page + 1; - for (const item of res.values) { - yield item; - } - } while (res.next); -} diff --git a/plugins/catalog-backend-module-bitbucket/src/lib/index.ts b/plugins/catalog-backend-module-bitbucket/src/lib/index.ts index 8faf3db08f..c93bd7afd9 100644 --- a/plugins/catalog-backend-module-bitbucket/src/lib/index.ts +++ b/plugins/catalog-backend-module-bitbucket/src/lib/index.ts @@ -16,8 +16,6 @@ export { defaultRepositoryParser } from './BitbucketRepositoryParser'; export type { BitbucketRepositoryParser } from './BitbucketRepositoryParser'; -export { BitbucketCloudClient, paginated20 } from './BitbucketCloudClient'; export { BitbucketServerClient, paginated } from './BitbucketServerClient'; -export type { PagedResponse20 } from './BitbucketCloudClient'; export type { PagedResponse } from './BitbucketServerClient'; -export type { BitbucketRepository, BitbucketRepository20 } from './types'; +export type { BitbucketRepository } from './types'; diff --git a/plugins/catalog-backend-module-bitbucket/src/lib/types.ts b/plugins/catalog-backend-module-bitbucket/src/lib/types.ts index db8cf69688..b1bb416363 100644 --- a/plugins/catalog-backend-module-bitbucket/src/lib/types.ts +++ b/plugins/catalog-backend-module-bitbucket/src/lib/types.ts @@ -29,33 +29,3 @@ export type BitbucketRepository = BitbucketRepositoryBase & { }[] >; }; - -export type BitbucketRepository20 = BitbucketRepositoryBase & { - links: Record< - | 'self' - | 'source' - | 'html' - | 'avatar' - | 'pullrequests' - | 'commits' - | 'forks' - | 'watchers' - | 'downloads' - | 'hooks', - { - href: string; - name?: string; - } - > & - Record< - 'clone', - { - href: string; - name?: string; - }[] - >; - mainbranch?: { - type: string; - name: string; - }; -};