Merge pull request #22612 from Bonial-International-GmbH/pjungermann/catalog/remove-module-bitbucket

chore(catalog): remove deprecated catalog-backend-module-bitbucket plugin
This commit is contained in:
Ben Lambert
2024-02-07 17:04:19 +01:00
committed by GitHub
16 changed files with 0 additions and 3198 deletions
@@ -1 +0,0 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
File diff suppressed because it is too large Load Diff
@@ -1,8 +0,0 @@
# Catalog Backend Module for Bitbucket
This is an extension module to the plugin-catalog-backend plugin, providing extensions targeted at Bitbucket offerings.
## Getting started
See [Backstage documentation](https://backstage.io/docs/integrations/bitbucket/discovery) for details on how to install
and configure the plugin.
@@ -1,47 +0,0 @@
## API Report File for "@backstage/plugin-catalog-backend-module-bitbucket"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BitbucketIntegration } from '@backstage/integration';
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node';
import { CatalogProcessorResult } from '@backstage/plugin-catalog-node';
import { Config } from '@backstage/config';
import { LocationSpec } from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import { ScmIntegrationRegistry } from '@backstage/integration';
// @public @deprecated (undocumented)
export class BitbucketDiscoveryProcessor implements CatalogProcessor {
constructor(options: {
integrations: ScmIntegrationRegistry;
parser?: BitbucketRepositoryParser;
logger: Logger;
});
// (undocumented)
static fromConfig(
config: Config,
options: {
parser?: BitbucketRepositoryParser;
logger: Logger;
},
): BitbucketDiscoveryProcessor;
// (undocumented)
getProcessorName(): string;
// (undocumented)
readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean>;
}
// @public @deprecated
export type BitbucketRepositoryParser = (options: {
integration: BitbucketIntegration;
target: string;
presence?: 'optional' | 'required';
logger: Logger;
}) => AsyncIterable<CatalogProcessorResult>;
```
@@ -1,10 +0,0 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-catalog-backend-module-bitbucket
title: '@backstage/plugin-catalog-backend-module-bitbucket'
description: A Backstage catalog backend module that helps integrate towards Bitbucket
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: catalog-maintainers
@@ -1,52 +0,0 @@
{
"name": "@backstage/plugin-catalog-backend-module-bitbucket",
"description": "A Backstage catalog backend module that helps integrate towards Bitbucket",
"version": "0.2.25-next.2",
"deprecated": true,
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/catalog-backend-module-bitbucket"
},
"keywords": [
"backstage"
],
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean",
"start": "backstage-cli package start"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-bitbucket-cloud-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"node-fetch": "^2.6.7",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"msw": "^1.0.0"
},
"files": [
"dist"
]
}
File diff suppressed because it is too large Load Diff
@@ -1,415 +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 { Config } from '@backstage/config';
import {
BitbucketIntegration,
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import {
BitbucketCloudClient,
Models,
} from '@backstage/plugin-bitbucket-cloud-common';
import {
CatalogProcessor,
CatalogProcessorEmit,
LocationSpec,
} from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
import {
BitbucketRepository,
BitbucketRepositoryParser,
BitbucketServerClient,
defaultRepositoryParser,
paginated,
} from './lib';
const DEFAULT_BRANCH = 'master';
const DEFAULT_CATALOG_LOCATION = '/catalog-info.yaml';
/**
* @public
* @deprecated Please migrate to `@backstage/plugin-catalog-backend-module-bitbucket-cloud` or `@backstage/plugin-catalog-backend-module-bitbucket-server` instead.
*/
export class BitbucketDiscoveryProcessor implements CatalogProcessor {
private readonly integrations: ScmIntegrationRegistry;
private readonly parser: BitbucketRepositoryParser;
private readonly logger: Logger;
static fromConfig(
config: Config,
options: {
parser?: BitbucketRepositoryParser;
logger: Logger;
},
) {
const integrations = ScmIntegrations.fromConfig(config);
return new BitbucketDiscoveryProcessor({
...options,
integrations,
});
}
constructor(options: {
integrations: ScmIntegrationRegistry;
parser?: BitbucketRepositoryParser;
logger: Logger;
}) {
this.integrations = options.integrations;
this.parser = options.parser || defaultRepositoryParser;
this.logger = options.logger;
this.logger.warn(
'Please migrate to `@backstage/plugin-catalog-backend-module-bitbucket-cloud` or `@backstage/plugin-catalog-backend-module-bitbucket-server` instead.',
);
}
getProcessorName(): string {
return 'BitbucketDiscoveryProcessor';
}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: CatalogProcessorEmit,
): Promise<boolean> {
if (location.type !== 'bitbucket-discovery') {
return false;
}
const integration = this.integrations.bitbucket.byUrl(location.target);
if (!integration) {
throw new Error(
`There is no Bitbucket integration that matches ${location.target}. Please add a configuration entry for it under integrations.bitbucket`,
);
}
const startTimestamp = Date.now();
this.logger.info(
`Reading ${integration.config.host} repositories from ${location.target}`,
);
const processOptions: ProcessOptions = {
emit,
integration,
location,
};
const isBitbucketCloud = integration.config.host === 'bitbucket.org';
const { scanned, matches } = isBitbucketCloud
? await this.processCloudRepositories(processOptions)
: await this.processOrganizationRepositories(processOptions);
const duration = ((Date.now() - startTimestamp) / 1000).toFixed(1);
this.logger.debug(
`Read ${scanned} ${integration.config.host} repositories (${matches} matching the pattern) in ${duration} seconds`,
);
return true;
}
private async processCloudRepositories(
options: ProcessOptions,
): Promise<ResultSummary> {
const { location, integration, emit } = options;
const client = BitbucketCloudClient.fromConfig(integration.config);
const { searchEnabled } = parseBitbucketCloudUrl(location.target);
const result = searchEnabled
? await searchBitbucketCloudLocations(client, location.target)
: await readBitbucketCloudLocations(client, location.target);
for (const locationTarget of result.matches) {
for await (const entity of this.parser({
integration,
target: locationTarget,
presence: searchEnabled ? 'required' : 'optional',
logger: this.logger,
})) {
emit(entity);
}
}
return {
matches: result.matches.length,
scanned: result.scanned,
};
}
private async processOrganizationRepositories(
options: ProcessOptions,
): Promise<ResultSummary> {
const { location, integration, emit } = options;
const { catalogPath: requestedCatalogPath } = parseUrl(location.target);
const catalogPath = requestedCatalogPath
? `/${requestedCatalogPath}`
: DEFAULT_CATALOG_LOCATION;
const client = new BitbucketServerClient({
config: integration.config,
});
const result = await readBitbucketOrg(client, location.target);
for (const repository of result.matches) {
for await (const entity of this.parser({
integration,
target: `${repository.links.self[0].href}${catalogPath}`,
logger: this.logger,
})) {
emit(entity);
}
}
return {
matches: result.matches.length,
scanned: result.scanned,
};
}
}
export async function readBitbucketOrg(
client: BitbucketServerClient,
target: string,
): Promise<Result<BitbucketRepository>> {
const { projectSearchPath, repoSearchPath } = parseUrl(target);
const projects = paginated(options => client.listProjects(options));
const result: Result<BitbucketRepository> = {
scanned: 0,
matches: [],
};
for await (const project of projects) {
if (!projectSearchPath.test(project.key)) {
continue;
}
const repositories = paginated(options =>
client.listRepositories(project.key, options),
);
for await (const repository of repositories) {
result.scanned++;
if (repoSearchPath.test(repository.slug)) {
result.matches.push(repository);
}
}
}
return result;
}
export async function searchBitbucketCloudLocations(
client: BitbucketCloudClient,
target: string,
): Promise<Result<string>> {
const {
workspacePath,
catalogPath: requestedCatalogPath,
projectSearchPath,
repoSearchPath,
} = parseBitbucketCloudUrl(target);
const result: Result<string> = {
scanned: 0,
matches: [],
};
const catalogPath = requestedCatalogPath
? requestedCatalogPath
: DEFAULT_CATALOG_LOCATION;
const catalogFilename = catalogPath.substring(
catalogPath.lastIndexOf('/') + 1,
);
// 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) {
continue;
}
const repository = searchResult.file!.commit!.repository!;
if (!matchesPostFilters(repository, projectSearchPath, repoSearchPath)) {
continue;
}
const repoUrl = repository.links!.html!.href;
const branch = repository.mainbranch?.name ?? DEFAULT_BRANCH;
const filePath = searchResult.file!.path;
const location = `${repoUrl}/src/${branch}/${filePath}`;
result.matches.push(location);
}
return result;
}
export async function readBitbucketCloudLocations(
client: BitbucketCloudClient,
target: string,
): Promise<Result<string>> {
const { catalogPath: requestedCatalogPath } = parseBitbucketCloudUrl(target);
const catalogPath = requestedCatalogPath
? `/${requestedCatalogPath}`
: DEFAULT_CATALOG_LOCATION;
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 {
scanned: result.scanned,
matches,
};
});
}
export async function readBitbucketCloud(
client: BitbucketCloudClient,
target: string,
): Promise<Result<Models.Repository>> {
const {
workspacePath,
queryParam: q,
projectSearchPath,
repoSearchPath,
} = parseBitbucketCloudUrl(target);
const repositories = client
.listRepositoriesByWorkspace(workspacePath, { q })
.iterateResults();
const result: Result<Models.Repository> = {
scanned: 0,
matches: [],
};
for await (const repository of repositories) {
result.scanned++;
if (matchesPostFilters(repository, projectSearchPath, repoSearchPath)) {
result.matches.push(repository);
}
}
return result;
}
function matchesPostFilters(
repository: Models.Repository,
projectSearchPath: RegExp | undefined,
repoSearchPath: RegExp | undefined,
): boolean {
return (
(!projectSearchPath || projectSearchPath.test(repository.project!.key!)) &&
(!repoSearchPath || repoSearchPath.test(repository.slug!))
);
}
function parseUrl(urlString: string): {
projectSearchPath: RegExp;
repoSearchPath: RegExp;
catalogPath: string;
} {
const url = new URL(urlString);
const indexOfProjectSegment =
url.pathname.toLowerCase().indexOf('/projects/') + 1;
const path = url.pathname.slice(indexOfProjectSegment).split('/');
// /projects/backstage/repos/techdocs-*/catalog-info.yaml
if (path.length > 3 && path[1].length && path[3].length) {
return {
projectSearchPath: escapeRegExp(decodeURIComponent(path[1])),
repoSearchPath: escapeRegExp(decodeURIComponent(path[3])),
catalogPath: decodeURIComponent(path.slice(4).join('/') + url.search),
};
}
throw new Error(`Failed to parse ${urlString}`);
}
function readPathParameters(pathParts: string[]): Map<string, string> {
const vals: Record<string, any> = {};
for (let i = 0; i + 1 < pathParts.length; i += 2) {
vals[pathParts[i]] = decodeURIComponent(pathParts[i + 1]);
}
return new Map<string, string>(Object.entries(vals));
}
function parseBitbucketCloudUrl(urlString: string): {
workspacePath: string;
catalogPath?: string;
projectSearchPath?: RegExp;
repoSearchPath?: RegExp;
queryParam?: string;
searchEnabled: boolean;
} {
const url = new URL(urlString);
const pathMap = readPathParameters(url.pathname.slice(1).split('/'));
const query = url.searchParams;
if (!pathMap.has('workspaces')) {
throw new Error(`Failed to parse workspace from ${urlString}`);
}
return {
workspacePath: pathMap.get('workspaces')!,
projectSearchPath: pathMap.has('projects')
? escapeRegExp(pathMap.get('projects')!)
: undefined,
repoSearchPath: pathMap.has('repos')
? escapeRegExp(pathMap.get('repos')!)
: undefined,
catalogPath: query.get('catalogPath') || undefined,
queryParam: query.get('q') || undefined,
searchEnabled: query.get('search')?.toLowerCase() === 'true',
};
}
function escapeRegExp(str: string): RegExp {
return new RegExp(`^${str.replace(/\*/g, '.*')}$`);
}
type ProcessOptions = {
integration: BitbucketIntegration;
location: LocationSpec;
emit: CatalogProcessorEmit;
};
type Result<T> = {
scanned: number;
matches: T[];
};
type ResultSummary = {
scanned: number;
matches: number;
};
@@ -1,24 +0,0 @@
/*
* 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.
*/
/**
* A Backstage catalog backend module that helps integrate towards Bitbucket
*
* @packageDocumentation
*/
export { BitbucketDiscoveryProcessor } from './BitbucketDiscoveryProcessor';
export type { BitbucketRepositoryParser } from './lib/BitbucketRepositoryParser';
@@ -1,44 +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 { processingResult } from '@backstage/plugin-catalog-node';
import { defaultRepositoryParser } from './BitbucketRepositoryParser';
describe('BitbucketRepositoryParser', () => {
describe('defaultRepositoryParser', () => {
it('emits location', async () => {
const browseUrl =
'https://bitbucket.mycompany.com/projects/project-key/repos/repo-slug/browse';
const path = '/catalog-info.yaml';
const expected = [
processingResult.location({
type: 'url',
target: `${browseUrl}${path}`,
presence: 'optional',
}),
];
const actual = defaultRepositoryParser({
target: `${browseUrl}${path}`,
});
let i = 0;
for await (const entity of actual) {
expect(entity).toStrictEqual(expected[i]);
i++;
}
});
});
});
@@ -1,51 +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 { BitbucketIntegration } from '@backstage/integration';
import {
CatalogProcessorResult,
processingResult,
} from '@backstage/plugin-catalog-node';
import { Logger } from 'winston';
/**
* A custom callback that reacts to finding a repository by yielding processing
* results.
*
* @public
* @deprecated Please migrate to `@backstage/plugin-catalog-backend-module-bitbucket-cloud` or `@backstage/plugin-catalog-backend-module-bitbucket-server` instead.
*/
export type BitbucketRepositoryParser = (options: {
integration: BitbucketIntegration;
target: string;
presence?: 'optional' | 'required';
logger: Logger;
}) => AsyncIterable<CatalogProcessorResult>;
export const defaultRepositoryParser =
async function* defaultRepositoryParser(options: {
target: string;
presence?: 'optional' | 'required';
}) {
yield processingResult.location({
type: 'url',
target: options.target,
// Not all locations may actually exist, since the user defined them as a wildcard pattern.
// Thus, we emit them as optional and let the downstream processor find them while not outputting
// an error if it couldn't.
presence: options.presence ?? 'optional',
});
};
@@ -1,100 +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';
export class BitbucketServerClient {
private readonly config: BitbucketIntegrationConfig;
constructor(options: { config: BitbucketIntegrationConfig }) {
this.config = options.config;
}
async listProjects(options?: ListOptions): Promise<PagedResponse<any>> {
return this.pagedRequest(`${this.config.apiBaseUrl}/projects`, options);
}
async listRepositories(
projectKey: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
return this.pagedRequest(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
projectKey,
)}/repos`,
options,
);
}
private async pagedRequest(
endpoint: string,
options?: ListOptions,
): Promise<PagedResponse<any>> {
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<PagedResponse<any>>;
}
}
export type ListOptions = {
[key: string]: number | undefined;
limit?: number | undefined;
start?: number | undefined;
};
export type PagedResponse<T> = {
size: number;
limit: number;
start: number;
isLastPage: boolean;
values: T[];
nextPageStart: number;
};
export async function* paginated(
request: (options: ListOptions) => Promise<PagedResponse<any>>,
options?: ListOptions,
) {
const opts = options || { start: 0 };
let res;
do {
res = await request(opts);
opts.start = res.nextPageStart;
for (const item of res.values) {
yield item;
}
} while (!res.isLastPage);
}
@@ -1,21 +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.
*/
export { defaultRepositoryParser } from './BitbucketRepositoryParser';
export type { BitbucketRepositoryParser } from './BitbucketRepositoryParser';
export { BitbucketServerClient, paginated } from './BitbucketServerClient';
export type { PagedResponse } from './BitbucketServerClient';
export type { BitbucketRepository } from './types';
@@ -1,31 +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.
*/
type BitbucketRepositoryBase = {
project: {
key: string;
};
slug: string;
};
export type BitbucketRepository = BitbucketRepositoryBase & {
links: Record<
string,
{
href: string;
}[]
>;
};
@@ -1,17 +0,0 @@
/*
* 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 {};
-17
View File
@@ -5419,23 +5419,6 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-catalog-backend-module-bitbucket@workspace:plugins/catalog-backend-module-bitbucket":
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-bitbucket@workspace:plugins/catalog-backend-module-bitbucket"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-bitbucket-cloud-common": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
msw: ^1.0.0
node-fetch: ^2.6.7
winston: ^3.2.1
languageName: unknown
linkType: soft
"@backstage/plugin-catalog-backend-module-gcp@workspace:plugins/catalog-backend-module-gcp":
version: 0.0.0-use.local
resolution: "@backstage/plugin-catalog-backend-module-gcp@workspace:plugins/catalog-backend-module-gcp"