From e7f1848c4ed74090439938fe50db4601fb349f5b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 2 Sep 2022 16:47:27 +0200 Subject: [PATCH 01/21] Initial draft promoting the plugin analytics api Signed-off-by: Eric Peterson Co-authored-by: Emma White <251374+ewhite1997@users.noreply.github.com> --- .../2022-09-08-fyi-plugin-analytics-api.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 microsite/blog/2022-09-08-fyi-plugin-analytics-api.md diff --git a/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md b/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md new file mode 100644 index 0000000000..24304c0d10 --- /dev/null +++ b/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md @@ -0,0 +1,88 @@ +--- +# prettier-ignore +title: FYI 📣 The Plugin Analytics API +author: Eric Peterson, Spotify +authorURL: https://github.com/iamEAP +authorImageURL: https://avatars.githubusercontent.com/u/3496491?v=4 +--- + +**TL;DR** If you didn't know, now you know: the Backstage plugin analytics API is here to help you understand how developers in your organization are using Backstage. + +## What is the plugin analytics API? + +The plugin analytics API is a [utility api](https://backstage.io/docs/api/utility-apis) available by default in every Backstage instance, intended to bridge the gap between the needs of Backstage integrators and plugin developers. While Backstage integrators want visibility into the plugins they’ve installed, they lack the power to instrument those plugins. And although plugin developers have the power to instrument plugins, they can’t do so without a single, vendor-agnostic way to track events. Enter: the plugin analytics API. + +While “analytics” as a concept can be broad, the goal of the API is narrowly focused: empower those deploying Backstage to understand usage of their instance. The plugin analytics API isn’t designed to solve for observability use-cases like tracing, logging, performance monitoring, error metrics, or alerting. Rather, the API is designed to capture and quantify real user interactions, which can form the basis for metrics like daily active users, top plugins, and more. + +## Start collecting data + +Backstage core (and a few other plugins) are already instrumented with [key events](https://backstage.io/docs/plugins/analytics#key-events) that are ready for you to start collecting and analyzing. + +The simplest way to get started is to use one of the [supported analytics tools](https://backstage.io/docs/plugins/analytics#supported-analytics-tools) and install its provided API implementation like you would any other utility API. For example: + +```tsx +// packages/app/src/apis.ts +import { + analyticsApiRef, + configApiRef, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga'; + +export const apis: AnyApiFactory[] = [ + // Instantiate and register the GA Analytics API Implementation. + createApiFactory({ + api: analyticsApiRef, + deps: { configApi: configApiRef, identityApi: identityApiRef }, + factory: ({ configApi, identityApi }) => + GoogleAnalytics.fromConfig(configApi, { + identityApi, + }), + }), +]; +``` + +If your chosen analytics tool doesn’t have an integration yet, you can write a custom integration by [following these instructions](https://backstage.io/docs/plugins/analytics#writing-integrations). (And if you’re integrating with a publicly available analytics service, as opposed to a custom in-house system, why not [consider contributing it back to the community](https://backstage.io/docs/plugins/create-a-plugin)?) + +## Instrument plugins + +While some key events are already instrumented, there may be important actions in open source plugins that are un-instrumented, not to mention in your custom, InnerSource plugins. Luckily, the plugin analytics API can be leveraged by open source and InnerSource plugins all the same. + +To capture an event, invoke the `useAnalytics()` react hook and call the function it returns when the user performs the action you wish to track (e.g. merging a pull request): + +```tsx +import { useAnalytics } from '@backstage/core-plugin-api'; + +const analytics = useAnalytics(); +analytics.captureEvent('merge', pullRequestName); +``` + +Don’t worry about having to stuff additional levels of detail into just the event action and subject, you can provide extra dimensional data on the `attributes` property, as well as a primary metric on the `value` property, like this: + +```tsx +analytics.captureEvent('merge', pullRequestName, { + value: pullRequestAgeInMinutes, + attributes: { + org: orgName, + repo: repoName, + }, +}); +``` + +In situations where your plugin is tracking multiple events and you want all of those events to share common dimensional data, you can use the ``. Every event captured in child components underneath this context automatically inherits the values you set: + +```tsx +import { AnalyticsContext } from '@backstage/core-plugin-api'; + + + {children} +` to automatically decorate every event with a corresponding plugin ID and an extension name in order to facilitate plugin-level analysis. + +While the above should be enough to get you going, don’t forget to check out [the complete guide to event capture](https://backstage.io/docs/plugins/analytics#capturing-events), which covers event naming considerations, testing, and more. + +## Get involved + +If you didn’t know, now you know! If you’re passionate about data and want to help push the Backstage analytics ecosystem forward, join us in the [#analytics channel on discord](https://discord.com/channels/687207715902193673/1007303347914690610), contribute [integration ideas](https://github.com/backstage/backstage/issues/new?assignees=&labels=plugin&template=plugin_template.md&title=%5BAnalytics+Module%5D+THE+ANALYTICS+TOOL+TO+INTEGRATE), or [suggest a new analytics event](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md&title=[Analytics%20Event]:%20THE+EVENT+TO+CAPTURE). From f70d22e3b75488becc0af9d8561e71d24eed681e Mon Sep 17 00:00:00 2001 From: Simone Corsi Date: Mon, 5 Sep 2022 18:28:07 +0200 Subject: [PATCH 02/21] docs: adds backstage-plugin-ldap-auth Signed-off-by: Simone Corsi --- microsite/data/plugins/backstage-plugin-ldap-auth.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 microsite/data/plugins/backstage-plugin-ldap-auth.yml diff --git a/microsite/data/plugins/backstage-plugin-ldap-auth.yml b/microsite/data/plugins/backstage-plugin-ldap-auth.yml new file mode 100644 index 0000000000..ca91b9bd30 --- /dev/null +++ b/microsite/data/plugins/backstage-plugin-ldap-auth.yml @@ -0,0 +1,9 @@ +--- +title: Backstage Plugin LDAP Auth +author: ImmobiliareLabs +authorUrl: https://github.com/immobiliare +category: Authentication +description: Backstage plugin to authenticate users to an external LDAP server +documentation: https://github.com/immobiliare/backstage-plugin-ldap-auth/blob/main/README.md +iconUrl: https://avatars.githubusercontent.com/u/10090828 +npmPackageName: '@immobiliarelabs/backstage-plugin-ldap-auth' From 858833a4d5d5bbf4b2d1d5418f6ae5a92ec0d5d3 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Wed, 7 Sep 2022 13:00:48 +0200 Subject: [PATCH 03/21] add refresh to connection Signed-off-by: Kiss Miklos --- plugins/catalog-backend/src/database/types.ts | 8 ++++++++ .../src/processing/connectEntityProviders.ts | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 07241f0682..2d30f8bd86 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -157,6 +157,14 @@ export interface ProcessingDatabase { */ refresh(txOpaque: Transaction, options: RefreshOptions): Promise; + /** + * Schedules a refresh for every entity that has a matching set of refresh key stored for it. + */ + refreshByRefreshKeys( + txOpaque: Transaction, + options: RefreshByKeyOptions, + ): Promise; + /** * Lists all ancestors of a given entityRef. * diff --git a/plugins/catalog-backend/src/processing/connectEntityProviders.ts b/plugins/catalog-backend/src/processing/connectEntityProviders.ts index 70a43de589..8325e2bcac 100644 --- a/plugins/catalog-backend/src/processing/connectEntityProviders.ts +++ b/plugins/catalog-backend/src/processing/connectEntityProviders.ts @@ -61,6 +61,16 @@ class Connection implements EntityProviderConnection { } } + async refresh(keys: string[]): Promise { + const db = this.config.processingDatabase; + + await db.transaction(async (tx: any) => { + return db.refreshByRefreshKeys(tx, { + keys, + }); + }); + } + private check(entities: Entity[]) { for (const entity of entities) { try { From 6e63bc43f27d0b72ee17f2273fe7e1267223138b Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Wed, 7 Sep 2022 13:58:39 +0200 Subject: [PATCH 04/21] add changeset Signed-off-by: Kiss Miklos --- .changeset/rude-weeks-retire.md | 5 +++++ plugins/catalog-node/src/api/provider.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/rude-weeks-retire.md diff --git a/.changeset/rude-weeks-retire.md b/.changeset/rude-weeks-retire.md new file mode 100644 index 0000000000..fddddd7a9c --- /dev/null +++ b/.changeset/rude-weeks-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Added refresh function to the EntityProviderConnection to be able to schedule refreshes from entity providers. diff --git a/plugins/catalog-node/src/api/provider.ts b/plugins/catalog-node/src/api/provider.ts index fa7659ee32..437da4e7d6 100644 --- a/plugins/catalog-node/src/api/provider.ts +++ b/plugins/catalog-node/src/api/provider.ts @@ -36,6 +36,7 @@ export interface EntityProviderConnection { * Applies either a full or delta update to the catalog engine. */ applyMutation(mutation: EntityProviderMutation): Promise; + refresh(keys: string[]): Promise; } /** From 652dc0fa0c6b554e0989e38a46a3353d2965b2b5 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Wed, 7 Sep 2022 14:03:47 +0200 Subject: [PATCH 05/21] fix tests Signed-off-by: Kiss Miklos --- .../src/providers/AwsS3EntityProvider.test.ts | 1 + .../src/providers/AzureDevOpsEntityProvider.test.ts | 1 + .../src/BitbucketCloudEntityProvider.test.ts | 1 + .../src/providers/BitbucketServerEntityProvider.test.ts | 2 ++ .../src/providers/GerritEntityProvider.test.ts | 1 + .../src/providers/GitHubEntityProvider.test.ts | 4 ++++ .../src/providers/GitHubOrgEntityProvider.test.ts | 1 + .../src/providers/GitlabDiscoveryEntityProvider.test.ts | 2 ++ .../src/processors/MicrosoftGraphOrgEntityProvider.test.ts | 1 + .../src/modules/core/DefaultLocationStore.test.ts | 2 +- plugins/catalog-node/src/api/provider.ts | 4 ++++ 11 files changed, 19 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts index 9d3bba0e8a..3d59d738cf 100644 --- a/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts +++ b/plugins/catalog-backend-module-aws/src/providers/AwsS3EntityProvider.test.ts @@ -100,6 +100,7 @@ describe('AwsS3EntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = AwsS3EntityProvider.fromConfig(config, { diff --git a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.test.ts b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.test.ts index 1c2c3d0082..c57455d8d6 100644 --- a/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.test.ts +++ b/plugins/catalog-backend-module-azure/src/providers/AzureDevOpsEntityProvider.test.ts @@ -71,6 +71,7 @@ describe('AzureDevOpsEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = AzureDevOpsEntityProvider.fromConfig(config, { diff --git a/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.test.ts index 08b127f69e..641150acbf 100644 --- a/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-cloud/src/BitbucketCloudEntityProvider.test.ts @@ -127,6 +127,7 @@ describe('BitbucketCloudEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = BitbucketCloudEntityProvider.fromConfig(config, { logger, diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts index fe2c81b9b6..39689b6c1d 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts @@ -221,6 +221,7 @@ describe('BitbucketServerEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = BitbucketServerEntityProvider.fromConfig(config, { logger, @@ -296,6 +297,7 @@ describe('BitbucketServerEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = BitbucketServerEntityProvider.fromConfig(config, { logger, diff --git a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.test.ts b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.test.ts index d5be045e6c..d6ad0ff668 100644 --- a/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gerrit/src/providers/GerritEntityProvider.test.ts @@ -83,6 +83,7 @@ describe('GerritEntityProvider', () => { const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; it('discovers projects from the api.', async () => { diff --git a/plugins/catalog-backend-module-github/src/providers/GitHubEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GitHubEntityProvider.test.ts index 871718cc05..63fb77288b 100644 --- a/plugins/catalog-backend-module-github/src/providers/GitHubEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GitHubEntityProvider.test.ts @@ -146,6 +146,7 @@ describe('GitHubEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitHubEntityProvider.fromConfig(config, { @@ -233,6 +234,7 @@ describe('GitHubEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitHubEntityProvider.fromConfig(config, { @@ -306,6 +308,7 @@ describe('GitHubEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitHubEntityProvider.fromConfig(config, { @@ -404,6 +407,7 @@ it('apply full update on scheduled execution with topic exclusion taking priorit const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitHubEntityProvider.fromConfig(config, { diff --git a/plugins/catalog-backend-module-github/src/providers/GitHubOrgEntityProvider.test.ts b/plugins/catalog-backend-module-github/src/providers/GitHubOrgEntityProvider.test.ts index 77d585eae1..2d05346f01 100644 --- a/plugins/catalog-backend-module-github/src/providers/GitHubOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GitHubOrgEntityProvider.test.ts @@ -83,6 +83,7 @@ describe('GitHubOrgEntityProvider', () => { const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const logger = getVoidLogger(); diff --git a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts index 7fb4bf8d09..3f3c4e7f91 100644 --- a/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/providers/GitlabDiscoveryEntityProvider.test.ts @@ -155,6 +155,7 @@ describe('GitlabDiscoveryEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitlabDiscoveryEntityProvider.fromConfig(config, { logger, @@ -253,6 +254,7 @@ describe('GitlabDiscoveryEntityProvider', () => { const schedule = new PersistingTaskRunner(); const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = GitlabDiscoveryEntityProvider.fromConfig(config, { logger, diff --git a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.test.ts b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.test.ts index e34eee0499..ff68235c4c 100644 --- a/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/processors/MicrosoftGraphOrgEntityProvider.test.ts @@ -95,6 +95,7 @@ describe('MicrosoftGraphOrgEntityProvider', () => { }; const entityProviderConnection: EntityProviderConnection = { applyMutation: jest.fn(), + refresh: jest.fn(), }; const provider = MicrosoftGraphOrgEntityProvider.fromConfig( new ConfigReader(config), diff --git a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.test.ts b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.test.ts index dfbc37f6c9..2a9b1f8d65 100644 --- a/plugins/catalog-backend/src/modules/core/DefaultLocationStore.test.ts +++ b/plugins/catalog-backend/src/modules/core/DefaultLocationStore.test.ts @@ -26,7 +26,7 @@ describe('DefaultLocationStore', () => { async function createLocationStore(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); await applyDatabaseMigrations(knex); - const connection = { applyMutation: jest.fn() }; + const connection = { applyMutation: jest.fn(), refresh: jest.fn() }; const store = new DefaultLocationStore(knex); await store.connect(connection); return { store, connection }; diff --git a/plugins/catalog-node/src/api/provider.ts b/plugins/catalog-node/src/api/provider.ts index 437da4e7d6..a29df7bcba 100644 --- a/plugins/catalog-node/src/api/provider.ts +++ b/plugins/catalog-node/src/api/provider.ts @@ -36,6 +36,10 @@ export interface EntityProviderConnection { * Applies either a full or delta update to the catalog engine. */ applyMutation(mutation: EntityProviderMutation): Promise; + + /** + * Schedules a refresh on all of the entities that has a matching refresh key associated with the provided keys. + */ refresh(keys: string[]): Promise; } From fabd576219bea5086293b4a058941ff7ce27f722 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Wed, 7 Sep 2022 14:21:35 +0200 Subject: [PATCH 06/21] add new api-report.md Signed-off-by: Kiss Miklos --- plugins/catalog-node/api-report.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catalog-node/api-report.md b/plugins/catalog-node/api-report.md index 7d12c2e065..7ffae771ef 100644 --- a/plugins/catalog-node/api-report.md +++ b/plugins/catalog-node/api-report.md @@ -126,6 +126,7 @@ export interface EntityProvider { // @public export interface EntityProviderConnection { applyMutation(mutation: EntityProviderMutation): Promise; + refresh(keys: string[]): Promise; } // @public From 9743bc788cd71d2cca37b5b8f5f6f86ab36be79b Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Wed, 7 Sep 2022 17:16:54 +0200 Subject: [PATCH 07/21] use object as param in refresh function Signed-off-by: Kiss Miklos --- .changeset/flat-crabs-love.md | 5 +++++ .changeset/rude-weeks-retire.md | 2 +- .../src/processing/connectEntityProviders.ts | 5 +++-- plugins/catalog-node/api-report.md | 7 ++++++- plugins/catalog-node/src/api/index.ts | 1 + plugins/catalog-node/src/api/provider.ts | 9 ++++++++- 6 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 .changeset/flat-crabs-love.md diff --git a/.changeset/flat-crabs-love.md b/.changeset/flat-crabs-love.md new file mode 100644 index 0000000000..96f566d712 --- /dev/null +++ b/.changeset/flat-crabs-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-node': minor +--- + +Added refresh function to the `EntityProviderConnection` to be able to schedule refreshes from entity providers. diff --git a/.changeset/rude-weeks-retire.md b/.changeset/rude-weeks-retire.md index fddddd7a9c..c2073c5826 100644 --- a/.changeset/rude-weeks-retire.md +++ b/.changeset/rude-weeks-retire.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': minor --- -Added refresh function to the EntityProviderConnection to be able to schedule refreshes from entity providers. +Added the `refresh` function to the Connection of the entity providers. diff --git a/plugins/catalog-backend/src/processing/connectEntityProviders.ts b/plugins/catalog-backend/src/processing/connectEntityProviders.ts index 8325e2bcac..a4795532f0 100644 --- a/plugins/catalog-backend/src/processing/connectEntityProviders.ts +++ b/plugins/catalog-backend/src/processing/connectEntityProviders.ts @@ -22,6 +22,7 @@ import { ProcessingDatabase } from '../database/types'; import { EntityProvider, EntityProviderConnection, + EntityProviderRefreshOptions, EntityProviderMutation, } from '@backstage/plugin-catalog-node'; @@ -61,12 +62,12 @@ class Connection implements EntityProviderConnection { } } - async refresh(keys: string[]): Promise { + async refresh(options: EntityProviderRefreshOptions): Promise { const db = this.config.processingDatabase; await db.transaction(async (tx: any) => { return db.refreshByRefreshKeys(tx, { - keys, + keys: options.keys, }); }); } diff --git a/plugins/catalog-node/api-report.md b/plugins/catalog-node/api-report.md index 7ffae771ef..3873e3376d 100644 --- a/plugins/catalog-node/api-report.md +++ b/plugins/catalog-node/api-report.md @@ -126,7 +126,7 @@ export interface EntityProvider { // @public export interface EntityProviderConnection { applyMutation(mutation: EntityProviderMutation): Promise; - refresh(keys: string[]): Promise; + refresh(options: EntityProviderRefreshOptions): Promise; } // @public @@ -141,6 +141,11 @@ export type EntityProviderMutation = removed: DeferredEntity[]; }; +// @public (undocumented) +export type EntityProviderRefreshOptions = { + keys: string[]; +}; + // @public export type EntityRelationSpec = { source: CompoundEntityRef; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 136c23d20d..66e8d6bd21 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -32,4 +32,5 @@ export type { EntityProvider, EntityProviderConnection, EntityProviderMutation, + EntityProviderRefreshOptions, } from './provider'; diff --git a/plugins/catalog-node/src/api/provider.ts b/plugins/catalog-node/src/api/provider.ts index a29df7bcba..fc9a172e0d 100644 --- a/plugins/catalog-node/src/api/provider.ts +++ b/plugins/catalog-node/src/api/provider.ts @@ -26,6 +26,13 @@ export type EntityProviderMutation = | { type: 'full'; entities: DeferredEntity[] } | { type: 'delta'; added: DeferredEntity[]; removed: DeferredEntity[] }; +/** + * @public + */ +export type EntityProviderRefreshOptions = { + keys: string[]; +}; + /** * The EntityProviderConnection is the connection between the catalog and the entity provider. * The EntityProvider use this connection to add and remove entities from the catalog. @@ -40,7 +47,7 @@ export interface EntityProviderConnection { /** * Schedules a refresh on all of the entities that has a matching refresh key associated with the provided keys. */ - refresh(keys: string[]): Promise; + refresh(options: EntityProviderRefreshOptions): Promise; } /** From c86741a0527d72b5bc80934f53b0965e81aa4cfd Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Wed, 7 Sep 2022 12:05:58 -0400 Subject: [PATCH 08/21] feat(EntityTagPicker): support showing option counts Signed-off-by: Phil Kuang --- .changeset/gorgeous-pillows-retire.md | 5 ++++ plugins/catalog-react/api-report.md | 9 ++++++- .../EntityTagPicker/EntityTagPicker.test.tsx | 24 ++++++++++++++++++- .../EntityTagPicker/EntityTagPicker.tsx | 21 ++++++++++++---- .../src/components/EntityTagPicker/index.ts | 5 +++- 5 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 .changeset/gorgeous-pillows-retire.md diff --git a/.changeset/gorgeous-pillows-retire.md b/.changeset/gorgeous-pillows-retire.md new file mode 100644 index 0000000000..d6d4009ac2 --- /dev/null +++ b/.changeset/gorgeous-pillows-retire.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Support showing counts in option labels of the `EntityTagPicker`. You can enable this by adding the `showCounts` property diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 6e0ba235b7..1ae82f702b 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -364,7 +364,14 @@ export class EntityTagFilter implements EntityFilter { } // @public (undocumented) -export const EntityTagPicker: () => JSX.Element | null; +export const EntityTagPicker: ( + props: EntityTagPickerProps, +) => JSX.Element | null; + +// @public (undocumented) +export type EntityTagPickerProps = { + showCounts?: boolean; +}; // @public export class EntityTextFilter implements EntityFilter { diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 2be521203f..78bdde1994 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -28,7 +28,9 @@ const tags = ['tag1', 'tag2', 'tag3', 'tag4']; describe('', () => { const mockCatalogApiRef = { getEntityFacets: async () => ({ - facets: { 'metadata.tags': tags.map(value => ({ value })) }, + facets: { + 'metadata.tags': tags.map((value, idx) => ({ value, count: idx })), + }, }), } as unknown as CatalogApi; @@ -68,6 +70,26 @@ describe('', () => { ]); }); + it('renders tags with counts', async () => { + const rendered = render( + + + + + , + ); + await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument()); + + fireEvent.click(rendered.getByTestId('tag-picker-expand')); + + expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'tag1 (0)', + 'tag2 (1)', + 'tag3 (2)', + 'tag4 (3)', + ]); + }); + it('respects the query parameter filter value', async () => { const updateFilters = jest.fn(); const queryParameters = { tags: ['tag3'] }; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 39895f229d..25f02e8adc 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -49,7 +49,12 @@ const icon = ; const checkedIcon = ; /** @public */ -export const EntityTagPicker = () => { +export type EntityTagPickerProps = { + showCounts?: boolean; +}; + +/** @public */ +export const EntityTagPicker = (props: EntityTagPickerProps) => { const classes = useStyles(); const { updateFilters, @@ -65,7 +70,9 @@ export const EntityTagPicker = () => { filter: filters.kind?.getCatalogFilters(), }); - return facets[facet].map(({ value }) => value); + return Object.fromEntries( + facets[facet].map(({ value, count }) => [value, count]), + ); }, [filters.kind]); const queryParamTags = useMemo( @@ -91,7 +98,7 @@ export const EntityTagPicker = () => { }); }, [selectedTags, updateFilters]); - if (!availableTags?.length) return null; + if (!Object.keys(availableTags ?? {}).length) return null; return ( @@ -99,7 +106,7 @@ export const EntityTagPicker = () => { Tags setSelectedTags(value)} renderOption={(option, { selected }) => ( @@ -111,7 +118,11 @@ export const EntityTagPicker = () => { checked={selected} /> } - label={option} + label={ + props.showCounts + ? `${option} (${availableTags?.[option]})` + : option + } /> )} size="small" diff --git a/plugins/catalog-react/src/components/EntityTagPicker/index.ts b/plugins/catalog-react/src/components/EntityTagPicker/index.ts index c982d33df0..28c8c844f8 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/index.ts +++ b/plugins/catalog-react/src/components/EntityTagPicker/index.ts @@ -15,4 +15,7 @@ */ export { EntityTagPicker } from './EntityTagPicker'; -export type { CatalogReactEntityTagPickerClassKey } from './EntityTagPicker'; +export type { + CatalogReactEntityTagPickerClassKey, + EntityTagPickerProps, +} from './EntityTagPicker'; From 2bfe26336b79486e02113ee1b00433a598368376 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Sep 2022 18:11:39 +0200 Subject: [PATCH 09/21] microsite: add fold and header for analytics blog post Signed-off-by: Patrik Oldsberg --- .../blog/2022-09-08-fyi-plugin-analytics-api.md | 4 ++++ .../blog/assets/22-09-08/analytics-api-fyi.png | Bin 0 -> 30010 bytes 2 files changed, 4 insertions(+) create mode 100644 microsite/blog/assets/22-09-08/analytics-api-fyi.png diff --git a/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md b/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md index 24304c0d10..4fba242e3e 100644 --- a/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md +++ b/microsite/blog/2022-09-08-fyi-plugin-analytics-api.md @@ -8,6 +8,10 @@ authorImageURL: https://avatars.githubusercontent.com/u/3496491?v=4 **TL;DR** If you didn't know, now you know: the Backstage plugin analytics API is here to help you understand how developers in your organization are using Backstage. +![The Plugin Analytics API](assets/22-09-08/analytics-api-fyi.png) + + + ## What is the plugin analytics API? The plugin analytics API is a [utility api](https://backstage.io/docs/api/utility-apis) available by default in every Backstage instance, intended to bridge the gap between the needs of Backstage integrators and plugin developers. While Backstage integrators want visibility into the plugins they’ve installed, they lack the power to instrument those plugins. And although plugin developers have the power to instrument plugins, they can’t do so without a single, vendor-agnostic way to track events. Enter: the plugin analytics API. diff --git a/microsite/blog/assets/22-09-08/analytics-api-fyi.png b/microsite/blog/assets/22-09-08/analytics-api-fyi.png new file mode 100644 index 0000000000000000000000000000000000000000..444d3fa465b8d1af72503d6afe3e05f9bac3ad30 GIT binary patch literal 30010 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 PBA{_!OC=X>>hpg9nDVzv literal 0 HcmV?d00001 From 67acd209cfc5b764ca9fc424554eac11d7adf9f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 7 Sep 2022 22:24:09 +0200 Subject: [PATCH 10/21] kubernetes-backend: ignore taffydb vulnerability Signed-off-by: Patrik Oldsberg --- plugins/kubernetes-backend/.snyk | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 plugins/kubernetes-backend/.snyk diff --git a/plugins/kubernetes-backend/.snyk b/plugins/kubernetes-backend/.snyk new file mode 100644 index 0000000000..2f19f3fcf7 --- /dev/null +++ b/plugins/kubernetes-backend/.snyk @@ -0,0 +1,10 @@ +# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. +version: v1.22.1 +# ignores vulnerabilities until expiry date; change duration by modifying expiry date +ignore: + SNYK-JS-TAFFYDB-2992450: + - '*': + reason: Development dependency only + expires: 2023-03-07T20:23:00.000Z + created: 2022-09-07T20:23:00.000Z +patch: {} From 54f14d5b88eee65693896124e1013776e377781b Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 7 Sep 2022 17:32:06 -0400 Subject: [PATCH 11/21] Added /validate-entity to the catalog client Signed-off-by: Taras --- .../catalog-client/src/CatalogClient.test.ts | 63 +++++++++++++++++++ packages/catalog-client/src/CatalogClient.ts | 38 ++++++++++- packages/catalog-client/src/types/api.ts | 23 +++++++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 0b838eb4c2..3fc70e25ae 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -295,4 +295,67 @@ describe('CatalogClient', () => { await client.getLocationById('42'); }); }); + + describe('validateEntity', () => { + it('returns valid false when validation fails', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + return res( + ctx.status(400), + ctx.json({ + errors: [ + { + message: 'Missing name', + }, + ], + }), + ); + }), + ); + + expect( + await client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: '', + }, + }, + 'http://example.com', + ), + ).toMatchObject({ + valid: false, + errors: [ + { + message: 'Missing name', + }, + ], + }); + }); + + it('returns valid true when validation fails', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + return res(ctx.status(200), ctx.text('')); + }), + ); + + expect( + await client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'good', + }, + }, + 'http://example.com', + ), + ).toMatchObject({ + valid: true, + errors: undefined, + }); + }); + }); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 8ddc3eb36d..bbc9b0b62d 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -21,7 +21,7 @@ import { stringifyEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError } from '@backstage/errors'; +import { ResponseError, SerializedError } from '@backstage/errors'; import crossFetch from 'cross-fetch'; import { CATALOG_FILTER_EXISTS, @@ -36,6 +36,7 @@ import { Location, GetEntityFacetsRequest, GetEntityFacetsResponse, + ValidateEntityResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; import { FetchApi } from './types/fetch'; @@ -353,6 +354,41 @@ export class CatalogClient implements CatalogApi { ); } + /** + * {@inheritdoc CatalogApi.validateEntity} + */ + async validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise { + const response = await this.fetchApi.fetch( + `${await this.discoveryApi.getBaseUrl('catalog')}/validate-entity`, + { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify({ entity, location }), + }, + ); + + if (response.ok) { + return { + valid: true, + errors: undefined, + }; + } + + const { errors } = (await response.json()) as { errors: SerializedError[] }; + + return { + valid: false, + errors, + }; + } + // // Private methods // diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 389b82c081..d66e2dfaf9 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -15,6 +15,7 @@ */ import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import { SerializedError } from '@backstage/errors'; /** * This symbol can be used in place of a value when passed to filters in e.g. @@ -269,6 +270,16 @@ export type AddLocationResponse = { exists?: boolean; }; +/** + * The response type for {@link CatalogClient.validateEntity} + * + * @public + */ +export type ValidateEntityResponse = { + valid: boolean; + errors?: SerializedError[]; +}; + /** * A client for interacting with the Backstage software catalog through its API. * @@ -390,4 +401,16 @@ export interface CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + + /** + * Validate entity and its location. + * + * @param entity - Entity to validate + * @param location - URL location of the entity + */ + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; } From 93a1072a0a6d51815bbca6fa84e055ee157fd885 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 7 Sep 2022 18:12:01 -0400 Subject: [PATCH 12/21] Updated tests to include validateEntity in mocks Signed-off-by: Taras --- packages/catalog-client/src/CatalogClient.test.ts | 4 ++-- .../src/lib/catalog/CatalogIdentityClient.test.ts | 1 + plugins/badges-backend/src/service/router.test.ts | 1 + .../src/components/CatalogGraphCard/CatalogGraphCard.test.tsx | 1 + .../src/components/CatalogGraphPage/CatalogGraphPage.test.tsx | 1 + .../EntityRelationsGraph/EntityRelationsGraph.test.tsx | 1 + .../components/EntityRelationsGraph/useEntityStore.test.ts | 1 + plugins/catalog-import/src/api/CatalogImportClient.test.ts | 1 + .../StepPrepareCreatePullRequest.test.tsx | 1 + .../components/DefaultExplorePage/DefaultExplorePage.test.tsx | 1 + .../DomainExplorerContent/DomainExplorerContent.test.tsx | 1 + .../GroupsExplorerContent/GroupsExplorerContent.test.tsx | 1 + plugins/fossa/src/components/FossaPage/FossaPage.test.tsx | 1 + plugins/todo-backend/src/service/TodoReaderService.test.ts | 1 + 14 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 3fc70e25ae..9427d78f5c 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -299,7 +299,7 @@ describe('CatalogClient', () => { describe('validateEntity', () => { it('returns valid false when validation fails', async () => { server.use( - rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => { return res( ctx.status(400), ctx.json({ @@ -336,7 +336,7 @@ describe('CatalogClient', () => { it('returns valid true when validation fails', async () => { server.use( - rest.post(`${mockBaseUrl}/validate-entity`, (req, res, ctx) => { + rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => { return res(ctx.status(200), ctx.text('')); }), ); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 587b433ca1..e4d9304892 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -35,6 +35,7 @@ describe('CatalogIdentityClient', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const tokenManager: jest.Mocked = { getToken: jest.fn(), diff --git a/plugins/badges-backend/src/service/router.test.ts b/plugins/badges-backend/src/service/router.test.ts index 1750a55be7..5fc809906f 100644 --- a/plugins/badges-backend/src/service/router.test.ts +++ b/plugins/badges-backend/src/service/router.test.ts @@ -68,6 +68,7 @@ describe('createRouter', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; config = new ConfigReader({ diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 9b354b5f38..74fcd4e501 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -59,6 +59,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; apis = TestApiRegistry.from([catalogApiRef, catalog]); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index d8b7ede2c2..9cca6ef9be 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -92,6 +92,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; wrapper = ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index db6711a0a5..c79f975789 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -119,6 +119,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; Wrapper = ({ children }) => ( diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 74d7423eea..e76b1001c6 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -38,6 +38,7 @@ describe('useEntityStore', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index e26d6c5d6a..30b9f47d99 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -100,6 +100,7 @@ describe('CatalogImportClient', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; let catalogImportClient: CatalogImportClient; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index f3dc1f2144..5ad7e27c4b 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -46,6 +46,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const errorApi: jest.Mocked = { diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx index a79d02a2a0..399112fac6 100644 --- a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -32,6 +32,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index c30e845112..c84363f5c6 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 4488a4d0d9..a4769e8f4f 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -33,6 +33,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const Wrapper = ({ children }: { children?: React.ReactNode }) => ( diff --git a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx index 7ef6c799df..676a3cff3c 100644 --- a/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx +++ b/plugins/fossa/src/components/FossaPage/FossaPage.test.tsx @@ -37,6 +37,7 @@ describe('', () => { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; const fossaApi: jest.Mocked = { getFindingSummary: jest.fn(), diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts index 6dd14afcb4..8077e2ddb9 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.test.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -53,6 +53,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked { refreshEntity: jest.fn(), getEntityAncestors: jest.fn(), getEntityFacets: jest.fn(), + validateEntity: jest.fn(), }; if (entity) { mock.getEntityByRef.mockReturnValue(entity); From 184a610d12de1bbabef326d2443c649c2e1cf105 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 7 Sep 2022 18:20:10 -0400 Subject: [PATCH 13/21] Add missing export and generate api report Signed-off-by: Taras --- packages/catalog-client/api-report.md | 17 +++++++++++++++++ packages/catalog-client/src/types/index.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 964a3c1018..1d66b469a9 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -5,6 +5,7 @@ ```ts import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; +import { SerializedError } from '@backstage/errors'; // @public export type AddLocationRequest = { @@ -65,6 +66,11 @@ export interface CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; } // @public @@ -122,6 +128,11 @@ export class CatalogClient implements CatalogApi { id: string, options?: CatalogRequestOptions, ): Promise; + validateEntity( + entity: Entity, + location: string, + options?: CatalogRequestOptions, + ): Promise; } // @public @@ -196,4 +207,10 @@ type Location_2 = { target: string; }; export { Location_2 as Location }; + +// @public +export type ValidateEntityResponse = { + valid: boolean; + errors?: SerializedError[]; +}; ``` diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 8bf4e34da2..86f7b18ab0 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -27,5 +27,6 @@ export type { Location, GetEntityFacetsRequest, GetEntityFacetsResponse, + ValidateEntityResponse, } from './api'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; From 65d1d4343ff6cf56b8cf28879d6d2891400b572b Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 7 Sep 2022 18:22:07 -0400 Subject: [PATCH 14/21] Added changeset Signed-off-by: Taras --- .changeset/large-books-deny.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/large-books-deny.md diff --git a/.changeset/large-books-deny.md b/.changeset/large-books-deny.md new file mode 100644 index 0000000000..51d2ce38c0 --- /dev/null +++ b/.changeset/large-books-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Adding validateEntity method that calls /validate-entity endpoint From d9e39544be21514f51ed89845257ceca998ae91f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 8 Sep 2022 09:39:49 +0200 Subject: [PATCH 15/21] start chipping away a bit at the yarn3 dependency warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/tame-socks-sniff.md | 6 ++++++ package.json | 1 + packages/app-defaults/package.json | 2 ++ packages/app/package.json | 1 + packages/test-utils/package.json | 1 + yarn.lock | 5 +++++ 6 files changed, 16 insertions(+) create mode 100644 .changeset/tame-socks-sniff.md diff --git a/.changeset/tame-socks-sniff.md b/.changeset/tame-socks-sniff.md new file mode 100644 index 0000000000..b6cca23380 --- /dev/null +++ b/.changeset/tame-socks-sniff.md @@ -0,0 +1,6 @@ +--- +'@backstage/app-defaults': patch +'@backstage/test-utils': patch +--- + +Add missing peer dependencies diff --git a/package.json b/package.json index 312d71858d..3c9e2743cb 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "concurrently": "^7.0.0", "cross-env": "^7.0.0", "e2e-test": "workspace:*", + "eslint": "^8.6.0", "eslint-plugin-notice": "^0.9.10", "fs-extra": "10.1.0", "husky": "^8.0.0", diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json index e7e2b8fbf4..f59ab4b071 100644 --- a/packages/app-defaults/package.json +++ b/packages/app-defaults/package.json @@ -42,6 +42,8 @@ }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0", + "react-router": "6.0.0-beta.0 || ^6.3.0", "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "devDependencies": { diff --git a/packages/app/package.json b/packages/app/package.json index 8052bf39d4..6ed4cadff0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -89,6 +89,7 @@ "@types/jest": "^26.0.7", "@types/jquery": "^3.3.34", "@types/node": "^16.11.26", + "@types/react": "*", "@types/react-dom": "*", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 1e99921b3c..d08a1f3e08 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -50,6 +50,7 @@ "peerDependencies": { "@types/react": "^16.13.1 || ^17.0.0", "react": "^16.13.1 || ^17.0.0", + "react-dom": "^16.13.1 || ^17.0.0", "react-router": "6.0.0-beta.0 || ^6.3.0", "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, diff --git a/yarn.lock b/yarn.lock index 1b47544c1d..ddb1e7f4d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2834,6 +2834,8 @@ __metadata: "@types/react": ^16.13.1 || ^17.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 + react-router: 6.0.0-beta.0 || ^6.3.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown linkType: soft @@ -7503,6 +7505,7 @@ __metadata: peerDependencies: "@types/react": ^16.13.1 || ^17.0.0 react: ^16.13.1 || ^17.0.0 + react-dom: ^16.13.1 || ^17.0.0 react-router: 6.0.0-beta.0 || ^6.3.0 react-router-dom: 6.0.0-beta.0 || ^6.3.0 languageName: unknown @@ -22556,6 +22559,7 @@ __metadata: "@types/jest": ^26.0.7 "@types/jquery": ^3.3.34 "@types/node": ^16.11.26 + "@types/react": "*" "@types/react-dom": "*" "@types/zen-observable": ^0.8.0 cross-env: ^7.0.0 @@ -36131,6 +36135,7 @@ __metadata: concurrently: ^7.0.0 cross-env: ^7.0.0 e2e-test: "workspace:*" + eslint: ^8.6.0 eslint-plugin-notice: ^0.9.10 fs-extra: 10.1.0 husky: ^8.0.0 From 926aa67040a022c19927dfc850fc283b02bfc2a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 8 Sep 2022 13:42:11 +0200 Subject: [PATCH 16/21] app: bump roadie plugins Signed-off-by: Patrik Oldsberg --- packages/app/package.json | 8 +++--- yarn.lock | 54 +++++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index f53035b29f..e9c9632d81 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -66,10 +66,10 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^19.0.3", - "@roadiehq/backstage-plugin-buildkite": "^2.0.0", - "@roadiehq/backstage-plugin-github-insights": "^2.0.0", - "@roadiehq/backstage-plugin-github-pull-requests": "^2.0.0", - "@roadiehq/backstage-plugin-travis-ci": "^2.0.0", + "@roadiehq/backstage-plugin-buildkite": "^2.0.8", + "@roadiehq/backstage-plugin-github-insights": "^2.0.5", + "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", + "@roadiehq/backstage-plugin-travis-ci": "^2.0.5", "history": "^5.0.0", "prop-types": "^15.7.2", "react": "^17.0.2", diff --git a/yarn.lock b/yarn.lock index 9b3a89c02b..e8c07a2c57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12492,9 +12492,9 @@ __metadata: languageName: node linkType: hard -"@roadiehq/backstage-plugin-buildkite@npm:^2.0.0": - version: 2.0.7 - resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.7" +"@roadiehq/backstage-plugin-buildkite@npm:^2.0.8": + version: 2.0.8 + resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.8" dependencies: "@backstage/catalog-model": ^1.0.0 "@backstage/core-components": ^0.11.0 @@ -12506,19 +12506,19 @@ __metadata: "@material-ui/lab": 4.0.0-alpha.57 history: ^5.0.0 moment: ^2.29.1 - react-router: 6.0.0-beta.0 - react-router-dom: 6.0.0-beta.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 react-dom: ^16.13.1 || ^17.0.0 - checksum: cf7ba623c418d3ea7ea56c67b0fe10d6be7fe36531539c31e87b586897fcb9c3474a432239d7d6c01623ff6fc29fb4ae905564a9d2aae11e2ce54b8502cc1c29 + react-router: 6.0.0-beta.0 || ^6.3.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: 589f89b53ff166ca9099ea8d2a5b6f96b87a3daf2e0cb115e94c8880639f69bc25a04cab2926cba0c99dc9bb70a159528c6f1fc10a9fba67e4fb3abe40bce0bf languageName: node linkType: hard -"@roadiehq/backstage-plugin-github-insights@npm:^2.0.0": - version: 2.0.4 - resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.4" +"@roadiehq/backstage-plugin-github-insights@npm:^2.0.5": + version: 2.0.5 + resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.5" dependencies: "@backstage/catalog-model": ^1.0.0 "@backstage/core-components": ^0.11.0 @@ -12534,19 +12534,19 @@ __metadata: "@octokit/types": ^6.14.2 history: ^5.0.0 immer: 9.0.7 - react-router: ^6.0.0-beta.0 react-use: ^17.2.4 zustand: 3.6.9 peerDependencies: react: ^16.13.1 || ^17.0.0 react-dom: ^16.13.1 || ^17.0.0 - checksum: 2cc6eb992e78d7ce32ecce09996bda4eb34d4af3564ab54d180543d3a195a8b6e93f3137724569c49e645d44bfc00f3802285418f690393c456700668be01c25 + react-router: 6.0.0-beta.0 || ^6.3.0 + checksum: 37f99d0478dee6acbbafaf334a2eb1f75c251e96f8b10cead0d6f6dc203667dca51ded935ca704900251c01493b6287a66814f79245410a79ad66c843a3d4803 languageName: node linkType: hard -"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.0.0": - version: 2.2.6 - resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.6" +"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.2.7": + version: 2.2.7 + resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.7" dependencies: "@backstage/catalog-model": ^1.0.0 "@backstage/core-components": ^0.11.0 @@ -12564,18 +12564,18 @@ __metadata: moment: ^2.27.0 msw: ^0.39.2 node-fetch: ^2.6.1 - react-router: 6.0.0-beta.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 react-dom: ^16.13.1 || ^17.0.0 - checksum: 1b6d584a1e6cc668026d9bcdea7e5588ad22084412e5e2c52659aed9da0f90fa5edb81e8d90d2ff67b645e0fb96790b91cb0a1024f1682319c7fb4799b62baa8 + react-router: 6.0.0-beta.0 || ^6.3.0 + checksum: 2453f72c4b853e4d6f43cb27aaf27cbce5d122fbcdc71625b1e1493486acde551798524cc84f792d986397011ab458ba9b7e56eec34cde55660ccd69ea488cb0 languageName: node linkType: hard -"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.0": - version: 2.0.4 - resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.4" +"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.5": + version: 2.0.5 + resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.5" dependencies: "@backstage/catalog-model": ^1.0.0 "@backstage/core-components": ^0.11.0 @@ -12589,13 +12589,13 @@ __metadata: date-fns: ^2.18.0 history: ^5.0.0 moment: ^2.29.1 - react-router: 6.0.0-beta.0 - react-router-dom: 6.0.0-beta.0 react-use: ^17.2.4 peerDependencies: react: ^16.13.1 || ^17.0.0 react-dom: ^16.13.1 || ^17.0.0 - checksum: 1c0f3389f6415d23fe1926e3387433da7f3d22b4a237c3a690c894cfa4c281b21307a3ec51a2c48cfa115727cca182c15017ef1e58a2e9b893d89d06df6db35e + react-router: 6.0.0-beta.0 || ^6.3.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + checksum: bafcef69ca1b36585dbafcf8964d897942bd026f74dad81f372aed9ee1ce496a4821570f462b62b1b8d0cb86013615ad39e103827163bde8cf32037143b05c7a languageName: node linkType: hard @@ -22435,10 +22435,10 @@ __metadata: "@material-ui/lab": 4.0.0-alpha.57 "@octokit/rest": ^19.0.3 "@rjsf/core": ^3.2.1 - "@roadiehq/backstage-plugin-buildkite": ^2.0.0 - "@roadiehq/backstage-plugin-github-insights": ^2.0.0 - "@roadiehq/backstage-plugin-github-pull-requests": ^2.0.0 - "@roadiehq/backstage-plugin-travis-ci": ^2.0.0 + "@roadiehq/backstage-plugin-buildkite": ^2.0.8 + "@roadiehq/backstage-plugin-github-insights": ^2.0.5 + "@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7 + "@roadiehq/backstage-plugin-travis-ci": ^2.0.5 "@testing-library/cypress": ^8.0.2 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 @@ -34556,7 +34556,7 @@ __metadata: languageName: node linkType: hard -"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.0.0-beta.0, react-router@npm:^6.3.0": +"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.3.0": version: 6.3.0 resolution: "react-router@npm:6.3.0" dependencies: From 2a46e3701e291ac499997ecae9bc2d6fbf81290d Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 8 Sep 2022 08:42:07 -0400 Subject: [PATCH 17/21] Integrated feedback from @freben Signed-off-by: Taras --- .changeset/large-books-deny.md | 2 +- packages/catalog-client/api-report.md | 12 ++++++++---- packages/catalog-client/src/CatalogClient.ts | 9 ++++++--- packages/catalog-client/src/types/api.ts | 7 +++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.changeset/large-books-deny.md b/.changeset/large-books-deny.md index 51d2ce38c0..c88d391639 100644 --- a/.changeset/large-books-deny.md +++ b/.changeset/large-books-deny.md @@ -2,4 +2,4 @@ '@backstage/catalog-client': minor --- -Adding validateEntity method that calls /validate-entity endpoint +Adding `validateEntity` method that calls `/validate-entity` endpoint. diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 1d66b469a9..0ad77ce39c 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -209,8 +209,12 @@ type Location_2 = { export { Location_2 as Location }; // @public -export type ValidateEntityResponse = { - valid: boolean; - errors?: SerializedError[]; -}; +export type ValidateEntityResponse = + | { + valid: true; + } + | { + valid: false; + errors: SerializedError[]; + }; ``` diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index bbc9b0b62d..f2b4186bfb 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -21,7 +21,7 @@ import { stringifyEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError, SerializedError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; import crossFetch from 'cross-fetch'; import { CATALOG_FILTER_EXISTS, @@ -377,11 +377,14 @@ export class CatalogClient implements CatalogApi { if (response.ok) { return { valid: true, - errors: undefined, }; } - const { errors } = (await response.json()) as { errors: SerializedError[] }; + if (response.status !== 400) { + throw await ResponseError.fromResponse(response); + } + + const { errors = [] } = await response.json(); return { valid: false, diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index d66e2dfaf9..f483c3454c 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -275,10 +275,9 @@ export type AddLocationResponse = { * * @public */ -export type ValidateEntityResponse = { - valid: boolean; - errors?: SerializedError[]; -}; +export type ValidateEntityResponse = + | { valid: true } + | { valid: false; errors: SerializedError[] }; /** * A client for interacting with the Backstage software catalog through its API. From 321a1b2d3609b0295bb22ae614810b0221580506 Mon Sep 17 00:00:00 2001 From: Simone Corsi Date: Thu, 8 Sep 2022 15:49:31 +0200 Subject: [PATCH 18/21] docs(backstage-plugin-ldap-auth): reviewed labels Signed-off-by: Simone Corsi --- microsite/data/plugins/backstage-plugin-ldap-auth.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/microsite/data/plugins/backstage-plugin-ldap-auth.yml b/microsite/data/plugins/backstage-plugin-ldap-auth.yml index ca91b9bd30..1800f7370c 100644 --- a/microsite/data/plugins/backstage-plugin-ldap-auth.yml +++ b/microsite/data/plugins/backstage-plugin-ldap-auth.yml @@ -1,9 +1,9 @@ --- -title: Backstage Plugin LDAP Auth +title: LDAP Auth author: ImmobiliareLabs authorUrl: https://github.com/immobiliare category: Authentication -description: Backstage plugin to authenticate users to an external LDAP server +description: Authenticate users to an external LDAP server documentation: https://github.com/immobiliare/backstage-plugin-ldap-auth/blob/main/README.md iconUrl: https://avatars.githubusercontent.com/u/10090828 npmPackageName: '@immobiliarelabs/backstage-plugin-ldap-auth' From afcafda62064363d13897f9839009de065df1df8 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 8 Sep 2022 11:20:43 -0400 Subject: [PATCH 19/21] Fix the test Signed-off-by: Taras --- packages/catalog-client/src/CatalogClient.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 9427d78f5c..263bb8971d 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -354,7 +354,6 @@ describe('CatalogClient', () => { ), ).toMatchObject({ valid: true, - errors: undefined, }); }); }); From c5cf37026ec31e83364e3c655e2594588892733e Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 8 Sep 2022 11:55:08 -0400 Subject: [PATCH 20/21] Add test for unexpected error Signed-off-by: Taras --- .../catalog-client/src/CatalogClient.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 263bb8971d..6c9c68efa2 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -356,5 +356,26 @@ describe('CatalogClient', () => { valid: true, }); }); + + it('throws unexpected error', async () => { + server.use( + rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => { + return res(ctx.status(500), ctx.json({})); + }), + ); + + await expect(() => + client.validateEntity( + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'good', + }, + }, + 'http://example.com', + ), + ).rejects.toThrow(/Request failed with 500 Error/); + }); }); }); From a7e82c9b017424f4adeab90fd3962b11fc788979 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 8 Sep 2022 13:01:46 +0200 Subject: [PATCH 21/21] cli: update versions:bump to work with Yarn 3 Signed-off-by: Patrik Oldsberg --- .changeset/weak-radios-sin.md | 5 + .../cli/src/commands/versions/bump.test.ts | 125 ++++-------------- packages/cli/src/lib/run.ts | 2 +- .../cli/src/lib/versioning/packages.test.ts | 60 +++++++-- packages/cli/src/lib/versioning/packages.ts | 51 +++++-- packages/cli/src/lib/yarn.ts | 49 +++++++ 6 files changed, 173 insertions(+), 119 deletions(-) create mode 100644 .changeset/weak-radios-sin.md create mode 100644 packages/cli/src/lib/yarn.ts diff --git a/.changeset/weak-radios-sin.md b/.changeset/weak-radios-sin.md new file mode 100644 index 0000000000..e2aac006d4 --- /dev/null +++ b/.changeset/weak-radios-sin.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated `versions:bump` command to be compatible with Yarn 3. diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index a127d15953..25682a4cd4 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -28,6 +28,7 @@ import { import { YarnInfoInspectData } from '../../lib/versioning/packages'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; +import { NotFoundError } from '@backstage/errors'; // Remove log coloring to simplify log matching jest.mock('chalk', () => ({ @@ -54,7 +55,15 @@ jest.mock('ora', () => ({ jest.mock('../../lib/run', () => { return { run: jest.fn(), - runPlain: jest.fn(), + }; +}); + +const mockFetchPackageInfo = jest.fn(); +jest.mock('../../lib/versioning/packages', () => { + const actual = jest.requireActual('../../lib/versioning/packages'); + return { + ...actual, + fetchPackageInfo: (name: string) => mockFetchPackageInfo(name), }; }); @@ -105,6 +114,14 @@ const lockfileMockResult = `${HEADER} `; describe('bump', () => { + beforeEach(() => { + mockFetchPackageInfo.mockImplementation(async name => ({ + name: name, + 'dist-tags': { + latest: REGISTRY_VERSIONS[name], + }, + })); + }); afterEach(() => { mockFs.restore(); jest.resetAllMocks(); @@ -138,17 +155,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -184,19 +190,10 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(3); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(3); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core-api'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -251,17 +248,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -309,19 +295,9 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(2); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).not.toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(2); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).not.toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -372,17 +348,6 @@ describe('bump', () => { }, }), }); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); @@ -480,17 +445,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -614,17 +568,6 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => - JSON.stringify({ - type: 'inspect', - data: { - name: name, - 'dist-tags': { - latest: REGISTRY_VERSIONS[name], - }, - }, - }), - ); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -672,19 +615,9 @@ describe('bump', () => { 'Version bump complete!', ]); - expect(runObj.runPlain).toHaveBeenCalledTimes(5); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/core', - ); - expect(runObj.runPlain).toHaveBeenCalledWith( - 'yarn', - 'info', - '--json', - '@backstage/theme', - ); + expect(mockFetchPackageInfo).toHaveBeenCalledTimes(5); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core'); + expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme'); expect(runObj.run).toHaveBeenCalledTimes(1); expect(runObj.run).toHaveBeenCalledWith( @@ -743,7 +676,7 @@ describe('bump', () => { jest .spyOn(paths, 'resolveTargetRoot') .mockImplementation((...path) => resolvePath('/', ...path)); - jest.spyOn(runObj, 'runPlain').mockImplementation(async () => ''); + mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope')); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts index 4efb791c30..1ef25cae62 100644 --- a/packages/cli/src/lib/run.ts +++ b/packages/cli/src/lib/run.ts @@ -25,7 +25,7 @@ import { promisify } from 'util'; import { LogFunc } from './logging'; import { assertError, ForwardedError } from '@backstage/errors'; -const execFile = promisify(execFileCb); +export const execFile = promisify(execFileCb); type SpawnOptionsPartialEnv = Omit & { env?: Partial; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index 975be74abd..5a255532f9 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -17,13 +17,20 @@ import mockFs from 'mock-fs'; import path from 'path'; import * as runObj from '../run'; +import * as yarn from '../yarn'; import { fetchPackageInfo, mapDependencies } from './packages'; import { NotFoundError } from '../errors'; jest.mock('../run', () => { return { run: jest.fn(), - runPlain: jest.fn(), + execFile: jest.fn(), + }; +}); + +jest.mock('../yarn', () => { + return { + detectYarnVersion: jest.fn(), }; }); @@ -32,24 +39,55 @@ describe('fetchPackageInfo', () => { jest.resetAllMocks(); }); - it('should forward info', async () => { - jest - .spyOn(runObj, 'runPlain') - .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`); + it('should forward info for yarn classic', async () => { + jest.spyOn(runObj, 'execFile').mockResolvedValue({ + stdout: `{"type":"inspect","data":{"the":"data"}}`, + stderr: '', + }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic'); await expect(fetchPackageInfo('my-package')).resolves.toEqual({ the: 'data', }); - expect(runObj.runPlain).toHaveBeenCalledWith( + expect(runObj.execFile).toHaveBeenCalledWith( 'yarn', - 'info', - '--json', - 'my-package', + ['info', '--json', 'my-package'], + { shell: true }, ); }); - it('should throw if no info', async () => { - jest.spyOn(runObj, 'runPlain').mockResolvedValue(''); + it('should forward info for yarn berry', async () => { + jest + .spyOn(runObj, 'execFile') + .mockResolvedValue({ stdout: `{"the":"data"}`, stderr: '' }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry'); + + await expect(fetchPackageInfo('my-package')).resolves.toEqual({ + the: 'data', + }); + expect(runObj.execFile).toHaveBeenCalledWith( + 'yarn', + ['npm', 'info', '--json', 'my-package'], + { shell: true }, + ); + }); + + it('should throw if no info with yarn classic', async () => { + jest + .spyOn(runObj, 'execFile') + .mockResolvedValue({ stdout: '', stderr: '' }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic'); + + await expect(fetchPackageInfo('my-package')).rejects.toThrow( + new NotFoundError(`No package information found for package my-package`), + ); + }); + + it('should throw if no info with yarn berry', async () => { + jest + .spyOn(runObj, 'execFile') + .mockRejectedValue({ stdout: 'bla bla bla Response Code: 404 bla bla' }); + jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry'); await expect(fetchPackageInfo('my-package')).rejects.toThrow( new NotFoundError(`No package information found for package my-package`), diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index ed7266a692..70229b6fed 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -13,10 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import minimatch from 'minimatch'; import { getPackages } from '@manypkg/get-packages'; -import { runPlain } from '../../lib/run'; import { NotFoundError } from '../errors'; +import { detectYarnVersion } from '../yarn'; +import { execFile } from '../run'; const DEP_TYPES = [ 'dependencies', @@ -48,18 +50,45 @@ type PkgVersionInfo = { export async function fetchPackageInfo( name: string, ): Promise { - const output = await runPlain('yarn', 'info', '--json', name); + const yarnVersion = await detectYarnVersion(); - if (!output) { - throw new NotFoundError(`No package information found for package ${name}`); + const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info']; + try { + const { stdout: output } = await execFile( + 'yarn', + [...cmd, '--json', name], + { shell: true }, + ); + + if (!output) { + throw new NotFoundError( + `No package information found for package ${name}`, + ); + } + + if (yarnVersion === 'berry') { + return JSON.parse(output) as YarnInfoInspectData; + } + + const info = JSON.parse(output) as YarnInfo; + if (info.type !== 'inspect') { + throw new Error(`Received unknown yarn info for ${name}, ${output}`); + } + + return info.data as YarnInfoInspectData; + } catch (error) { + if (yarnVersion === 'classic') { + throw error; + } + + if (error?.stdout.includes('Response Code: 404')) { + throw new NotFoundError( + `No package information found for package ${name}`, + ); + } + + throw error; } - - const info = JSON.parse(output) as YarnInfo; - if (info.type !== 'inspect') { - throw new Error(`Received unknown yarn info for ${name}, ${output}`); - } - - return info.data as YarnInfoInspectData; } /** Map all dependencies in the repo as dependency => dependents */ diff --git a/packages/cli/src/lib/yarn.ts b/packages/cli/src/lib/yarn.ts new file mode 100644 index 0000000000..b6c0383bc2 --- /dev/null +++ b/packages/cli/src/lib/yarn.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2022 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 { assertError, ForwardedError } from '@backstage/errors'; +import { execFile as execFileCb } from 'child_process'; +import { promisify } from 'util'; + +const execFile = promisify(execFileCb); + +const versions = new Map>(); + +export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> { + const cwd = dir ?? process.cwd(); + if (versions.has(cwd)) { + return versions.get(cwd)!; + } + + const promise = Promise.resolve().then(async () => { + try { + const { stdout } = await execFile('yarn', ['--version'], { + shell: true, + cwd, + }); + return stdout.trim().startsWith('1.') ? 'classic' : 'berry'; + } catch (error) { + assertError(error); + if ('stderr' in error) { + process.stderr.write(error.stderr as Buffer); + } + throw new ForwardedError('Failed to determine yarn version', error); + } + }); + + versions.set(cwd, promise); + return promise; +}