From 6c70919f1ad6412d1d7f8bba5e20a184428c965c Mon Sep 17 00:00:00 2001 From: Christopher Kruse Date: Mon, 30 Jan 2023 10:58:49 -0800 Subject: [PATCH 01/17] fix: better error around github teams not found Adds a literate error around the `createGithubRepoWithCollaboratorsAndTopics` function in order to provide that error in the scaffolder logs. Prior to this, the only thing returned was "Not Found," which didn't provide much information for someone trying to determine what failed in the background. Fixes #10160 Signed-off-by: Christopher Kruse --- .changeset/olive-ads-peel.md | 5 ++++ .../actions/builtin/github/helpers.ts | 23 +++++++++++------ .../actions/builtin/publish/github.test.ts | 25 +++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 .changeset/olive-ads-peel.md diff --git a/.changeset/olive-ads-peel.md b/.changeset/olive-ads-peel.md new file mode 100644 index 0000000000..7a7c73ddb7 --- /dev/null +++ b/.changeset/olive-ads-peel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Provide better error messaging when GitHub fails due to missing team definitions diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index dae5e16374..25f343da9e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -190,13 +190,22 @@ export async function createGithubRepoWithCollaboratorsAndTopics( if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); - await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ - org: owner, - team_slug: team, - owner, - repo, - permission: 'admin', - }); + try { + await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug: team, + owner, + repo, + permission: 'admin', + }); + } catch (e) { + if (e.data.message === 'Not Found') { + const message = `Received 'Not Found' from the API; one of org: + ${owner}, team: ${team} or repo: ${repo} was not found within GitHub.`; + logger.warn(message); + throw new Error(message, { cause: e }); + } + } // No need to add access if it's the person who owns the personal account } else if (access && access !== owner) { await client.rest.repos.addCollaborator({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 8ead489ea4..0edeafecfd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -518,6 +518,31 @@ describe('publish:github', () => { }); }); + it('should provide an adequate failure message when adding access', async () => { + mockOctokit.rest.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg.mockRejectedValue({ + status: 404, + data: { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + }, + }); + await expect(action.handler(mockContext)).rejects.toThrow( + "Received 'Not Found' from the API;", + ); + }); + it('should add outside collaborators when provided', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'User' }, From 8cd21b9b596f78758c46a249fd8fde979390c4d2 Mon Sep 17 00:00:00 2001 From: Christopher Kruse Date: Thu, 2 Feb 2023 11:40:44 -0800 Subject: [PATCH 02/17] fix: check for teams before creating repository Shifts the team check for the 'access' parameter to happen prior to the repo creation, so that repositories are not created and have no permissions set on them due to missing teams. Signed-off-by: Christopher Kruse --- .../builtin/github/githubRepoCreate.test.ts | 8 +++ .../actions/builtin/github/helpers.ts | 46 +++++++++------ .../actions/builtin/publish/github.test.ts | 56 ++++++++++++++----- 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts index f15747fd6b..a9ef8c3daa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts @@ -42,6 +42,7 @@ const mockOctokit = { }, teams: { addOrUpdateRepoPermissionsInOrg: jest.fn(), + getByName: jest.fn(), }, }, }; @@ -96,6 +97,13 @@ describe('github:repo:create', () => { data: { type: 'Organization' }, }); + mockOctokit.rest.teams.getByName.mockResolvedValue({ + data: { + name: 'blam', + id: 42, + }, + }); + mockOctokit.rest.repos.createInOrg.mockResolvedValue({ data: {} }); await action.handler(mockContext); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index 25f343da9e..dc8c4cd856 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -135,6 +135,10 @@ export async function createGithubRepoWithCollaboratorsAndTopics( username: owner, }); + if (access?.startsWith(`${owner}/`)) { + await validateAccessTeam(client, access); + } + const repoCreationPromise = user.data.type === 'Organization' ? client.rest.repos.createInOrg({ @@ -190,22 +194,13 @@ export async function createGithubRepoWithCollaboratorsAndTopics( if (access?.startsWith(`${owner}/`)) { const [, team] = access.split('/'); - try { - await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ - org: owner, - team_slug: team, - owner, - repo, - permission: 'admin', - }); - } catch (e) { - if (e.data.message === 'Not Found') { - const message = `Received 'Not Found' from the API; one of org: - ${owner}, team: ${team} or repo: ${repo} was not found within GitHub.`; - logger.warn(message); - throw new Error(message, { cause: e }); - } - } + await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ + org: owner, + team_slug: team, + owner, + repo, + permission: 'admin', + }); // No need to add access if it's the person who owns the personal account } else if (access && access !== owner) { await client.rest.repos.addCollaborator({ @@ -360,3 +355,22 @@ function extractCollaboratorName( if ('user' in collaborator) return collaborator.user; return collaborator.team; } + +async function validateAccessTeam(client: Octokit, access: string) { + const [org, team_slug] = access.split('/'); + try { + // Below rule disabled because of a 'getByName' check for a different library + // incorrectly triggers here. + // eslint-disable-next-line testing-library/no-await-sync-query + await client.rest.teams.getByName({ + org, + team_slug, + }); + } catch (e) { + if (e.response.data.message === 'Not Found') { + const message = `Received 'Not Found' from the API; one of org: + ${org} or team: ${team_slug} was not found within GitHub.`; + throw new Error(message, { cause: e }); + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 0edeafecfd..94b51f3e94 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -45,6 +45,7 @@ const mockOctokit = { replaceAllTopics: jest.fn(), }, teams: { + getByName: jest.fn(), addOrUpdateRepoPermissionsInOrg: jest.fn(), }, }, @@ -96,11 +97,41 @@ describe('publish:github', () => { }); }); + it('should fail to create if the team is not found in the org', async () => { + mockOctokit.rest.users.getByUsername.mockResolvedValue({ + data: { type: 'Organization' }, + }); + + mockOctokit.rest.teams.getByName.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + }, + }, + }); + + await expect(action.handler(mockContext)).rejects.toThrow( + "Received 'Not Found' from the API;", + ); + + expect(mockOctokit.rest.repos.createInOrg).not.toHaveBeenCalled(); + }); + it('should call the githubApis with the correct values for createInOrg', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'Organization' }, }); + mockOctokit.rest.teams.getByName.mockResolvedValue({ + data: { + name: 'blam', + id: 42, + }, + }); + mockOctokit.rest.repos.createInOrg.mockResolvedValue({ data: {} }); await action.handler(mockContext); @@ -523,24 +554,23 @@ describe('publish:github', () => { data: { type: 'User' }, }); - mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({ - data: { - clone_url: 'https://github.com/clone/url.git', - html_url: 'https://github.com/html/url', - }, - }); - - mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg.mockRejectedValue({ - status: 404, - data: { - message: 'Not Found', - documentation_url: - 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + mockOctokit.rest.teams.getByName.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Not Found', + documentation_url: + 'https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions', + }, }, }); await expect(action.handler(mockContext)).rejects.toThrow( "Received 'Not Found' from the API;", ); + + expect( + mockOctokit.rest.repos.createForAuthenticatedUser, + ).not.toHaveBeenCalled(); }); it('should add outside collaborators when provided', async () => { From db10b6ef6583677fa492bb4a8b79aa5d06c16eb9 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 6 Feb 2023 08:54:01 +0100 Subject: [PATCH 03/17] feat(auth): add auth provider for Bitbucket Server Signed-off-by: Katharina Sick --- .changeset/pretty-jars-peel.md | 9 + docs/auth/bitbucket/provider.md | 2 +- docs/auth/bitbucketServer/provider.md | 52 +++ docs/auth/index.md | 1 + packages/app-defaults/src/defaults/apis.ts | 15 + packages/app/src/identityProviders.ts | 7 + packages/backend/src/plugins/auth.ts | 7 + .../BitbucketServerAuth.test.ts | 51 +++ .../bitbucketServer/BitbucketServerAuth.ts | 66 +++ .../auth/bitbucketServer/index.ts | 18 + .../auth/bitbucketServer/types.ts | 35 ++ .../src/apis/implementations/auth/index.ts | 1 + .../src/apis/definitions/auth.ts | 15 + .../src/providers/bitbucketServer/index.ts | 18 + .../bitbucketServer/provider.test.ts | 377 ++++++++++++++++++ .../src/providers/bitbucketServer/provider.ts | 297 ++++++++++++++ plugins/auth-backend/src/providers/index.ts | 1 + .../auth-backend/src/providers/providers.ts | 3 + .../AuthProviders/DefaultProviderSettings.tsx | 9 + 19 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 .changeset/pretty-jars-peel.md create mode 100644 docs/auth/bitbucketServer/provider.md create mode 100644 packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts create mode 100644 packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts create mode 100644 plugins/auth-backend/src/providers/bitbucketServer/index.ts create mode 100644 plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/bitbucketServer/provider.ts diff --git a/.changeset/pretty-jars-peel.md b/.changeset/pretty-jars-peel.md new file mode 100644 index 0000000000..639d4565ed --- /dev/null +++ b/.changeset/pretty-jars-peel.md @@ -0,0 +1,9 @@ +--- +'@backstage/core-plugin-api': minor +'@backstage/app-defaults': minor +'@backstage/core-app-api': minor +'@backstage/plugin-user-settings': minor +'@backstage/plugin-auth-backend': minor +--- + +Added a Bitbucket Server Auth Provider and added its API to the app defaults diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index 36859dab45..b03201893c 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -61,7 +61,7 @@ how this is done. Note that for the Bitbucket provider, you'll want to use factory. The `@backstage/plugin-auth-backend` plugin also comes with two built-in -resolves that can be used if desired. The first one is the +resolvers that can be used if desired. The first one is the `bitbucketUsernameSignInResolver`, which identifies users by matching their Bitbucket username to `bitbucket.org/username` annotations of `User` entities in the catalog. Note that you must populate your catalog with matching entities or diff --git a/docs/auth/bitbucketServer/provider.md b/docs/auth/bitbucketServer/provider.md new file mode 100644 index 0000000000..96c7db263e --- /dev/null +++ b/docs/auth/bitbucketServer/provider.md @@ -0,0 +1,52 @@ +--- +id: provider +title: Bitbucket Server Authentication Provider +sidebar_label: Bitbucket Server +description: Adding Bitbucket Server OAuth as an authentication provider in Backstage +--- + +The Backstage `core-plugin-api` package comes with a Bitbucket Server authentication provider that can authenticate +users using Bitbucket Server. This does **NOT** work with Bitbucket Cloud. + +## Create an Application Link in Bitbucket Server + +To add Bitbucket Server authentication, you must create an outgoing application link. Follow the steps described in +the [Bitbucket Server documentation](https://confluence.atlassian.com/bitbucketserver/configure-an-outgoing-link-1108483656.html) +to create one. + +## Configuration + +The provider configuration can then be added to your `app-config.yaml` under the root `auth` configuration: + +```yaml +auth: + environment: development + providers: + bitbucketServer: + development: + host: bitbucket.org + clientId: ${AUTH_BITBUCKET_SERVER_CLIENT_ID} + clientSecret: ${AUTH_BITBUCKET_SERVER_CLIENT_SECRET} +``` + +The Bitbucket Server provider is a structure with two configuration keys: + +- `clientId`: The client ID that was generated by Bitbucket, e.g. `b0f868455c15dcdff5c5fb5d173ae684`. +- `clientSecret`: The client secret tied to the generated client ID. + +## Adding the provider to the Backstage frontend + +To add the provider to the frontend, add the `bitbucketServerAuthApi` reference and `SignInPage` component as shown +in [Adding the provider to the sign-in page](../index.md#adding-the-provider-to-the-sign-in-page). + +## Using Bitbucket Server for sign-in + +In order to use the Bitbucket Server provider for sign-in, you must configure it with a `signIn.resolver`. See +the [Sign-In Resolver documentation](../identity-resolver.md) for more details on how this is done. Note that for the +Bitbucket Server provider, you'll want to use `bitbucketServer` as the provider ID, +and `providers.bitbucketServer.create` for the provider factory. + +The `@backstage/plugin-auth-backend` plugin also comes with a built-in resolver that can be used if desired. +The `emailMatchingUserEntityProfileEmail` identifies users by matching their Bitbucket Server email address to the email +address of `User` entities in the catalog. Note that you must populate your catalog with matching entities or users will +not be able to sign in with this resolver. diff --git a/docs/auth/index.md b/docs/auth/index.md index fa185c0c23..8e15830aa8 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -18,6 +18,7 @@ Backstage comes with many common authentication providers in the core library: - [Auth0](auth0/provider.md) - [Azure](microsoft/provider.md) - [Bitbucket](bitbucket/provider.md) +- [Bitbucket Server](bitbucketServer/provider.md) - [Cloudflare Access](cloudflare/access.md) - [GitHub](github/provider.md) - [GitLab](gitlab/provider.md) diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 3f5cfc1c58..d3ebddfb84 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -25,6 +25,7 @@ import { GitlabAuth, MicrosoftAuth, BitbucketAuth, + BitbucketServerAuth, OAuthRequestManager, WebStorage, UrlPatternDiscovery, @@ -53,6 +54,7 @@ import { configApiRef, oneloginAuthApiRef, bitbucketAuthApiRef, + bitbucketServerAuthApiRef, atlassianAuthApiRef, } from '@backstage/core-plugin-api'; import { @@ -219,6 +221,19 @@ export const apis = [ environment: configApi.getOptionalString('auth.environment'), }), }), + createApiFactory({ + api: bitbucketServerAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi }) => + BitbucketServerAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['REPO_READ'], + }), + }), createApiFactory({ api: atlassianAuthApiRef, deps: { diff --git a/packages/app/src/identityProviders.ts b/packages/app/src/identityProviders.ts index 5f8c0faf47..66f1460210 100644 --- a/packages/app/src/identityProviders.ts +++ b/packages/app/src/identityProviders.ts @@ -22,6 +22,7 @@ import { microsoftAuthApiRef, oneloginAuthApiRef, bitbucketAuthApiRef, + bitbucketServerAuthApiRef, } from '@backstage/core-plugin-api'; export const providers = [ @@ -67,4 +68,10 @@ export const providers = [ message: 'Sign In using Bitbucket', apiRef: bitbucketAuthApiRef, }, + { + id: 'bitbucket-server-auth-provider', + title: 'Bitbucket Server', + message: 'Sign In using Bitbucket Server', + apiRef: bitbucketServerAuthApiRef, + }, ]; diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 8beda6f3fb..0d92315f92 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -114,6 +114,13 @@ export default async function createPlugin( }, }), + bitbucketServer: providers.bitbucketServer.create({ + signIn: { + resolver: + providers.bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(), + }, + }), + // This is an example of how to configure the OAuth2Proxy provider as well // as how to sign a user in without a matching user entity in the catalog. // You can try it out using `` diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts new file mode 100644 index 0000000000..32e7755a00 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.test.ts @@ -0,0 +1,51 @@ +/* + * 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. + */ + +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; +import BitbucketServerAuth from './BitbucketServerAuth'; + +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('BitbucketServerAuth', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['PUBLIC_REPOS', ['PUBLIC_REPOS']], + ['PROJECT_ADMIN REPO_READ', ['PROJECT_ADMIN', 'REPO_READ']], + [ + 'PROJECT_ADMIN REPO_READ ACCOUNT_WRITE', + ['PROJECT_ADMIN', 'REPO_READ', 'ACCOUNT_WRITE'], + ], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const bitbucketServerAuth = BitbucketServerAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + bitbucketServerAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts new file mode 100644 index 0000000000..dc50eff6ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/BitbucketServerAuth.ts @@ -0,0 +1,66 @@ +/* + * 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. + */ + +import { + BackstageIdentityResponse, + bitbucketServerAuthApiRef, + ProfileInfo, +} from '@backstage/core-plugin-api'; + +import { OAuthApiCreateOptions } from '../types'; +import { OAuth2 } from '../oauth2'; + +export type BitbucketServerAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; + +const DEFAULT_PROVIDER = { + id: 'bitbucketServer', + title: 'Bitbucket Server', + icon: () => null, +}; + +/** + * Implements the OAuth flow to Bitbucket Server. + * @public + */ +export default class BitbucketServerAuth { + static create( + options: OAuthApiCreateOptions, + ): typeof bitbucketServerAuthApiRef.T { + const { + discoveryApi, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + defaultScopes = ['PROJECT_ADMIN'], + } = options; + + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes, + }); + } +} diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts new file mode 100644 index 0000000000..5fcb8f72f2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './types'; +export { default as BitbucketServerAuth } from './BitbucketServerAuth'; diff --git a/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts new file mode 100644 index 0000000000..1041aa8110 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/bitbucketServer/types.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +import { + ProfileInfo, + BackstageIdentityResponse, +} from '@backstage/core-plugin-api'; + +/** + * Session information for Bitbucket Server auth. + * + * @public + */ +export type BitbucketServerSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; diff --git a/packages/core-app-api/src/apis/implementations/auth/index.ts b/packages/core-app-api/src/apis/implementations/auth/index.ts index c4cf520db5..b54e0424da 100644 --- a/packages/core-app-api/src/apis/implementations/auth/index.ts +++ b/packages/core-app-api/src/apis/implementations/auth/index.ts @@ -23,5 +23,6 @@ export * from './saml'; export * from './microsoft'; export * from './onelogin'; export * from './bitbucket'; +export * from './bitbucketServer'; export * from './atlassian'; export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types'; diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index 163ae74806..43597639b6 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -412,6 +412,21 @@ export const bitbucketAuthApiRef: ApiRef< id: 'core.auth.bitbucket', }); +/** + * Provides authentication towards Bitbucket Server APIs. + * + * @public + * @remarks + * + * See {@link https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html#BitbucketOAuth2.0providerAPI-scopes} + * for a full list of supported scopes. + */ +export const bitbucketServerAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.bitbucket-server', +}); + /** * Provides authentication towards Atlassian APIs. * diff --git a/plugins/auth-backend/src/providers/bitbucketServer/index.ts b/plugins/auth-backend/src/providers/bitbucketServer/index.ts new file mode 100644 index 0000000000..cef12cd50d --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { bitbucketServer } from './provider'; +export type { BitbucketServerOAuthResult } from './provider'; diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts new file mode 100644 index 0000000000..127ca71a17 --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts @@ -0,0 +1,377 @@ +/* + * 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. + */ + +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; +import { AuthResolverContext } from '../types'; +import { + BitbucketServerAuthProvider, + BitbucketServerOAuthResult, +} from './provider'; +import { commonByEmailResolver } from '../resolvers'; + +jest.mock('../../lib/passport/PassportStrategyHelper', () => { + return { + ...jest.requireActual('../../lib/passport/PassportStrategyHelper'), + executeFrameHandlerStrategy: jest.fn(), + executeRefreshTokenStrategy: jest.fn(), + executeFetchUserProfileStrategy: jest.fn(), + }; +}); + +const mockFrameHandler = jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown as jest.MockedFunction< + () => Promise<{ + result: BitbucketServerOAuthResult; + privateInfo: { refreshToken?: string }; + }> +>; + +const passportProfile = { + id: '123', + username: 'john.doe', + provider: 'bitubcketServer', + displayName: 'John Doe', + emails: [{ value: 'john@doe.com' }], + photos: [{ value: 'https://bitbucket.org/user/123/avatar' }], +}; + +const mockFetchUserRequests = ( + failOnWhoAmI: boolean = false, + whoAmIValue: string = passportProfile.username, + failOnGetUser: boolean = false, + getUserOk: boolean = true, + avatarUrl: string = '/user/123/avatar', + setDisplayName: boolean = true, + setUserName: boolean = true, +) => { + const fetchMock = global.fetch as jest.Mock; + if (failOnWhoAmI) { + fetchMock.mockRejectedValueOnce(() => {}); + } else { + fetchMock.mockResolvedValueOnce({ + headers: { get: jest.fn(() => whoAmIValue) }, + }); + } + if (failOnGetUser) { + fetchMock.mockRejectedValueOnce(() => {}); + } else { + fetchMock.mockResolvedValueOnce({ + ok: getUserOk, + json: () => ({ + name: setUserName ? 'john.doe' : undefined, + emailAddress: 'john@doe.com', + id: 123, + displayName: setDisplayName ? 'John Doe' : undefined, + active: true, + slug: 'john.doe', + type: 'NORMAL', + links: { + self: [ + { + href: 'https://bitbucket.org/users/john.doe', + }, + ], + }, + avatarUrl: avatarUrl, + }), + }); + } +}; + +describe('BitbucketServerAuthProvider', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const provider = new BitbucketServerAuthProvider({ + resolverContext: { + signInWithCatalogUser: jest.fn(info => { + return { + token: `token-for-user:${info.filter['spec.profile.email']}`, + }; + }), + } as unknown as AuthResolverContext, + signInResolver: commonByEmailResolver, + authHandler: async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }), + callbackUrl: 'mock', + clientId: 'mock', + clientSecret: 'mock', + host: 'bitbucket.org', + authorizationUrl: 'mock', + tokenUrl: 'mock', + }); + + describe('when transforming to type OAuthResponse', () => { + it('should map to a valid response', async () => { + mockFetchUserRequests(); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + + const expected = { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: 'John Doe', + picture: 'https://bitbucket.org/user/123/avatar', + }, + }; + + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); + }); + it('should throw if whoami fails', async () => { + mockFetchUserRequests(true); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + + await expect(provider.handler({} as any)).rejects.toThrow( + `Failed to retrieve the username of the logged in user`, + ); + }); + it('should throw if whoami returns an invalid response', async () => { + mockFetchUserRequests(false, ''); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + + await expect(provider.handler({} as any)).rejects.toThrow( + `Failed to retrieve the username of the logged in user`, + ); + }); + it('should throw if get user fails', async () => { + mockFetchUserRequests(false, passportProfile.username, true); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + + await expect(provider.handler({} as any)).rejects.toThrow( + `Failed to retrieve the user '${passportProfile.username}'`, + ); + }); + it('should throw if get user is not ok', async () => { + mockFetchUserRequests(false, passportProfile.username, false, false); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + + await expect(provider.handler({} as any)).rejects.toThrow( + `Failed to retrieve the user '${passportProfile.username}'`, + ); + }); + it('should not set an avatar url if not given', async () => { + mockFetchUserRequests(false, passportProfile.username, false, true, ''); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + + const expected = { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: 'John Doe', + }, + }; + + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); + }); + it('should fallback to the username if no displayName is given', async () => { + mockFetchUserRequests( + false, + passportProfile.username, + false, + true, + '/user/123/avatar', + false, + ); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + + const expected = { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: 'john.doe', + picture: 'https://bitbucket.org/user/123/avatar', + }, + }; + + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); + }); + it('should fallback to the user id if no name is given', async () => { + mockFetchUserRequests( + false, + passportProfile.username, + false, + true, + '/user/123/avatar', + false, + false, + ); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + + const expected = { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: '123', + picture: 'https://bitbucket.org/user/123/avatar', + }, + }; + + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: {}, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual(expected); + }); + }); + + describe('when authenticating', () => { + it('should forward the refresh token', async () => { + mockFetchUserRequests(); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: { refreshToken: 'refresh-token' }, + }); + + const response = await provider.handler({} as any); + + const expected = { + response: { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: 'John Doe', + picture: 'https://bitbucket.org/user/123/avatar', + }, + }, + refreshToken: 'refresh-token', + }; + + expect(response).toEqual(expected); + }); + it('should forward a new refresh token on refresh', async () => { + mockFetchUserRequests(); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; + const params = { scope: 'REPO_READ' }; + const mockRefreshToken = jest.spyOn( + helpers, + 'executeRefreshTokenStrategy', + ) as unknown as jest.MockedFunction<() => Promise<{}>>; + mockRefreshToken.mockResolvedValueOnce({ + accessToken, + refreshToken: 'dont-forget-to-send-refresh', + params, + }); + mockFrameHandler.mockResolvedValueOnce({ + result: { fullProfile: passportProfile, accessToken, params }, + privateInfo: { refreshToken: 'refresh-token' }, + }); + + const expected = { + response: { + backstageIdentity: { + token: 'token-for-user:john@doe.com', + }, + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + scope: 'REPO_READ', + }, + profile: { + email: 'john@doe.com', + displayName: 'John Doe', + picture: 'https://bitbucket.org/user/123/avatar', + }, + }, + refreshToken: 'dont-forget-to-send-refresh', + }; + const response = await provider.refresh({ scope: 'REPO_WRITE' } as any); + + expect(response).toEqual(expected); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts new file mode 100644 index 0000000000..397f63733f --- /dev/null +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts @@ -0,0 +1,297 @@ +/* + * Copyright 2023 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 { + encodeState, + OAuthAdapter, + OAuthEnvironmentHandler, + OAuthHandlers, + OAuthProviderOptions, + OAuthRefreshRequest, + OAuthResponse, + OAuthStartRequest, +} from '../../lib/oauth'; +import { Strategy as OAuth2Strategy, VerifyCallback } from 'passport-oauth2'; +import { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, +} from '../../lib/passport'; +import { + AuthHandler, + AuthResolverContext, + OAuthStartResponse, + SignInResolver, +} from '../types'; +import express from 'express'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { PassportProfile } from '../../lib/passport/types'; +import { commonByEmailResolver } from '../resolvers'; + +type PrivateInfo = { + refreshToken: string; +}; + +export type BitbucketServerOAuthResult = { + fullProfile: PassportProfile; + params: { + scope: string; + access_token?: string; + token_type?: string; + expires_in?: number; + }; + accessToken: string; + refreshToken?: string; +}; + +export type BitbucketServerAuthProviderOptions = OAuthProviderOptions & { + host: string; + authorizationUrl: string; + tokenUrl: string; + authHandler: AuthHandler; + signInResolver?: SignInResolver; + resolverContext: AuthResolverContext; +}; + +export class BitbucketServerAuthProvider implements OAuthHandlers { + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly resolverContext: AuthResolverContext; + private readonly strategy: OAuth2Strategy; + private readonly host: string; + + constructor(options: BitbucketServerAuthProviderOptions) { + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.resolverContext = options.resolverContext; + this.strategy = new OAuth2Strategy( + { + authorizationURL: options.authorizationUrl, + tokenURL: options.tokenUrl, + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + }, + ( + accessToken: string, + refreshToken: string, + params: any, + fullProfile: PassportProfile, + done: VerifyCallback, + ) => { + done(undefined, { fullProfile, params, accessToken }, { refreshToken }); + }, + ); + this.host = options.host; + } + + async start(req: OAuthStartRequest): Promise { + return await executeRedirectStrategy(req, this.strategy, { + accessType: 'offline', + prompt: 'consent', + scope: req.scope, + state: encodeState(req.state), + }); + } + + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { + const { result, privateInfo } = await executeFrameHandlerStrategy< + BitbucketServerOAuthResult, + PrivateInfo + >(req, this.strategy); + + return { + response: await this.handleResult(result), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh( + req: OAuthRefreshRequest, + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { + const { accessToken, refreshToken, params } = + await executeRefreshTokenStrategy( + this.strategy, + req.refreshToken, + req.scope, + ); + const fullProfile = await executeFetchUserProfileStrategy( + this.strategy, + accessToken, + ); + return { + response: await this.handleResult({ + fullProfile, + params, + accessToken, + }), + refreshToken, + }; + } + + private async handleResult( + result: BitbucketServerOAuthResult, + ): Promise { + // The OAuth2 strategy does not return a user profile -> let's fetch it before calling the auth handler + result.fullProfile = await this.fetchProfile(result); + const { profile } = await this.authHandler(result, this.resolverContext); + + let backstageIdentity = undefined; + if (this.signInResolver) { + backstageIdentity = await this.signInResolver( + { result, profile }, + this.resolverContext, + ); + } + + return { + providerInfo: { + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + backstageIdentity, + }; + } + + private async fetchProfile( + result: BitbucketServerOAuthResult, + ): Promise { + // Get current user name + let whoAmIResponse; + try { + whoAmIResponse = await fetch( + `https://${this.host}/plugins/servlet/applinks/whoami`, + { + headers: { + Authorization: `Bearer ${result.accessToken}`, + }, + }, + ); + } catch (e) { + throw new Error(`Failed to retrieve the username of the logged in user`); + } + + // A response.ok check here would be worthless as the Bitbucket API always returns 200 OK for this call + const username = whoAmIResponse.headers.get('X-Ausername'); + if (!username) { + throw new Error(`Failed to retrieve the username of the logged in user`); + } + + let userResponse; + try { + userResponse = await fetch( + `https://${this.host}/rest/api/latest/users/${username}?avatarSize=256`, + { + headers: { + Authorization: `Bearer ${result.accessToken}`, + }, + }, + ); + } catch (e) { + throw new Error(`Failed to retrieve the user '${username}'`); + } + + if (!userResponse.ok) { + throw new Error(`Failed to retrieve the user '${username}'`); + } + + const user = await userResponse.json(); + + return { + provider: 'bitbucketServer', + id: user.id.toString(), + displayName: user.displayName, + username: user.name, + emails: [ + { + value: user.emailAddress, + }, + ], + avatarUrl: user.avatarUrl + ? `https://${this.host}${user.avatarUrl}` + : undefined, + }; + } +} + +export const bitbucketServer = createAuthProviderIntegration({ + create(options?: { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + authHandler?: AuthHandler; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + */ + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const host = envConfig.getString('host'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://${host}/rest/oauth2/latest/authorize`; + const tokenUrl = `https://${host}/rest/oauth2/latest/token`; + + const authHandler: AuthHandler = + options?.authHandler + ? options.authHandler + : async ({ fullProfile }) => ({ + profile: makeProfileInfo(fullProfile), + }); + + const provider = new BitbucketServerAuthProvider({ + callbackUrl, + clientId, + clientSecret, + host, + authorizationUrl, + tokenUrl, + authHandler, + signInResolver: options?.signIn?.resolver, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + providerId, + callbackUrl, + }); + }); + }, + resolvers: { + /** + * Looks up the user by matching their email to the entity email. + */ + emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + }, +}); diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 67d3a62990..a922065fd8 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -19,6 +19,7 @@ export type { BitbucketOAuthResult, BitbucketPassportProfile, } from './bitbucket'; +export type { BitbucketServerOAuthResult } from './bitbucketServer'; export type { CloudflareAccessClaims, CloudflareAccessGroup, diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index b2795c25f0..aa630b7481 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -31,6 +31,7 @@ import { okta } from './okta'; import { onelogin } from './onelogin'; import { saml } from './saml'; import { AuthProviderFactory } from './types'; +import { bitbucketServer } from './bitbucketServer'; /** * All built-in auth provider integrations. @@ -42,6 +43,7 @@ export const providers = Object.freeze({ auth0, awsAlb, bitbucket, + bitbucketServer, cfAccess, gcpIap, github, @@ -76,5 +78,6 @@ export const defaultAuthProviderFactories: { onelogin: onelogin.create(), awsalb: awsAlb.create(), bitbucket: bitbucket.create(), + bitbucketServer: bitbucketServer.create(), atlassian: atlassian.create(), }; diff --git a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx index f8e1fb190c..772ec80982 100644 --- a/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx +++ b/plugins/user-settings/src/components/AuthProviders/DefaultProviderSettings.tsx @@ -24,6 +24,7 @@ import { oktaAuthApiRef, microsoftAuthApiRef, bitbucketAuthApiRef, + bitbucketServerAuthApiRef, atlassianAuthApiRef, oneloginAuthApiRef, } from '@backstage/core-plugin-api'; @@ -99,6 +100,14 @@ export const DefaultProviderSettings = (props: { icon={Star} /> )} + {configuredProviders.includes('bitbucketServer') && ( + + )} ); }; From 8940c2727986cf51f818571075f9ecf66c4329c1 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 6 Feb 2023 12:04:37 +0100 Subject: [PATCH 04/17] Add API report Signed-off-by: Katharina Sick --- plugins/auth-backend/api-report.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 88cb33863e..e25ed18dfe 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -16,6 +16,7 @@ import { GetEntitiesRequest } from '@backstage/catalog-client'; import { IncomingHttpHeaders } from 'http'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; +import passport from 'passport'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; @@ -132,6 +133,21 @@ export type BitbucketPassportProfile = Profile & { }; }; +// Warning: (ae-missing-release-tag) "BitbucketServerOAuthResult" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type BitbucketServerOAuthResult = { + fullProfile: PassportProfile; + params: { + scope: string; + access_token?: string; + token_type?: string; + expires_in?: number; + }; + accessToken: string; + refreshToken?: string; +}; + // @public export class CatalogIdentityClient { constructor(options: { catalogApi: CatalogApi; tokenManager: TokenManager }); @@ -480,6 +496,23 @@ export const providers: Readonly<{ userIdMatchingUserEntityAnnotation(): SignInResolver; }>; }>; + bitbucketServer: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: Readonly<{ + emailMatchingUserEntityProfileEmail: () => SignInResolver; + }>; + }>; cfAccess: Readonly<{ create: (options: { authHandler?: AuthHandler | undefined; @@ -730,4 +763,8 @@ export type WebMessageResponse = type: 'authorization_response'; error: Error; }; + +// Warnings were encountered during analysis: +// +// src/providers/bitbucketServer/provider.d.ts:6:5 - (ae-forgotten-export) The symbol "PassportProfile" needs to be exported by the entry point index.d.ts ``` From b3f650b1a8546f05779a2d43f8dc61a8658d136e Mon Sep 17 00:00:00 2001 From: Justin De Burgo Date: Mon, 6 Feb 2023 12:36:57 -0600 Subject: [PATCH 05/17] adding dot option to minimatch Signed-off-by: Justin De Burgo --- .../src/ingestion/CatalogRules.test.ts | 20 +++++++++++++++++++ .../src/ingestion/CatalogRules.ts | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts index 34e89fad29..592b1dda21 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -35,6 +35,10 @@ const entity = { }; const location: Record = { + v: { + type: 'url', + target: 'https://github.com/b/c/blob/master/.folder/v.yaml', + }, w: { type: 'url', target: 'https://github.com/b/c/blob/master/w.yaml', @@ -263,4 +267,20 @@ describe('DefaultCatalogRulesEnforcer', () => { expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); }); }); + + it('should allow locations with a hidden folder', () => { + const enforcer = DefaultCatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [ + { + allow: ['Component'], + locations: [{ type: 'url', pattern: 'https://github.com/b/**' }], + }, + ], + }, + }), + ); + expect(enforcer.isAllowed(entity.component, location.v)).toBe(true); + }); }); diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index 677649d4b2..21b2d05a46 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -186,7 +186,10 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { } if ( matcher.pattern && - !minimatch(location?.target, matcher.pattern, { nocase: true }) + !minimatch(location?.target, matcher.pattern, { + nocase: true, + dot: true, + }) ) { continue; } From d19c77cc4140f774989a81a4ba78f78323e9db16 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Tue, 7 Feb 2023 08:55:10 +0100 Subject: [PATCH 06/17] fixed API report errors and regenerated the reports Signed-off-by: Katharina Sick --- packages/core-app-api/api-report.md | 20 +++++++++++++++++++ packages/core-plugin-api/api-report.md | 5 +++++ plugins/auth-backend/api-report.md | 9 +-------- .../src/providers/bitbucketServer/provider.ts | 18 +++++++++++------ 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 133ac22e5b..37f7a98de0 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -22,6 +22,7 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstageIdentityResponse } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; +import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { ConfigReader } from '@backstage/config'; @@ -280,6 +281,25 @@ export class BitbucketAuth { static create(options: OAuthApiCreateOptions): typeof bitbucketAuthApiRef.T; } +// @public +export class BitbucketServerAuth { + // (undocumented) + static create( + options: OAuthApiCreateOptions, + ): typeof bitbucketServerAuthApiRef.T; +} + +// @public +export type BitbucketServerSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt?: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentityResponse; +}; + // @public export type BitbucketSession = { providerInfo: { diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index dc7aa9eb5f..91f05921bf 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -241,6 +241,11 @@ export const bitbucketAuthApiRef: ApiRef< OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi >; +// @public +export const bitbucketServerAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +>; + // @public export type BootErrorPageProps = { step: 'load-config' | 'load-chunk'; diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index e25ed18dfe..8df6079b9f 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -16,7 +16,6 @@ import { GetEntitiesRequest } from '@backstage/catalog-client'; import { IncomingHttpHeaders } from 'http'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; -import passport from 'passport'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Profile } from 'passport'; @@ -133,11 +132,9 @@ export type BitbucketPassportProfile = Profile & { }; }; -// Warning: (ae-missing-release-tag) "BitbucketServerOAuthResult" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export type BitbucketServerOAuthResult = { - fullProfile: PassportProfile; + fullProfile: Profile; params: { scope: string; access_token?: string; @@ -763,8 +760,4 @@ export type WebMessageResponse = type: 'authorization_response'; error: Error; }; - -// Warnings were encountered during analysis: -// -// src/providers/bitbucketServer/provider.d.ts:6:5 - (ae-forgotten-export) The symbol "PassportProfile" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts index 397f63733f..305f07c9d5 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts @@ -40,13 +40,14 @@ import { } from '../types'; import express from 'express'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { PassportProfile } from '../../lib/passport/types'; +import { Profile as PassportProfile } from 'passport'; import { commonByEmailResolver } from '../resolvers'; type PrivateInfo = { refreshToken: string; }; +/** @public */ export type BitbucketServerOAuthResult = { fullProfile: PassportProfile; params: { @@ -216,7 +217,7 @@ export class BitbucketServerAuthProvider implements OAuthHandlers { const user = await userResponse.json(); - return { + const passportProfile = { provider: 'bitbucketServer', id: user.id.toString(), displayName: user.displayName, @@ -226,10 +227,15 @@ export class BitbucketServerAuthProvider implements OAuthHandlers { value: user.emailAddress, }, ], - avatarUrl: user.avatarUrl - ? `https://${this.host}${user.avatarUrl}` - : undefined, - }; + } as PassportProfile; + + if (user.avatarUrl) { + passportProfile.photos = [ + { value: `https://${this.host}${user.avatarUrl}` }, + ]; + } + + return passportProfile; } } From 9f71a2fd20442888ccae9ed5c2da05e7ebfd686d Mon Sep 17 00:00:00 2001 From: Justin De Burgo Date: Tue, 7 Feb 2023 10:22:10 -0600 Subject: [PATCH 07/17] adding changelog Signed-off-by: Justin De Burgo --- .changeset/famous-squids-wonder.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/famous-squids-wonder.md diff --git a/.changeset/famous-squids-wonder.md b/.changeset/famous-squids-wonder.md new file mode 100644 index 0000000000..11ca76ddc1 --- /dev/null +++ b/.changeset/famous-squids-wonder.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Adding dot option to mini match used in catalog rules to allow for catalog imports with hidden folders From bb3e4c3f8d89733097f0d4fbfb44cbbdb165d842 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Tue, 7 Feb 2023 17:30:34 +0100 Subject: [PATCH 08/17] use msw for tests & fix resolver type Signed-off-by: Katharina Sick --- plugins/auth-backend/api-report.md | 2 +- plugins/auth-backend/package.json | 2 + .../bitbucketServer/provider.test.ts | 174 ++++++++++-------- .../src/providers/bitbucketServer/provider.ts | 3 +- plugins/auth-backend/src/setupTests.ts | 3 +- yarn.lock | 4 +- 6 files changed, 105 insertions(+), 83 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 8df6079b9f..032344a4f4 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -507,7 +507,7 @@ export const providers: Readonly<{ | undefined, ) => AuthProviderFactory; resolvers: Readonly<{ - emailMatchingUserEntityProfileEmail: () => SignInResolver; + emailMatchingUserEntityProfileEmail: () => SignInResolver; }>; }>; cfAccess: Readonly<{ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a95465dbad..91fb5bed5b 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -78,6 +78,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@testing-library/jest-dom": "^5.16.5", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", @@ -89,6 +90,7 @@ "@types/passport-saml": "^1.1.3", "@types/passport-strategy": "^0.2.35", "@types/xml2js": "^0.4.7", + "cross-fetch": "^3.1.5", "msw": "^0.49.0", "supertest": "^6.1.3" }, diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts index 127ca71a17..3934cafd36 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts @@ -18,10 +18,16 @@ import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; import { AuthResolverContext } from '../types'; import { + bitbucketServer, BitbucketServerAuthProvider, BitbucketServerOAuthResult, } from './provider'; -import { commonByEmailResolver } from '../resolvers'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { fetch } from 'cross-fetch'; +import { rest } from 'msw'; + +global.fetch = fetch; jest.mock('../../lib/passport/PassportStrategyHelper', () => { return { @@ -51,60 +57,60 @@ const passportProfile = { photos: [{ value: 'https://bitbucket.org/user/123/avatar' }], }; -const mockFetchUserRequests = ( - failOnWhoAmI: boolean = false, - whoAmIValue: string = passportProfile.username, - failOnGetUser: boolean = false, - getUserOk: boolean = true, - avatarUrl: string = '/user/123/avatar', - setDisplayName: boolean = true, - setUserName: boolean = true, -) => { - const fetchMock = global.fetch as jest.Mock; - if (failOnWhoAmI) { - fetchMock.mockRejectedValueOnce(() => {}); - } else { - fetchMock.mockResolvedValueOnce({ - headers: { get: jest.fn(() => whoAmIValue) }, - }); - } - if (failOnGetUser) { - fetchMock.mockRejectedValueOnce(() => {}); - } else { - fetchMock.mockResolvedValueOnce({ - ok: getUserOk, - json: () => ({ - name: setUserName ? 'john.doe' : undefined, - emailAddress: 'john@doe.com', - id: 123, - displayName: setDisplayName ? 'John Doe' : undefined, - active: true, - slug: 'john.doe', - type: 'NORMAL', - links: { - self: [ - { - href: 'https://bitbucket.org/users/john.doe', - }, - ], - }, - avatarUrl: avatarUrl, - }), - }); - } -}; +const mockHost = 'bitbucket.org'; +const mockBaseUrl = `https://${mockHost}`; + +const whoAmIHandler = (options?: { fail?: boolean; value?: string }) => + rest.get( + `${mockBaseUrl}/plugins/servlet/applinks/whoami`, + (_req, res, ctx) => { + if (options?.fail) { + res.networkError('error'); + } + return res( + ctx.status(200), + ctx.set('X-Ausername', options?.value ?? passportProfile.username), + ); + }, + ); + +const getUserHandler = (options?: { + fail?: boolean; + status?: number; + avatarUrl?: string; + noDisplayName?: boolean; + noUserName?: boolean; +}) => + rest.get( + `${mockBaseUrl}/rest/api/latest/users/${passportProfile.username}`, + (_req, res, ctx) => { + if (options?.fail) { + res.networkError('error'); + } + return res( + ctx.status(options?.status ?? 200), + ctx.json({ + name: options?.noUserName ? undefined : 'john.doe', + emailAddress: 'john@doe.com', + id: 123, + displayName: options?.noDisplayName ? undefined : 'John Doe', + active: true, + slug: 'john.doe', + type: 'NORMAL', + links: { + self: [ + { + href: 'https://bitbucket.org/users/john.doe', + }, + ], + }, + avatarUrl: options?.avatarUrl ?? '/user/123/avatar', + }), + ); + }, + ); describe('BitbucketServerAuthProvider', () => { - const originalFetch = global.fetch; - - beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - global.fetch = originalFetch; - }); - const provider = new BitbucketServerAuthProvider({ resolverContext: { signInWithCatalogUser: jest.fn(info => { @@ -113,21 +119,26 @@ describe('BitbucketServerAuthProvider', () => { }; }), } as unknown as AuthResolverContext, - signInResolver: commonByEmailResolver, + signInResolver: + bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(), authHandler: async ({ fullProfile }) => ({ profile: makeProfileInfo(fullProfile), }), callbackUrl: 'mock', clientId: 'mock', clientSecret: 'mock', - host: 'bitbucket.org', + host: mockHost, authorizationUrl: 'mock', tokenUrl: 'mock', }); describe('when transforming to type OAuthResponse', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + it('should map to a valid response', async () => { - mockFetchUserRequests(); + server.use(whoAmIHandler(), getUserHandler()); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; @@ -153,8 +164,10 @@ describe('BitbucketServerAuthProvider', () => { const { response } = await provider.handler({} as any); expect(response).toEqual(expected); }); + it('should throw if whoami fails', async () => { - mockFetchUserRequests(true); + server.use(whoAmIHandler({ fail: true }), getUserHandler()); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; mockFrameHandler.mockResolvedValueOnce({ @@ -166,8 +179,10 @@ describe('BitbucketServerAuthProvider', () => { `Failed to retrieve the username of the logged in user`, ); }); + it('should throw if whoami returns an invalid response', async () => { - mockFetchUserRequests(false, ''); + server.use(whoAmIHandler({ value: '' }), getUserHandler()); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; mockFrameHandler.mockResolvedValueOnce({ @@ -179,8 +194,9 @@ describe('BitbucketServerAuthProvider', () => { `Failed to retrieve the username of the logged in user`, ); }); + it('should throw if get user fails', async () => { - mockFetchUserRequests(false, passportProfile.username, true); + server.use(whoAmIHandler(), getUserHandler({ fail: true })); const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; mockFrameHandler.mockResolvedValueOnce({ @@ -192,8 +208,9 @@ describe('BitbucketServerAuthProvider', () => { `Failed to retrieve the user '${passportProfile.username}'`, ); }); + it('should throw if get user is not ok', async () => { - mockFetchUserRequests(false, passportProfile.username, false, false); + server.use(whoAmIHandler(), getUserHandler({ status: 500 })); const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; mockFrameHandler.mockResolvedValueOnce({ @@ -205,8 +222,9 @@ describe('BitbucketServerAuthProvider', () => { `Failed to retrieve the user '${passportProfile.username}'`, ); }); + it('should not set an avatar url if not given', async () => { - mockFetchUserRequests(false, passportProfile.username, false, true, ''); + server.use(whoAmIHandler(), getUserHandler({ avatarUrl: '' })); const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; @@ -231,15 +249,10 @@ describe('BitbucketServerAuthProvider', () => { const { response } = await provider.handler({} as any); expect(response).toEqual(expected); }); + it('should fallback to the username if no displayName is given', async () => { - mockFetchUserRequests( - false, - passportProfile.username, - false, - true, - '/user/123/avatar', - false, - ); + server.use(whoAmIHandler(), getUserHandler({ noDisplayName: true })); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; @@ -265,16 +278,13 @@ describe('BitbucketServerAuthProvider', () => { const { response } = await provider.handler({} as any); expect(response).toEqual(expected); }); + it('should fallback to the user id if no name is given', async () => { - mockFetchUserRequests( - false, - passportProfile.username, - false, - true, - '/user/123/avatar', - false, - false, + server.use( + whoAmIHandler(), + getUserHandler({ noDisplayName: true, noUserName: true }), ); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; @@ -303,8 +313,12 @@ describe('BitbucketServerAuthProvider', () => { }); describe('when authenticating', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + it('should forward the refresh token', async () => { - mockFetchUserRequests(); + server.use(whoAmIHandler(), getUserHandler()); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; mockFrameHandler.mockResolvedValueOnce({ @@ -334,8 +348,10 @@ describe('BitbucketServerAuthProvider', () => { expect(response).toEqual(expected); }); + it('should forward a new refresh token on refresh', async () => { - mockFetchUserRequests(); + server.use(whoAmIHandler(), getUserHandler()); + const accessToken = '19xasczxcm9n7gacn9jdgm19me'; const params = { scope: 'REPO_READ' }; const mockRefreshToken = jest.spyOn( diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts index 305f07c9d5..cd082ee953 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts @@ -298,6 +298,7 @@ export const bitbucketServer = createAuthProviderIntegration({ /** * Looks up the user by matching their email to the entity email. */ - emailMatchingUserEntityProfileEmail: () => commonByEmailResolver, + emailMatchingUserEntityProfileEmail: + (): SignInResolver => commonByEmailResolver, }, }); diff --git a/plugins/auth-backend/src/setupTests.ts b/plugins/auth-backend/src/setupTests.ts index d3232290a7..c1d649f2ad 100644 --- a/plugins/auth-backend/src/setupTests.ts +++ b/plugins/auth-backend/src/setupTests.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export {}; +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/yarn.lock b/yarn.lock index 1621cdc5b1..7bfbb5ea1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4537,6 +4537,7 @@ __metadata: "@backstage/types": "workspace:^" "@davidzemon/passport-okta-oauth": ^0.0.5 "@google-cloud/firestore": ^6.0.0 + "@testing-library/jest-dom": ^5.16.5 "@types/body-parser": ^1.19.0 "@types/cookie-parser": ^1.4.2 "@types/express": ^4.17.6 @@ -4553,6 +4554,7 @@ __metadata: compression: ^1.7.4 cookie-parser: ^1.4.5 cors: ^2.8.5 + cross-fetch: ^3.1.5 express: ^4.17.1 express-promise-router: ^4.1.0 express-session: ^1.17.1 @@ -13578,7 +13580,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4": +"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4, @testing-library/jest-dom@npm:^5.16.5": version: 5.16.5 resolution: "@testing-library/jest-dom@npm:5.16.5" dependencies: From a3c86a7ed2510df014dcab21f2bb36236370f0ca Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Thu, 19 Jan 2023 15:00:48 -0500 Subject: [PATCH 09/17] feat(entity-feedback): implement plugin Signed-off-by: Phil Kuang --- .changeset/gorgeous-llamas-mate.md | 7 + microsite/data/plugins/entity-feedback.yaml | 10 + microsite/static/img/entity-feedback-logo.png | Bin 0 -> 894 bytes packages/app/package.json | 1 + .../app/src/components/catalog/EntityPage.tsx | 56 +++- packages/backend/package.json | 1 + packages/backend/src/index.ts | 5 + .../backend/src/plugins/entityFeedback.ts | 28 ++ plugins/entity-feedback-backend/.eslintrc.js | 1 + plugins/entity-feedback-backend/README.md | 50 +++ plugins/entity-feedback-backend/api-report.md | 26 ++ .../migrations/20230109124329_init.js | 69 ++++ plugins/entity-feedback-backend/package.json | 50 +++ plugins/entity-feedback-backend/src/index.ts | 22 ++ plugins/entity-feedback-backend/src/run.ts | 33 ++ .../src/service/DatabaseHandler.test.ts | 310 ++++++++++++++++++ .../src/service/DatabaseHandler.ts | 161 +++++++++ .../src/service/router.test.ts | 290 ++++++++++++++++ .../src/service/router.ts | 215 ++++++++++++ .../src/service/standaloneServer.ts | 80 +++++ .../entity-feedback-backend/src/setupTests.ts | 17 + plugins/entity-feedback-common/.eslintrc.js | 1 + plugins/entity-feedback-common/README.md | 3 + plugins/entity-feedback-common/api-report.md | 42 +++ plugins/entity-feedback-common/package.json | 31 ++ plugins/entity-feedback-common/src/index.ts | 52 +++ .../entity-feedback-common/src/setupTests.ts | 16 + plugins/entity-feedback/.eslintrc.js | 1 + plugins/entity-feedback/README.md | 136 ++++++++ plugins/entity-feedback/api-report.md | 180 ++++++++++ .../docs/feedback-response-dialog.png | Bin 0 -> 46890 bytes .../docs/feedback-response-table.png | Bin 0 -> 32787 bytes .../docs/like-dislike-rating.png | Bin 0 -> 8754 bytes .../docs/like-dislike-table.png | Bin 0 -> 39177 bytes .../docs/starred-rating-table.png | Bin 0 -> 32379 bytes .../entity-feedback/docs/starred-rating.png | Bin 0 -> 9677 bytes plugins/entity-feedback/package.json | 63 ++++ .../src/api/EntityFeedbackApi.ts | 49 +++ .../src/api/EntityFeedbackClient.test.ts | 175 ++++++++++ .../src/api/EntityFeedbackClient.ts | 136 ++++++++ plugins/entity-feedback/src/api/index.ts | 18 + .../FeedbackRatingsTable.test.tsx | 117 +++++++ .../FeedbackRatingsTable.tsx | 123 +++++++ .../components/FeedbackRatingsTable/index.ts | 17 + .../FeedbackResponseDialog.test.tsx | 118 +++++++ .../FeedbackResponseDialog.tsx | 176 ++++++++++ .../FeedbackResponseDialog/index.ts | 17 + .../FeedbackResponseTable.test.tsx | 76 +++++ .../FeedbackResponseTable.tsx | 121 +++++++ .../components/FeedbackResponseTable/index.ts | 17 + .../LikeDislikeButtons.test.tsx | 147 +++++++++ .../LikeDislikeButtons/LikeDislikeButtons.tsx | 154 +++++++++ .../components/LikeDislikeButtons/index.ts | 17 + .../LikeDislikeRatingsTable.test.tsx | 42 +++ .../LikeDislikeRatingsTable.tsx | 46 +++ .../LikeDislikeRatingsTable/index.ts | 17 + .../StarredRatingButtons.test.tsx | 159 +++++++++ .../StarredRatingButtons.tsx | 160 +++++++++ .../components/StarredRatingButtons/index.ts | 17 + .../StarredRatingsTable.test.tsx | 44 +++ .../StarredRatingsTable.tsx | 44 +++ .../components/StarredRatingsTable/index.ts | 17 + .../entity-feedback/src/components/index.ts | 25 ++ plugins/entity-feedback/src/index.ts | 32 ++ plugins/entity-feedback/src/plugin.test.ts | 23 ++ plugins/entity-feedback/src/plugin.tsx | 212 ++++++++++++ plugins/entity-feedback/src/routes.ts | 21 ++ plugins/entity-feedback/src/setupTests.ts | 18 + yarn.lock | 72 +++- 69 files changed, 4381 insertions(+), 3 deletions(-) create mode 100644 .changeset/gorgeous-llamas-mate.md create mode 100644 microsite/data/plugins/entity-feedback.yaml create mode 100644 microsite/static/img/entity-feedback-logo.png create mode 100644 packages/backend/src/plugins/entityFeedback.ts create mode 100644 plugins/entity-feedback-backend/.eslintrc.js create mode 100644 plugins/entity-feedback-backend/README.md create mode 100644 plugins/entity-feedback-backend/api-report.md create mode 100644 plugins/entity-feedback-backend/migrations/20230109124329_init.js create mode 100644 plugins/entity-feedback-backend/package.json create mode 100644 plugins/entity-feedback-backend/src/index.ts create mode 100644 plugins/entity-feedback-backend/src/run.ts create mode 100644 plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts create mode 100644 plugins/entity-feedback-backend/src/service/DatabaseHandler.ts create mode 100644 plugins/entity-feedback-backend/src/service/router.test.ts create mode 100644 plugins/entity-feedback-backend/src/service/router.ts create mode 100644 plugins/entity-feedback-backend/src/service/standaloneServer.ts create mode 100644 plugins/entity-feedback-backend/src/setupTests.ts create mode 100644 plugins/entity-feedback-common/.eslintrc.js create mode 100644 plugins/entity-feedback-common/README.md create mode 100644 plugins/entity-feedback-common/api-report.md create mode 100644 plugins/entity-feedback-common/package.json create mode 100644 plugins/entity-feedback-common/src/index.ts create mode 100644 plugins/entity-feedback-common/src/setupTests.ts create mode 100644 plugins/entity-feedback/.eslintrc.js create mode 100644 plugins/entity-feedback/README.md create mode 100644 plugins/entity-feedback/api-report.md create mode 100644 plugins/entity-feedback/docs/feedback-response-dialog.png create mode 100644 plugins/entity-feedback/docs/feedback-response-table.png create mode 100644 plugins/entity-feedback/docs/like-dislike-rating.png create mode 100644 plugins/entity-feedback/docs/like-dislike-table.png create mode 100644 plugins/entity-feedback/docs/starred-rating-table.png create mode 100644 plugins/entity-feedback/docs/starred-rating.png create mode 100644 plugins/entity-feedback/package.json create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackApi.ts create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts create mode 100644 plugins/entity-feedback/src/api/EntityFeedbackClient.ts create mode 100644 plugins/entity-feedback/src/api/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx create mode 100644 plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingButtons/index.ts create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx create mode 100644 plugins/entity-feedback/src/components/StarredRatingsTable/index.ts create mode 100644 plugins/entity-feedback/src/components/index.ts create mode 100644 plugins/entity-feedback/src/index.ts create mode 100644 plugins/entity-feedback/src/plugin.test.ts create mode 100644 plugins/entity-feedback/src/plugin.tsx create mode 100644 plugins/entity-feedback/src/routes.ts create mode 100644 plugins/entity-feedback/src/setupTests.ts diff --git a/.changeset/gorgeous-llamas-mate.md b/.changeset/gorgeous-llamas-mate.md new file mode 100644 index 0000000000..a2553875e9 --- /dev/null +++ b/.changeset/gorgeous-llamas-mate.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-entity-feedback-backend': minor +'@backstage/plugin-entity-feedback-common': minor +'@backstage/plugin-entity-feedback': minor +--- + +Implement entity feedback plugin, check out the `README.md` for more details! diff --git a/microsite/data/plugins/entity-feedback.yaml b/microsite/data/plugins/entity-feedback.yaml new file mode 100644 index 0000000000..c40d0450ca --- /dev/null +++ b/microsite/data/plugins/entity-feedback.yaml @@ -0,0 +1,10 @@ +--- +title: Entity Feedback +author: Phil Kuang +authorUrl: https://github.com/kuangp +category: Quality +description: Give and view feedback on entities available in the Backstage catalog. +documentation: https://github.com/backstage/backstage/tree/master/plugins/entity-feedback +iconUrl: img/entity-feedback-logo.png +npmPackageName: '@backstage/plugin-entity-feedback' +addedDate: '2023-01-20' diff --git a/microsite/static/img/entity-feedback-logo.png b/microsite/static/img/entity-feedback-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e172b260a0027bb669bb105d8e1e095061ae3691 GIT binary patch literal 894 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G|oWRD45dJguM!v-tY$DUh!@P+6=(yLU`z6LcLCBs@Y8vBJ&@uo z@Q5r1(g|SvA=~LZ0|Qfor;B4q#=WaEyt73dMciKdB^W~l)A?;2c4uuf`B3_RF>mjh zS?Ag15;kVJ$yYR-4lVw5fY~SP?5n1HhShvGVoU!qORSt_uKS10VEwwap=VzkZMeW( zC$eMWBJZ!4#0vKDFw4h$*!H%wow1?Z^)q+FuCf@HsBP=(jgQHE)>2S%m0DrRwe8Kr zCHgg*m)_qozP`h2d%Q{gS$o&%&#qK{Jl(hF9z($m!~8QXDG$25zOq_KO=>Kix+>D5 zCXTJaSnI6(0~ZgrX(6+o+I?K!kbc1N7-x}m22*K1^8twqVVTQ*ANhZVW8KyX?Ty)5 zN7WQKKbjXevETS;^xd(rNVqY^zNC}S1$2Bkh9WnO-Ts5`sYZ4bNZylJ@pwe zm#izd?`=P4#QJ7+yV<*$>%CjwNtAFsn~>(^VixoB)C;G5zfyZDlOMX5CQGv2tG@T& z$>m?vkK?Oyck?iBJ~l(Q|M&dk55AuJaaZUEyYPl(`z}{5dh53~bU~o$`ZjsRTZj6y z%{eNv6T1FYJuwy(%S}*=sK|a0w&BCI1FPje@VCp|lghAu5Z?Bw`EBBZT#)e28;|sL ze$43PKJa(ThyM)j;UAo*Gfv+NOq8l6t`Q|Ei6yC4$wjF^iowXh$XM6FP}k5h#K_1B rh)m724GgRd4E!9{8lz~)%}>cptHiCrURJapsDZ)L)z4*}Q$iB}C{%i? literal 0 HcmV?d00001 diff --git a/packages/app/package.json b/packages/app/package.json index 1b3841ceae..d6a05d58fc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -30,6 +30,7 @@ "@backstage/plugin-code-coverage": "workspace:^", "@backstage/plugin-cost-insights": "workspace:^", "@backstage/plugin-dynatrace": "workspace:^", + "@backstage/plugin-entity-feedback": "workspace:^", "@backstage/plugin-explore": "workspace:^", "@backstage/plugin-gcalendar": "workspace:^", "@backstage/plugin-gcp-projects": "workspace:^", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 3566800f0b..4b7722008e 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -25,7 +25,7 @@ import { RELATION_PART_OF, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; -import { EmptyState } from '@backstage/core-components'; +import { EmptyState, InfoCard } from '@backstage/core-components'; import { EntityApiDefinitionCard, EntityConsumedApisCard, @@ -79,6 +79,11 @@ import { DynatraceTab, isDynatraceAvailable, } from '@backstage/plugin-dynatrace'; +import { + EntityFeedbackResponseContent, + EntityLikeDislikeRatingsCard, + LikeDislikeButtons, +} from '@backstage/plugin-entity-feedback'; import { EntityGithubActionsContent, EntityRecentGithubActionsRunsCard, @@ -374,6 +379,12 @@ const overviewContent = ( + + + + + + {cicdCard} @@ -511,6 +522,10 @@ const serviceEntityPage = ( > + + + + ); @@ -590,6 +605,10 @@ const websiteEntityPage = ( + + + + ); @@ -606,6 +625,10 @@ const defaultEntityPage = ( + + + + ); @@ -642,6 +665,11 @@ const apiPage = ( + + + + + @@ -654,6 +682,10 @@ const apiPage = ( + + + + ); @@ -671,6 +703,9 @@ const userPage = ( entityFilterKind={customEntityFilterKind} /> + + + @@ -693,6 +728,9 @@ const groupPage = ( + + + @@ -718,6 +756,11 @@ const systemPage = ( + + + + + @@ -746,6 +789,9 @@ const systemPage = ( unidirectional={false} /> + + + ); @@ -763,8 +809,16 @@ const domainPage = ( + + + + + + + + ); diff --git a/packages/backend/package.json b/packages/backend/package.json index 3678c1f9ae..34a7498927 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -42,6 +42,7 @@ "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/plugin-code-coverage-backend": "workspace:^", + "@backstage/plugin-entity-feedback-backend": "workspace:^", "@backstage/plugin-events-backend": "workspace:^", "@backstage/plugin-events-node": "workspace:^", "@backstage/plugin-explore-backend": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b68b162e52..495dc435c1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -44,6 +44,7 @@ import azureDevOps from './plugins/azure-devops'; import catalog from './plugins/catalog'; import catalogEventBasedProviders from './plugins/catalogEventBasedProviders'; import codeCoverage from './plugins/codecoverage'; +import entityFeedback from './plugins/entityFeedback'; import events from './plugins/events'; import explore from './plugins/explore'; import kubernetes from './plugins/kubernetes'; @@ -147,6 +148,9 @@ async function main() { ); const permissionEnv = useHotMemoize(module, () => createEnv('permission')); const playlistEnv = useHotMemoize(module, () => createEnv('playlist')); + const entityFeedbackEnv = useHotMemoize(module, () => + createEnv('entityFeedback'), + ); const eventsEnv = useHotMemoize(module, () => createEnv('events')); const exploreEnv = useHotMemoize(module, () => createEnv('explore')); const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse')); @@ -179,6 +183,7 @@ async function main() { apiRouter.use('/permission', await permission(permissionEnv)); apiRouter.use('/playlist', await playlist(playlistEnv)); apiRouter.use('/explore', await explore(exploreEnv)); + apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv)); apiRouter.use('/adr', await adr(adrEnv)); apiRouter.use(notFoundHandler()); diff --git a/packages/backend/src/plugins/entityFeedback.ts b/packages/backend/src/plugins/entityFeedback.ts new file mode 100644 index 0000000000..c37c3c9a97 --- /dev/null +++ b/packages/backend/src/plugins/entityFeedback.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 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 { createRouter } from '@backstage/plugin-entity-feedback-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default function createPlugin(env: PluginEnvironment): Promise { + return createRouter({ + database: env.database, + discovery: env.discovery, + identity: env.identity, + logger: env.logger, + }); +} diff --git a/plugins/entity-feedback-backend/.eslintrc.js b/plugins/entity-feedback-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback-backend/README.md b/plugins/entity-feedback-backend/README.md new file mode 100644 index 0000000000..ef180dbae5 --- /dev/null +++ b/plugins/entity-feedback-backend/README.md @@ -0,0 +1,50 @@ +# Entity Feedback Backend + +Welcome to the entity-feedback backend plugin! + +## Installation + +### Install the package + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-entity-feedback-backend +``` + +### Adding the plugin to your `packages/backend` + +You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/entityFeedback.ts` + +```tsx +import { createRouter } from '@backstage/plugin-entity-feedback-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default function createPlugin(env: PluginEnvironment): Promise { + return createRouter({ + database: env.database, + discovery: env.discovery, + identity: env.identity, + logger: env.logger, + }); +} +``` + +With the `entityFeedback.ts` router setup in place, add the router to `packages/backend/src/index.ts`: + +```diff ++import entityFeedback from './plugins/entityFeedback'; + +async function main() { + ... + const createEnv = makeCreateEnv(config); + + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); ++ const entityFeedbackEnv = useHotMemoize(module, () => createEnv('entityFeedback')); + + const apiRouter = Router(); ++ apiRouter.use('/entity-feedback', await entityFeedback(entityFeedbackEnv)); + ... + apiRouter.use(notFoundHandler()); + +``` diff --git a/plugins/entity-feedback-backend/api-report.md b/plugins/entity-feedback-backend/api-report.md new file mode 100644 index 0000000000..3909ac3056 --- /dev/null +++ b/plugins/entity-feedback-backend/api-report.md @@ -0,0 +1,26 @@ +## API Report File for "@backstage/plugin-entity-feedback-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import express from 'express'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: Logger; +} +``` diff --git a/plugins/entity-feedback-backend/migrations/20230109124329_init.js b/plugins/entity-feedback-backend/migrations/20230109124329_init.js new file mode 100644 index 0000000000..42a48d264c --- /dev/null +++ b/plugins/entity-feedback-backend/migrations/20230109124329_init.js @@ -0,0 +1,69 @@ +/* + * Copyright 2023 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. + */ + +// @ts-check + +/** + * @param { import("knex").Knex } knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('ratings', table => { + table.comment('Ratings table'); + table + .string('entity_ref') + .notNullable() + .comment('The ref of the entity being rated'); + table + .string('user_ref') + .notNullable() + .comment('The user applying the rating'); + table.string('rating').notNullable().comment('The rating value'); + table + .timestamp('timestamp') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('When the rating was recorded'); + }); + + await knex.schema.createTable('responses', table => { + table.comment('Responses table'); + table + .string('entity_ref') + .notNullable() + .comment('The ref of the applicable entity'); + table.string('user_ref').notNullable().comment('The user responding'); + table.text('response').comment('The serialized response'); + table.text('comments').comment('Additional user comments'); + table + .boolean('consent') + .defaultTo(true) + .notNullable() + .comment('Whether user (if recorded) consents to being contacted'); + table + .timestamp('timestamp') + .defaultTo(knex.fn.now()) + .notNullable() + .comment('When the response was recorded'); + }); +}; + +/** + * @param { import("knex").Knex } knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('ratings'); + await knex.schema.dropTable('responses'); +}; diff --git a/plugins/entity-feedback-backend/package.json b/plugins/entity-feedback-backend/package.json new file mode 100644 index 0000000000..487d5f5d5e --- /dev/null +++ b/plugins/entity-feedback-backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-entity-feedback-backend", + "version": "0.1.0", + "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" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-entity-feedback-common": "workspace:^", + "@types/express": "*", + "express": "^4.18.1", + "express-promise-router": "^4.1.0", + "knex": "^2.0.0", + "node-fetch": "^2.6.7", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@types/supertest": "^2.0.12", + "msw": "^0.49.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/plugins/entity-feedback-backend/src/index.ts b/plugins/entity-feedback-backend/src/index.ts new file mode 100644 index 0000000000..b3c4139eed --- /dev/null +++ b/plugins/entity-feedback-backend/src/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 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. + */ + +/** + * Entity Feedback backend plugin + * + * @packageDocumentation + */ +export * from './service/router'; diff --git a/plugins/entity-feedback-backend/src/run.ts b/plugins/entity-feedback-backend/src/run.ts new file mode 100644 index 0000000000..7344f24c17 --- /dev/null +++ b/plugins/entity-feedback-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 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 { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts new file mode 100644 index 0000000000..c6cde95b3c --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.test.ts @@ -0,0 +1,310 @@ +/* + * Copyright 2023 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; + +import { DatabaseHandler } from './DatabaseHandler'; + +describe('DatabaseHandler', () => { + const databases = TestDatabases.create(); + + async function createDatabaseHandler(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + return { + knex, + dbHandler: await DatabaseHandler.create({ database: knex }), + }; + } + + describe.each(databases.eachSupportedId())( + '%p', + databaseId => { + let knex: Knex; + let dbHandler: DatabaseHandler; + + beforeEach(async () => { + ({ knex, dbHandler } = await createDatabaseHandler(databaseId)); + }, 30000); + + afterEach(async () => { + // Clean up after each test + await knex('ratings').del(); + await knex('responses').del(); + }, 30000); + + it('getAllRatedEntities', async () => { + await knex('ratings').insert([ + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'DISLIKE', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'LIKE', + }, + { + entity_ref: 'component:default/library', + user_ref: 'user:default/bar', + rating: 'LIKE', + }, + { + entity_ref: 'component:default/website', + user_ref: 'user:default/foo', + rating: 'LIKE', + }, + ]); + + const entities = await dbHandler.getAllRatedEntities(); + expect(entities.length).toEqual(3); + expect(entities).toEqual( + expect.arrayContaining([ + 'component:default/service', + 'component:default/library', + 'component:default/website', + ]), + ); + }); + + it('getRatingsAggregates', async () => { + await knex('ratings').insert([ + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'DISLIKE', + timestamp: '2004-10-19 10:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'LIKE', + timestamp: '2004-11-19 08:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/bar', + rating: 'LIKE', + timestamp: '2004-11-20 08:23:54', + }, + { + entity_ref: 'component:default/website', + user_ref: 'user:default/foo', + rating: 'LIKE', + timestamp: '2004-11-20 08:23:54', + }, + { + entity_ref: 'component:default/website', + user_ref: 'user:default/test', + rating: 'DISLIKE', + timestamp: '2004-11-20 08:23:54', + }, + ]); + + let ratings = await dbHandler.getRatingsAggregates([ + 'component:default/service', + 'component:default/website', + ]); + expect(ratings.length).toEqual(3); + expect(ratings).toEqual( + expect.arrayContaining([ + { + entityRef: 'component:default/service', + rating: 'LIKE', + count: 2, + }, + { + entityRef: 'component:default/website', + rating: 'DISLIKE', + count: 1, + }, + { + entityRef: 'component:default/website', + rating: 'LIKE', + count: 1, + }, + ]), + ); + + ratings = await dbHandler.getRatingsAggregates([ + 'component:default/website', + ]); + expect(ratings.length).toEqual(2); + expect(ratings).toEqual( + expect.arrayContaining([ + { + entityRef: 'component:default/website', + rating: 'DISLIKE', + count: 1, + }, + { + entityRef: 'component:default/website', + rating: 'LIKE', + count: 1, + }, + ]), + ); + }); + + it('recordRating', async () => { + const newRating = { + userRef: 'user:default/me', + entityRef: 'component:default/service', + rating: 'LIKE', + }; + + await dbHandler.recordRating(newRating); + + const ratings = await knex('ratings').select(); + expect(ratings.length).toEqual(1); + expect(ratings[0]).toEqual( + expect.objectContaining({ + user_ref: newRating.userRef, + entity_ref: newRating.entityRef, + rating: newRating.rating, + timestamp: expect.anything(), + }), + ); + }); + + it('getRatings', async () => { + await knex('ratings').insert([ + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'DISLIKE', + timestamp: '2004-10-19 10:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + rating: 'LIKE', + timestamp: '2004-11-19 08:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/bar', + rating: 'LIKE', + timestamp: '2004-11-20 08:23:54', + }, + { + entity_ref: 'component:default/website', + user_ref: 'user:default/foo', + rating: 'LIKE', + timestamp: '2004-11-20 08:23:54', + }, + ]); + + const ratings = await dbHandler.getRatings('component:default/service'); + expect(ratings.length).toEqual(2); + expect(ratings).toEqual( + expect.arrayContaining([ + { userRef: 'user:default/foo', rating: 'LIKE' }, + { userRef: 'user:default/bar', rating: 'LIKE' }, + ]), + ); + }); + + it('recordResponse', async () => { + const newResponse = { + userRef: 'user:default/me', + entityRef: 'component:default/service', + response: 'blah', + comments: 'here is some feedback', + consent: false, + }; + + await dbHandler.recordResponse(newResponse); + + const responses = await knex('responses').select(); + expect(responses.length).toEqual(1); + expect({ + ...responses[0], + consent: Boolean(responses[0].consent), + }).toEqual( + expect.objectContaining({ + user_ref: newResponse.userRef, + entity_ref: newResponse.entityRef, + response: newResponse.response, + comments: newResponse.comments, + consent: newResponse.consent, + timestamp: expect.anything(), + }), + ); + }); + + it('getResponses', async () => { + await knex('responses').insert([ + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + response: 'blah', + comments: 'here is some feedback', + consent: true, + timestamp: '2004-10-19 10:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/foo', + response: 'asdf', + comments: 'here is new feedback', + consent: false, + timestamp: '2004-11-19 08:23:54', + }, + { + entity_ref: 'component:default/service', + user_ref: 'user:default/bar', + response: 'noop', + comments: 'here is different feedback', + consent: true, + timestamp: '2004-11-20 08:23:54', + }, + { + entity_ref: 'component:default/website', + user_ref: 'user:default/foo', + response: 'cool', + comments: 'no comment', + consent: true, + timestamp: '2004-11-20 08:23:54', + }, + ]); + + const responses = await dbHandler.getResponses( + 'component:default/service', + ); + expect(responses.length).toEqual(2); + expect(responses).toEqual( + expect.arrayContaining([ + { + userRef: 'user:default/foo', + response: 'asdf', + comments: 'here is new feedback', + consent: false, + }, + { + userRef: 'user:default/bar', + response: 'noop', + comments: 'here is different feedback', + consent: true, + }, + ]), + ); + }); + }, + 60000, + ); +}); diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts new file mode 100644 index 0000000000..aeeaffd694 --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2023 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 { resolvePackagePath } from '@backstage/backend-common'; +import { Rating, Response } from '@backstage/plugin-entity-feedback-common'; +import { Knex } from 'knex'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-entity-feedback-backend', + 'migrations', +); + +type RatingsAggregatesResult = { + entityRef: string; + rating: string; + count: number; +}[]; + +type Options = { + database: Knex; +}; + +/** + * @public + */ +export class DatabaseHandler { + static async create(options: Options): Promise { + const { database } = options; + + await database.migrate.latest({ + directory: migrationsDir, + }); + + return new DatabaseHandler(options); + } + + private readonly database: Knex; + + private constructor(options: Options) { + this.database = options.database; + } + + async getAllRatedEntities(): Promise { + return (await this.database('ratings').distinct('entity_ref')).map( + ({ entity_ref }) => entity_ref, + ); + } + + async getRatingsAggregates( + entityRefs: string[], + ): Promise { + return ( + await this.database('ratings') + .whereIn('ratings.entity_ref', entityRefs) + .innerJoin( + this.database('ratings') + .as('latest_ratings') + .select('entity_ref', 'user_ref') + .max('timestamp', { as: 'timestamp' }) + .whereIn('entity_ref', entityRefs) + .groupBy('entity_ref', 'user_ref'), + function joinClause() { + this.on('ratings.entity_ref', '=', 'latest_ratings.entity_ref') + .andOn('ratings.user_ref', '=', 'latest_ratings.user_ref') + .andOn('ratings.timestamp', '=', 'latest_ratings.timestamp'); + }, + ) + .select('ratings.entity_ref', 'rating') + .count('ratings.user_ref as count') + .groupBy('ratings.entity_ref', 'rating') + ).map(({ entity_ref, rating, count }) => ({ + entityRef: entity_ref as string, + rating: rating as string, + count: Number(count), + })); + } + + async recordRating(rating: Rating) { + await this.database('ratings').insert({ + entity_ref: rating.entityRef, + rating: rating.rating, + user_ref: rating.userRef, + }); + } + + async getRatings(entityRef: string): Promise[]> { + return ( + await this.database('ratings') + .where('entity_ref', entityRef) + .innerJoin( + this.database('ratings') + .as('latest_ratings') + .select('user_ref') + .max('timestamp', { as: 'timestamp' }) + .where('entity_ref', entityRef) + .groupBy('user_ref'), + function joinClause() { + this.on('ratings.user_ref', '=', 'latest_ratings.user_ref').andOn( + 'ratings.timestamp', + '=', + 'latest_ratings.timestamp', + ); + }, + ) + .select('ratings.user_ref', 'rating') + ).map(rating => ({ userRef: rating.user_ref, rating: rating.rating })); + } + + async recordResponse(response: Response) { + await this.database('responses').insert({ + entity_ref: response.entityRef, + response: response.response, + comments: response.comments, + consent: response.consent, + user_ref: response.userRef, + }); + } + + async getResponses( + entityRef: string, + ): Promise[]> { + return ( + await this.database('responses') + .where('entity_ref', entityRef) + .innerJoin( + this.database('responses') + .as('latest_responses') + .select('user_ref') + .max('timestamp', { as: 'timestamp' }) + .where('entity_ref', entityRef) + .groupBy('user_ref'), + function joinClause() { + this.on( + 'responses.user_ref', + '=', + 'latest_responses.user_ref', + ).andOn('responses.timestamp', '=', 'latest_responses.timestamp'); + }, + ) + .select('responses.user_ref', 'response', 'comments', 'consent') + ).map(response => ({ + userRef: response.user_ref, + response: response.response, + comments: response.comments, + consent: Boolean(response.consent), + })); + } +} diff --git a/plugins/entity-feedback-backend/src/service/router.test.ts b/plugins/entity-feedback-backend/src/service/router.test.ts new file mode 100644 index 0000000000..74fae8248b --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/router.test.ts @@ -0,0 +1,290 @@ +/* + * Copyright 2023 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 { + DatabaseManager, + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { ConfigReader } from '@backstage/config'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +const sampleOwnedEntities = [ + { + kind: 'component', + metadata: { + namespace: 'default', + name: 'foo', + title: 'Foo Component', + }, + }, + { + kind: 'component', + metadata: { + namespace: 'default', + name: 'bar', + title: 'Bar Component', + }, + }, +]; + +const sampleEntities = [ + ...sampleOwnedEntities, + { + kind: 'user', + metadata: { + namespace: 'default', + name: 'foo', + }, + }, + null, + { + kind: 'user', + metadata: { + namespace: 'default', + name: 'bar', + }, + }, +]; + +const mockGetEntties = jest + .fn() + .mockImplementation(async () => ({ items: sampleOwnedEntities })); + +const mockGetEnttiesByRefs = jest + .fn() + .mockImplementation(async () => ({ items: sampleEntities })); + +jest.mock('@backstage/catalog-client', () => ({ + CatalogClient: jest.fn().mockImplementation(() => ({ + getEntities: mockGetEntties, + getEntitiesByRefs: mockGetEnttiesByRefs, + })), +})); + +jest.mock('@backstage/plugin-auth-node', () => ({ + getBearerTokenFromAuthorizationHeader: () => 'token', +})); + +const mockRatings = [ + { userRef: 'user:default/foo', rating: 'LIKE' }, + { userRef: 'user:default/bar', rating: 'LIKE' }, + { userRef: 'user:default/test', rating: 'DISLIKE' }, +]; + +const mockResponses = [ + { + userRef: 'user:default/foo', + response: 'asdf', + comments: 'here is new feedback', + consent: false, + }, + { + userRef: 'user:default/bar', + response: 'noop', + comments: 'here is different feedback', + consent: true, + }, + { + userRef: 'user:default/test', + response: 'err', + comments: 'no comment', + consent: false, + }, +]; + +const mockDbHandler = { + getAllRatedEntities: jest + .fn() + .mockImplementation(async () => [ + 'component:default/foo', + 'component:default/bar', + 'component:default/test', + ]), + getRatingsAggregates: jest.fn().mockImplementation(async () => [ + { entityRef: 'component:default/foo', rating: 'LIKE', count: 3 }, + { entityRef: 'component:default/foo', rating: 'DISLIKE', count: 1 }, + { entityRef: 'component:default/bar', rating: 'LIKE', count: 5 }, + ]), + recordRating: jest.fn().mockImplementation(async () => {}), + getRatings: jest.fn().mockImplementation(async () => mockRatings), + recordResponse: jest.fn().mockImplementation(async () => {}), + getResponses: jest.fn().mockImplementation(async () => mockResponses), +}; + +jest.mock('./DatabaseHandler', () => ({ + DatabaseHandler: { create: async () => mockDbHandler }, +})); + +describe('createRouter', () => { + let app: express.Express; + + const createDatabase = () => + DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('entity-feedback'); + + const mockIdentityClient = { + getIdentity: jest.fn().mockImplementation(async () => ({ + identity: { userEntityRef: 'user:default/me' }, + })), + } as unknown as IdentityApi; + + const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), + }; + + beforeEach(async () => { + const router = await createRouter({ + database: createDatabase(), + discovery, + identity: mockIdentityClient, + logger: getVoidLogger(), + }); + + app = express().use(router); + jest.clearAllMocks(); + }); + + describe('GET /ratings', () => { + it('should get ratings for all entities correctly', async () => { + const response = await request(app).get('/ratings').send(); + + expect(mockDbHandler.getAllRatedEntities).toHaveBeenCalled(); + expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith( + sampleEntities + .filter(Boolean) + .map((ent: any) => stringifyEntityRef(ent)), + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo Component', + ratings: { LIKE: 3, DISLIKE: 1 }, + }, + { + entityRef: 'component:default/bar', + entityTitle: 'Bar Component', + ratings: { LIKE: 5 }, + }, + ]); + }); + + it('should get ratings for all owned entities correctly', async () => { + const response = await request(app) + .get('/ratings?ownerRef=group:default/test-team') + .send(); + + expect(mockGetEntties).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { 'relations.ownedBy': 'group:default/test-team' }, + }), + { token: 'token' }, + ); + expect(mockDbHandler.getAllRatedEntities).not.toHaveBeenCalled(); + expect(mockDbHandler.getRatingsAggregates).toHaveBeenCalledWith( + sampleOwnedEntities.map((ent: any) => stringifyEntityRef(ent)), + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo Component', + ratings: { LIKE: 3, DISLIKE: 1 }, + }, + { + entityRef: 'component:default/bar', + entityTitle: 'Bar Component', + ratings: { LIKE: 5 }, + }, + ]); + }); + }); + + describe('POST /ratings/:entityRef', () => { + it('should record a rating correctly', async () => { + const body = { rating: 'LIKE' }; + const response = await request(app) + .post('/ratings/component%3Adefault%2Fservice') + .send(body); + expect(mockDbHandler.recordRating).toHaveBeenCalledWith({ + entityRef: 'component:default/service', + userRef: 'user:default/me', + ...body, + }); + expect(response.status).toEqual(201); + }); + }); + + describe('GET /ratings/:entityRef', () => { + it('should get ratings for an entity correctly', async () => { + const response = await request(app) + .get('/ratings/component%3Adefault%2Fservice') + .send(); + expect(mockDbHandler.getRatings).toHaveBeenCalledWith( + 'component:default/service', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual( + mockRatings.filter(r => r.userRef !== 'user:default/test'), + ); + }); + }); + + describe('POST /responses/:entityRef', () => { + it('should record a response correctly', async () => { + const body = { response: 'blah', comments: 'feedback', consent: true }; + const response = await request(app) + .post('/responses/component%3Adefault%2Fservice') + .send(body); + expect(mockDbHandler.recordResponse).toHaveBeenCalledWith({ + entityRef: 'component:default/service', + userRef: 'user:default/me', + ...body, + }); + expect(response.status).toEqual(201); + }); + }); + + describe('GET /responses/:entityRef', () => { + it('should get responses for an entity correctly', async () => { + const response = await request(app) + .get('/responses/component%3Adefault%2Fservice') + .send(); + expect(mockDbHandler.getResponses).toHaveBeenCalledWith( + 'component:default/service', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual( + mockResponses.filter(r => r.userRef !== 'user:default/test'), + ); + }); + }); +}); diff --git a/plugins/entity-feedback-backend/src/service/router.ts b/plugins/entity-feedback-backend/src/service/router.ts new file mode 100644 index 0000000000..e1243cedaf --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/router.ts @@ -0,0 +1,215 @@ +/* + * Copyright 2023 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 { + errorHandler, + PluginDatabaseManager, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { + getBearerTokenFromAuthorizationHeader, + IdentityApi, +} from '@backstage/plugin-auth-node'; +import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; + +import { DatabaseHandler } from './DatabaseHandler'; + +/** + * @public + */ +export interface RouterOptions { + database: PluginDatabaseManager; + discovery: PluginEndpointDiscovery; + identity: IdentityApi; + logger: Logger; +} + +/** + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { database, discovery, identity, logger } = options; + + logger.info('Initializing Entity Feedback backend'); + + const catalogClient = new CatalogClient({ discoveryApi: discovery }); + const db = await database.getClient(); + const dbHandler = await DatabaseHandler.create({ database: db }); + + const router = Router(); + router.use(express.json()); + + router.get('/ratings', async (req, res) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const requestedEntities: { [ref: string]: Entity } = {}; + if (req.query.ownerRef) { + // Get ratings from all owned entities (also ensures only accessible entities are requested) + ( + await catalogClient.getEntities( + { + filter: { 'relations.ownedBy': req.query.ownerRef as string }, + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'metadata.title', + ], + }, + { token }, + ) + ).items.forEach(ent => { + requestedEntities[stringifyEntityRef(ent)] = ent; + }); + } else { + const allRatedEntities = await dbHandler.getAllRatedEntities(); + + // Filter entities to only expose entity refs accessible by current user + ( + await catalogClient.getEntitiesByRefs( + { + entityRefs: allRatedEntities, + fields: [ + 'kind', + 'metadata.namespace', + 'metadata.name', + 'metadata.title', + ], + }, + { token }, + ) + ).items + .filter(Boolean) + .forEach(ent => { + requestedEntities[stringifyEntityRef(ent!)] = ent!; + }); + } + + const entityRatings = await dbHandler.getRatingsAggregates( + Object.keys(requestedEntities), + ); + + // Merge rating aggregates into a condensed per entity structure + const entityRatingsMap = entityRatings.reduce<{ + [ref: string]: EntityRatingsData; + }>((ratingsMap, { entityRef, rating, count }) => { + ratingsMap[entityRef] = ratingsMap[entityRef] ?? { + entityRef, + entityTitle: requestedEntities[entityRef].metadata.title, + ratings: {}, + }; + ratingsMap[entityRef].ratings[rating] = count; + return ratingsMap; + }, {}); + + res.json(Object.values(entityRatingsMap)); + }); + + router.post('/ratings/:entityRef', async (req, res) => { + const user = await identity.getIdentity({ request: req }); + const rating = req.body.rating; + if (!user || !rating) { + res.status(400).end(); + return; + } + + await dbHandler.recordRating({ + entityRef: req.params.entityRef, + rating, + userRef: user.identity.userEntityRef, + }); + + res.status(201).end(); + }); + + router.get('/ratings/:entityRef', async (req, res) => { + const ratings = await dbHandler.getRatings(req.params.entityRef); + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + // Filter ratings via user refs to only expose entity refs accessible by current user + const accessibleEntityRefs = ( + await catalogClient.getEntitiesByRefs( + { + entityRefs: ratings.map(r => r.userRef), + fields: ['kind', 'metadata.namespace', 'metadata.name'], + }, + { token }, + ) + ).items + .filter(Boolean) + .map(ent => stringifyEntityRef(ent!)); + + res.json(ratings.filter(r => accessibleEntityRefs.includes(r.userRef))); + }); + + router.post('/responses/:entityRef', async (req, res) => { + const user = await identity.getIdentity({ request: req }); + const { response, comments, consent } = req.body; + + if (!user) { + res.status(400).end(); + return; + } + + await dbHandler.recordResponse({ + entityRef: req.params.entityRef, + response, + comments, + consent, + userRef: user.identity.userEntityRef, + }); + + res.status(201).end(); + }); + + router.get('/responses/:entityRef', async (req, res) => { + const responses = await dbHandler.getResponses(req.params.entityRef); + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + // Filter responses via user refs to only expose entity refs accessible by current user + const accessibleEntityRefs = ( + await catalogClient.getEntitiesByRefs( + { + entityRefs: responses.map(r => r.userRef), + fields: ['kind', 'metadata.namespace', 'metadata.name'], + }, + { token }, + ) + ).items + .filter(Boolean) + .map(ent => stringifyEntityRef(ent!)); + + res.json(responses.filter(r => accessibleEntityRefs.includes(r.userRef))); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/entity-feedback-backend/src/service/standaloneServer.ts b/plugins/entity-feedback-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..9395c72141 --- /dev/null +++ b/plugins/entity-feedback-backend/src/service/standaloneServer.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2023 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 { + createServiceBuilder, + DatabaseManager, + loadBackendConfig, + SingleHostDiscovery, + useHotMemoize, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'entity-feedback-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + + const database = useHotMemoize(module, () => { + const manager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { client: 'better-sqlite3', connection: ':memory:' }, + }, + }), + ); + return manager.forPlugin('entity-feedback'); + }); + + const identity = DefaultIdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + + logger.debug('Starting application server...'); + const router = await createRouter({ + database, + discovery, + identity, + logger, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/entity-feedback', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/entity-feedback-backend/src/setupTests.ts b/plugins/entity-feedback-backend/src/setupTests.ts new file mode 100644 index 0000000000..aa70772592 --- /dev/null +++ b/plugins/entity-feedback-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 {}; diff --git a/plugins/entity-feedback-common/.eslintrc.js b/plugins/entity-feedback-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback-common/README.md b/plugins/entity-feedback-common/README.md new file mode 100644 index 0000000000..430d14aeab --- /dev/null +++ b/plugins/entity-feedback-common/README.md @@ -0,0 +1,3 @@ +# Entity Feedback Common + +Common types for the entity-feedback plugin. diff --git a/plugins/entity-feedback-common/api-report.md b/plugins/entity-feedback-common/api-report.md new file mode 100644 index 0000000000..35da32c1da --- /dev/null +++ b/plugins/entity-feedback-common/api-report.md @@ -0,0 +1,42 @@ +## API Report File for "@backstage/plugin-entity-feedback-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public (undocumented) +export interface EntityRatingsData { + // (undocumented) + entityRef: string; + // (undocumented) + entityTitle?: string; + // (undocumented) + ratings: { + [ratingValue: string]: number; + }; +} + +// @public (undocumented) +export interface Rating { + // (undocumented) + entityRef: string; + // (undocumented) + rating: string; + // (undocumented) + userRef: string; +} + +// @public (undocumented) +interface Response_2 { + // (undocumented) + comments?: string; + // (undocumented) + consent?: boolean; + // (undocumented) + entityRef: string; + // (undocumented) + response?: string; + // (undocumented) + userRef: string; +} +export { Response_2 as Response }; +``` diff --git a/plugins/entity-feedback-common/package.json b/plugins/entity-feedback-common/package.json new file mode 100644 index 0000000000..45a5a2b131 --- /dev/null +++ b/plugins/entity-feedback-common/package.json @@ -0,0 +1,31 @@ +{ + "name": "@backstage/plugin-entity-feedback-common", + "description": "Common functionalities for the entity-feedback plugin", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "common-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/entity-feedback-common/src/index.ts b/plugins/entity-feedback-common/src/index.ts new file mode 100644 index 0000000000..f19fcdd648 --- /dev/null +++ b/plugins/entity-feedback-common/src/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2023 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. + */ + +/** + * Common functionalities for the entity-feedback plugin. + * + * @packageDocumentation + */ + +/** + * @public + */ +export interface Rating { + entityRef: string; + rating: string; + userRef: string; +} + +/** + * @public + */ +export interface Response { + entityRef: string; + response?: string; + comments?: string; + consent?: boolean; + userRef: string; +} + +/** + * @public + */ +export interface EntityRatingsData { + entityRef: string; + entityTitle?: string; + ratings: { + [ratingValue: string]: number; + }; +} diff --git a/plugins/entity-feedback-common/src/setupTests.ts b/plugins/entity-feedback-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/entity-feedback-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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 {}; diff --git a/plugins/entity-feedback/.eslintrc.js b/plugins/entity-feedback/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/entity-feedback/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/entity-feedback/README.md b/plugins/entity-feedback/README.md new file mode 100644 index 0000000000..fdfa4761d6 --- /dev/null +++ b/plugins/entity-feedback/README.md @@ -0,0 +1,136 @@ +# Entity Feedback Plugin + +Welcome to the entity-feedback plugin! + +This plugin allows you give and view feedback on entities available in the Backstage catalog. + +## Features + +### Rate entities + +#### Like/Dislike rating + +![Like dislike rating example](./docs/like-dislike-rating.png) + +#### Starred rating + +![Starred rating example](./docs/starred-rating.png) + +### Request additional feedback when poorly rated + +![Response dialog example](./docs/feedback-response-dialog.png) + +### View entity feedback responses + +![Feedback responses example](./docs/feedback-response-table.png) + +### View aggregated ratings on owned entities + +#### Total likes/dislikes + +![Like dislike table example](./docs/like-dislike-table.png) + +#### Star breakdowns + +![Starred rating table example](./docs/starred-rating-table.png) + +## Setup + +The following sections will help you get the Entity Feedback plugin setup and running + +### Backend + +You need to setup the [Entity Feedback backend plugin](https://github.com/backstage/backstage/tree/master/plugins/entity-feedback-backend) before you move forward with any of these steps if you haven't already + +### Installation + +Install this plugin: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-entity-feedback +``` + +### Entity Pages + +Add rating and feedback components to your `EntityPage.tsx` to hook up UI so that users +can rate your entities and for owners to view feedback/responses. + +To allow users to apply "Like" and "Dislike" ratings add the following to each kind/type of +entity in your `EntityPage.tsx` you want to be rated (if you prefer to use star ratings, replace +`EntityLikeDislikeRatingsCard` with `EntityStarredRatingsCard` and `LikeDislikeButtons` with +`StarredRatingButtons`): + +```diff +import { + ... ++ InfoCard, + ... +} from '@backstage/core-components'; ++import { ++ EntityFeedbackResponseContent, ++ EntityLikeDislikeRatingsCard, ++ LikeDislikeButtons, ++} from '@backstage/plugin-entity-feedback'; + +// Add to each applicable kind/type of entity as desired +const overviewContent = ( + + ... ++ ++ ++ ++ ++ + ... + +); + +... + +// Add to each applicable kind/type of entity as desired +const serviceEntityPage = ( + + ... ++ ++ ++ + ... + +); + +... + +// Add ratings card component to user/group entities to view ratings of owned entities +const userPage = ( + + + + ... ++ ++ ++ + ... + + + + +); + +const groupPage = ( + + + + ... ++ ++ ++ + ... + + + + +); +``` + +Note: For a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx) diff --git a/plugins/entity-feedback/api-report.md b/plugins/entity-feedback/api-report.md new file mode 100644 index 0000000000..399e84e674 --- /dev/null +++ b/plugins/entity-feedback/api-report.md @@ -0,0 +1,180 @@ +## API Report File for "@backstage/plugin-entity-feedback" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; +import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common'; +import { FetchApi } from '@backstage/core-plugin-api'; +import { Rating } from '@backstage/plugin-entity-feedback-common'; +import { ReactNode } from 'react'; +import { Response as Response_2 } from '@backstage/plugin-entity-feedback-common'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export interface EntityFeedbackApi { + // (undocumented) + getAllRatings(): Promise; + // (undocumented) + getOwnedRatings(ownerRef: string): Promise; + // (undocumented) + getRatings(entityRef: string): Promise[]>; + // (undocumented) + getResponses(entityRef: string): Promise[]>; + // (undocumented) + recordRating(entityRef: string, rating: string): Promise; + // (undocumented) + recordResponse( + entityRef: string, + response: Omit, + ): Promise; +} + +// @public (undocumented) +export const entityFeedbackApiRef: ApiRef; + +// @public (undocumented) +export class EntityFeedbackClient implements EntityFeedbackApi { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + // (undocumented) + getAllRatings(): Promise; + // (undocumented) + getOwnedRatings(ownerRef: string): Promise; + // (undocumented) + getRatings(entityRef: string): Promise[]>; + // (undocumented) + getResponses(entityRef: string): Promise[]>; + // (undocumented) + recordRating(entityRef: string, rating: string): Promise; + // (undocumented) + recordResponse( + entityRef: string, + response: Omit, + ): Promise; +} + +// @public (undocumented) +export const entityFeedbackPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {}, + {} +>; + +// @public (undocumented) +export interface EntityFeedbackResponse { + // (undocumented) + id: string; + // (undocumented) + label: string; +} + +// @public (undocumented) +export const EntityFeedbackResponseContent: () => JSX.Element; + +// @public (undocumented) +export const EntityLikeDislikeRatingsCard: () => JSX.Element; + +// @public (undocumented) +export const EntityStarredRatingsCard: () => JSX.Element; + +// @public (undocumented) +export const FeedbackResponseDialog: ( + props: FeedbackResponseDialogProps, +) => JSX.Element; + +// @public (undocumented) +export interface FeedbackResponseDialogProps { + // (undocumented) + entity: Entity; + // (undocumented) + feedbackDialogResponses?: EntityFeedbackResponse[]; + // (undocumented) + feedbackDialogTitle?: ReactNode; + // (undocumented) + onClose: () => void; + // (undocumented) + open: boolean; +} + +// @public (undocumented) +export const FeedbackResponseTable: ( + props: FeedbackResponseTableProps, +) => JSX.Element; + +// @public (undocumented) +export interface FeedbackResponseTableProps { + // (undocumented) + entityRef: string; + // (undocumented) + title?: string; +} + +// @public (undocumented) +export const LikeDislikeButtons: ( + props: LikeDislikeButtonsProps, +) => JSX.Element; + +// @public (undocumented) +export interface LikeDislikeButtonsProps { + // (undocumented) + feedbackDialogResponses?: EntityFeedbackResponse[]; + // (undocumented) + feedbackDialogTitle?: ReactNode; + // (undocumented) + requestResponse?: boolean; +} + +// @public (undocumented) +export const LikeDislikeRatingsTable: ( + props: LikeDislikeRatingsTableProps, +) => JSX.Element; + +// @public (undocumented) +export interface LikeDislikeRatingsTableProps { + // (undocumented) + allEntities?: boolean; + // (undocumented) + ownerRef?: string; + // (undocumented) + title?: string; +} + +// @public (undocumented) +export const StarredRatingButtons: ( + props: StarredRatingButtonsProps, +) => JSX.Element; + +// @public (undocumented) +export interface StarredRatingButtonsProps { + // (undocumented) + feedbackDialogResponses?: EntityFeedbackResponse[]; + // (undocumented) + feedbackDialogTitle?: ReactNode; + // (undocumented) + requestResponse?: boolean; + // (undocumented) + requestResponseThreshold?: number; +} + +// @public (undocumented) +export const StarredRatingsTable: ( + props: StarredRatingsTableProps, +) => JSX.Element; + +// @public (undocumented) +export interface StarredRatingsTableProps { + // (undocumented) + allEntities?: boolean; + // (undocumented) + ownerRef?: string; + // (undocumented) + title?: string; +} +``` diff --git a/plugins/entity-feedback/docs/feedback-response-dialog.png b/plugins/entity-feedback/docs/feedback-response-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..74270c805a549412e34b0e95785114f601ca60ba GIT binary patch literal 46890 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 literal 0 HcmV?d00001 diff --git a/plugins/entity-feedback/docs/feedback-response-table.png b/plugins/entity-feedback/docs/feedback-response-table.png new file mode 100644 index 0000000000000000000000000000000000000000..3795ac809d07381fd734818f51dc3afa915755f8 GIT binary patch literal 32787 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 Rptq08N-Euaena2y{{ed7=1>3t literal 0 HcmV?d00001 diff --git a/plugins/entity-feedback/docs/like-dislike-rating.png b/plugins/entity-feedback/docs/like-dislike-rating.png new file mode 100644 index 0000000000000000000000000000000000000000..0267218243ab67ccad8417f9c6bcb15e84ede7dd GIT binary patch literal 8754 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 literal 0 HcmV?d00001 diff --git a/plugins/entity-feedback/docs/like-dislike-table.png b/plugins/entity-feedback/docs/like-dislike-table.png new file mode 100644 index 0000000000000000000000000000000000000000..df38883d2546c38295c3722beb771f1a7f0cca15 GIT binary patch literal 39177 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 literal 0 HcmV?d00001 diff --git a/plugins/entity-feedback/docs/starred-rating.png b/plugins/entity-feedback/docs/starred-rating.png new file mode 100644 index 0000000000000000000000000000000000000000..6198f7ae664d1278bfd6dd5de040c37c0fe5d519 GIT binary patch literal 9677 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 + id: 'plugin.entity-feedback.service', +}); + +/** + * @public + */ +export interface EntityFeedbackApi { + getAllRatings(): Promise; + + getOwnedRatings(ownerRef: string): Promise; + + recordRating(entityRef: string, rating: string): Promise; + + getRatings(entityRef: string): Promise[]>; + + recordResponse( + entityRef: string, + response: Omit, + ): Promise; + + getResponses(entityRef: string): Promise[]>; +} diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts new file mode 100644 index 0000000000..694fd66b06 --- /dev/null +++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.test.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2023 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 { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import { EntityFeedbackClient } from './EntityFeedbackClient'; + +const server = setupServer(); + +describe('EntityFeedbackClient', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/entity-feedback'; + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; + const fetchApi = new MockFetchApi(); + + let client: EntityFeedbackClient; + beforeEach(() => { + client = new EntityFeedbackClient({ discoveryApi, fetchApi }); + }); + + it('getAllRatings', async () => { + const ratings = [ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo', + ratings: { LIKE: 10 }, + }, + { + entityRef: 'component:default/bar', + entityTitle: 'Bar', + ratings: { DISLIKE: 10 }, + }, + ]; + + server.use( + rest.get(`${mockBaseUrl}/ratings`, (_, res, ctx) => + res(ctx.json(ratings)), + ), + ); + + const response = await client.getAllRatings(); + expect(response).toEqual(ratings); + }); + + it('getOwnedRatings', async () => { + const ratings = [ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo', + ratings: { LIKE: 10 }, + }, + { + entityRef: 'component:default/bar', + entityTitle: 'Bar', + ratings: { DISLIKE: 10 }, + }, + ]; + + server.use( + rest.get( + `${mockBaseUrl}/ratings?ownerRef=${encodeURIComponent( + 'group:default/team', + )}`, + (_, res, ctx) => res(ctx.json(ratings)), + ), + ); + + const response = await client.getOwnedRatings('group:default/team'); + expect(response).toEqual(ratings); + }); + + it('recordRating', async () => { + expect.assertions(1); + + server.use( + rest.post( + `${mockBaseUrl}/ratings/${encodeURIComponent( + 'component:default/service', + )}`, + (req, res) => { + expect(req.body).toEqual({ rating: 'LIKE' }); + return res(); + }, + ), + ); + + await client.recordRating('component:default/service', 'LIKE'); + }); + + it('getRatings', async () => { + const ratings = [ + { userRef: 'user:default/foo', rating: 'LIKE' }, + { userRef: 'user:default/bar', rating: 'LIKE' }, + ]; + + server.use( + rest.get( + `${mockBaseUrl}/ratings/${encodeURIComponent( + 'component:default/service', + )}`, + (_, res, ctx) => res(ctx.json(ratings)), + ), + ); + + const response = await client.getRatings('component:default/service'); + expect(response).toEqual(ratings); + }); + + it('recordResponse', async () => { + expect.assertions(1); + const response = { + response: 'blah', + comments: 'feedback', + consent: false, + }; + + server.use( + rest.post( + `${mockBaseUrl}/responses/${encodeURIComponent( + 'component:default/service', + )}`, + (req, res) => { + expect(req.body).toEqual(response); + return res(); + }, + ), + ); + + await client.recordResponse('component:default/service', response); + }); + + it('getResponses', async () => { + const responses = [ + { + userRef: 'user:default/foo', + response: 'asdf', + comments: 'here is new feedback', + consent: false, + }, + { + userRef: 'user:default/bar', + response: 'noop', + comments: 'here is different feedback', + consent: true, + }, + ]; + + server.use( + rest.get( + `${mockBaseUrl}/responses/${encodeURIComponent( + 'component:default/service', + )}`, + (_, res, ctx) => res(ctx.json(responses)), + ), + ); + + const response = await client.getResponses('component:default/service'); + expect(response).toEqual(responses); + }); +}); diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts new file mode 100644 index 0000000000..43d3cbe95a --- /dev/null +++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2023 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 { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { + EntityRatingsData, + Rating, + Response, +} from '@backstage/plugin-entity-feedback-common'; + +import { EntityFeedbackApi } from './EntityFeedbackApi'; + +/** + * @public + */ +export class EntityFeedbackClient implements EntityFeedbackApi { + private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; + + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) { + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi; + } + + async getAllRatings(): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch(`${baseUrl}/ratings`, { + method: 'GET', + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async getOwnedRatings(ownerRef: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings?ownerRef=${encodeURIComponent(ownerRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async recordRating(entityRef: string, rating: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify({ rating }), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async getRatings(entityRef: string): Promise[]> { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/ratings/${encodeURIComponent(entityRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } + + async recordResponse( + entityRef: string, + response: Omit, + ) { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/responses/${encodeURIComponent(entityRef)}`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify(response), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async getResponses( + entityRef: string, + ): Promise[]> { + const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/responses/${encodeURIComponent(entityRef)}`, + { + method: 'GET', + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return resp.json(); + } +} diff --git a/plugins/entity-feedback/src/api/index.ts b/plugins/entity-feedback/src/api/index.ts new file mode 100644 index 0000000000..32c8624425 --- /dev/null +++ b/plugins/entity-feedback/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 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 * from './EntityFeedbackClient'; +export * from './EntityFeedbackApi'; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx new file mode 100644 index 0000000000..b639b2e727 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright 2023 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 { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackRatingsTable } from './FeedbackRatingsTable'; + +describe('FeedbackRatingsTable', () => { + const sampleRatings = [ + { + entityRef: 'component:default/foo', + entityTitle: 'Foo Component', + ratings: { 'Rating 1': 3, 'Rating 2': 1 }, + }, + { + entityRef: 'system:default/bar', + entityTitle: 'Bar Component', + ratings: { 'Rating 1': 5 }, + }, + { + entityRef: 'domain:default/hello-world', + entityTitle: 'Hello World', + ratings: { 'Rating 3': 5 }, + }, + ]; + + const feedbackApi: Partial = { + getAllRatings: jest.fn().mockImplementation(async () => sampleRatings), + getOwnedRatings: jest.fn().mockImplementation(async () => sampleRatings), + }; + + const sampleRatingValues = ['Rating 1', 'Rating 2']; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all ratings correctly', async () => { + const rendered = await render({ allEntities: true }); + + expect(feedbackApi.getAllRatings).toHaveBeenCalled(); + expect(feedbackApi.getOwnedRatings).not.toHaveBeenCalled(); + + expect(rendered.getByText('Entity Ratings')).toBeInTheDocument(); + + // Columns + expect(rendered.getByText('Entity')).toBeInTheDocument(); + expect(rendered.getByText('Rating 1')).toBeInTheDocument(); + expect(rendered.getByText('Rating 2')).toBeInTheDocument(); + + // Rows + expect(rendered.getByText('Foo Component')).toBeInTheDocument(); + expect(rendered.getByText('component')).toBeInTheDocument(); + expect(rendered.getByText('3')).toBeInTheDocument(); + expect(rendered.getByText('1')).toBeInTheDocument(); + expect(rendered.getByText('Bar Component')).toBeInTheDocument(); + expect(rendered.getByText('system')).toBeInTheDocument(); + expect(rendered.getByText('5')).toBeInTheDocument(); + + expect(rendered.queryByText('Hello World')).toBeNull(); + }); + + it('renders owned entity ratings correctly', async () => { + const rendered = await render({ + ownerRef: 'group:default/test-team', + title: 'Custom Title', + }); + + expect(feedbackApi.getAllRatings).not.toHaveBeenCalled(); + expect(feedbackApi.getOwnedRatings).toHaveBeenCalledWith( + 'group:default/test-team', + ); + + expect(rendered.getByText('Custom Title')).toBeInTheDocument(); + + // Columns + expect(rendered.getByText('Entity')).toBeInTheDocument(); + expect(rendered.getByText('Rating 1')).toBeInTheDocument(); + expect(rendered.getByText('Rating 2')).toBeInTheDocument(); + + // Rows + expect(rendered.getByText('Foo Component')).toBeInTheDocument(); + expect(rendered.getByText('component')).toBeInTheDocument(); + expect(rendered.getByText('3')).toBeInTheDocument(); + expect(rendered.getByText('1')).toBeInTheDocument(); + expect(rendered.getByText('Bar Component')).toBeInTheDocument(); + expect(rendered.getByText('system')).toBeInTheDocument(); + expect(rendered.getByText('5')).toBeInTheDocument(); + + expect(rendered.queryByText('Hello World')).toBeNull(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx new file mode 100644 index 0000000000..48b2018729 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/FeedbackRatingsTable.tsx @@ -0,0 +1,123 @@ +/* + * Copyright 2023 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 { parseEntityRef } from '@backstage/catalog-model'; +import { ErrorPanel, SubvalueCell, Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; +import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { entityFeedbackApiRef } from '../../api'; + +interface FeedbackRatingsTableProps { + allEntities?: boolean; + ownerRef?: string; + ratingValues: string[]; + title?: string; +} + +export const FeedbackRatingsTable = (props: FeedbackRatingsTableProps) => { + const { + allEntities, + ownerRef, + ratingValues, + title = 'Entity Ratings', + } = props; + const feedbackApi = useApi(entityFeedbackApiRef); + + const { + error, + loading, + value: ratings, + } = useAsync(async () => { + if (allEntities) { + return feedbackApi.getAllRatings(); + } + + if (!ownerRef) { + return []; + } + + return feedbackApi.getOwnedRatings(ownerRef); + }, [allEntities, feedbackApi, ownerRef]); + + const columns = [ + { title: 'Title', field: 'entityTitle', hidden: true, searchable: true }, + { + title: 'Entity', + field: 'entityRef', + highlight: true, + customSort: (a: EntityRatingsData, b: EntityRatingsData) => { + const titleA = a.entityTitle ?? parseEntityRef(a.entityRef).name; + const titleB = b.entityTitle ?? parseEntityRef(b.entityRef).name; + return titleA.localeCompare(titleB); + }, + render: (rating: EntityRatingsData) => { + const compoundRef = parseEntityRef(rating.entityRef); + return ( + + } + subvalue={compoundRef.kind} + /> + ); + }, + }, + ...ratingValues.map(ratingVal => ({ + title: ratingVal, + field: `ratings.${ratingVal}`, + })), + ]; + + // Exclude entities that don't have applicable ratings + const ratingsRows = ratings?.filter(r => + Object.keys(r.ratings).some(v => ratingValues.includes(v)), + ); + + if (error) { + return ( + + ); + } + + return ( + + columns={columns} + data={ratingsRows ?? []} + isLoading={loading} + options={{ + emptyRowsWhenPaging: false, + loadingType: 'linear', + pageSize: 20, + pageSizeOptions: [20, 50, 100], + paging: true, + showEmptyDataSourceMessage: !loading, + }} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts new file mode 100644 index 0000000000..565063b3fa --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackRatingsTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './FeedbackRatingsTable'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx new file mode 100644 index 0000000000..631738d6f9 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.test.tsx @@ -0,0 +1,118 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { getByRole, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackResponseDialog } from './FeedbackResponseDialog'; + +describe('FeedbackResponseDialog', () => { + const testEntity: Partial = { + kind: 'component', + metadata: { name: 'test', namespace: 'default' }, + }; + const errorApi: Partial = { post: jest.fn() }; + const feedbackApi: Partial = { + recordResponse: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows customization of the dialog title', async () => { + const rendered = await render(); + expect( + rendered.getByText('Please provide feedback on what can be improved'), + ).toBeInTheDocument(); + + const customRendered = await render({ feedbackDialogTitle: 'Test Title' }); + expect(customRendered.getByText('Test Title')).toBeInTheDocument(); + }); + + it('allows customization of the reponse options', async () => { + const rendered = await render(); + expect(rendered.getByText('Incorrect info')).toBeInTheDocument(); + expect(rendered.getByText('Missing info')).toBeInTheDocument(); + expect( + rendered.getByText('Other (please specify below)'), + ).toBeInTheDocument(); + + const customResponses = [ + { id: 'foo', label: 'Foo option' }, + { id: 'bar', label: 'Bar option' }, + ]; + const customRendered = await render({ + feedbackDialogResponses: customResponses, + }); + expect(customRendered.getByText('Foo option')).toBeInTheDocument(); + expect(customRendered.getByText('Bar option')).toBeInTheDocument(); + }); + + it('handles saving user responses', async () => { + const rendered = await render(); + + await userEvent.click( + rendered.getByRole('checkbox', { name: 'Incorrect info' }), + ); + await userEvent.click( + rendered.getByRole('checkbox', { name: 'Other (please specify below)' }), + ); + await userEvent.type( + getByRole( + rendered.getByTestId('feedback-response-dialog-comments-input'), + 'textbox', + ), + 'test comments', + ); + await userEvent.click( + rendered.getByTestId('feedback-response-dialog-submit-button'), + ); + + await waitFor(() => { + expect(feedbackApi.recordResponse).toHaveBeenCalledWith( + 'component:default/test', + { + comments: 'test comments', + consent: true, + response: 'incorrect,other', + }, + ); + }); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx new file mode 100644 index 0000000000..c3f935e432 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/FeedbackResponseDialog.tsx @@ -0,0 +1,176 @@ +/* + * Copyright 2023 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 { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core-components'; +import { ErrorApiError, errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + Grid, + makeStyles, + Switch, + TextField, + Typography, +} from '@material-ui/core'; +import React, { ReactNode, useState } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { entityFeedbackApiRef } from '../../api'; + +/** + * @public + */ +export interface EntityFeedbackResponse { + id: string; + label: string; +} + +const defaultFeedbackResponses: EntityFeedbackResponse[] = [ + { id: 'incorrect', label: 'Incorrect info' }, + { id: 'missing', label: 'Missing info' }, + { id: 'other', label: 'Other (please specify below)' }, +]; + +/** + * @public + */ +export interface FeedbackResponseDialogProps { + entity: Entity; + feedbackDialogResponses?: EntityFeedbackResponse[]; + feedbackDialogTitle?: ReactNode; + open: boolean; + onClose: () => void; +} + +const useStyles = makeStyles({ + contactConsent: { + marginTop: '5px', + }, +}); + +export const FeedbackResponseDialog = (props: FeedbackResponseDialogProps) => { + const { + entity, + feedbackDialogResponses = defaultFeedbackResponses, + feedbackDialogTitle = 'Please provide feedback on what can be improved', + open, + onClose, + } = props; + const classes = useStyles(); + const errorApi = useApi(errorApiRef); + const feedbackApi = useApi(entityFeedbackApiRef); + const [responseSelections, setResponseSelections] = useState( + Object.fromEntries(feedbackDialogResponses.map(r => [r.id, false])), + ); + const [comments, setComments] = useState(''); + const [consent, setConsent] = useState(true); + + const [{ loading: saving }, saveResponse] = useAsyncFn(async () => { + try { + await feedbackApi.recordResponse(stringifyEntityRef(entity), { + comments, + consent, + response: Object.keys(responseSelections) + .filter(id => responseSelections[id]) + .join(','), + }); + onClose(); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, [comments, consent, entity, feedbackApi, onClose, responseSelections]); + + return ( + !saving && onClose()}> + {saving && } + {feedbackDialogTitle} + + + Choose all that applies + + {feedbackDialogResponses.map(response => ( + + setResponseSelections({ + ...responseSelections, + [e.target.name]: e.target.checked, + }) + } + /> + } + label={response.label} + /> + ))} + + + + setComments(e.target.value)} + variant="outlined" + value={comments} + /> + + + Can we reach out to you for more info? + + No + + setConsent(e.target.checked)} + /> + + Yes + + + + + + + + + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts new file mode 100644 index 0000000000..8ce88f4b87 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './FeedbackResponseDialog'; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx new file mode 100644 index 0000000000..c5d3d51884 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright 2023 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackResponseTable } from './FeedbackResponseTable'; + +describe('FeedbackResponseTable', () => { + const sampleResponses = [ + { + userRef: 'user:default/foo', + consent: true, + response: 'resp1,resp2', + comments: 'test comment 1', + }, + { + userRef: 'user:default/bar', + consent: false, + response: 'resp3,resp4', + comments: 'test comment 2', + }, + ]; + + const feedbackApi: Partial = { + getResponses: jest.fn().mockImplementation(async () => sampleResponses), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all responses correctly', async () => { + const rendered = await render(); + + expect(feedbackApi.getResponses).toHaveBeenCalledWith( + 'component:default/test', + ); + + expect(rendered.getByText('Entity Responses')).toBeInTheDocument(); + + expect(rendered.getByText('foo')).toBeInTheDocument(); + expect(rendered.getByText('resp1')).toBeInTheDocument(); + expect(rendered.getByText('resp2')).toBeInTheDocument(); + expect(rendered.getByText('test comment 1')).toBeInTheDocument(); + expect(rendered.getByText('bar')).toBeInTheDocument(); + expect(rendered.getByText('resp3')).toBeInTheDocument(); + expect(rendered.getByText('resp4')).toBeInTheDocument(); + expect(rendered.getByText('test comment 2')).toBeInTheDocument(); + }); + + it('renders a custom title correctly', async () => { + const rendered = await render({ title: 'Custom Title' }); + expect(rendered.getByText('Custom Title')).toBeInTheDocument(); + }); +}); diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx new file mode 100644 index 0000000000..2bc03b8537 --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx @@ -0,0 +1,121 @@ +/* + * Copyright 2023 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 { parseEntityRef } from '@backstage/catalog-model'; +import { ErrorPanel, Table } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; +import { humanizeEntityRef } from '@backstage/plugin-catalog-react'; +import { Response } from '@backstage/plugin-entity-feedback-common'; +import { BackstageTheme } from '@backstage/theme'; +import { Chip, makeStyles } from '@material-ui/core'; +import CheckIcon from '@material-ui/icons/Check'; +import React from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { entityFeedbackApiRef } from '../../api'; + +type ResponseRow = Omit; + +const useStyles = makeStyles(theme => ({ + consentCheck: { + color: theme.palette.status.ok, + }, +})); + +/** + * @public + */ +export interface FeedbackResponseTableProps { + entityRef: string; + title?: string; +} + +export const FeedbackResponseTable = (props: FeedbackResponseTableProps) => { + const { entityRef, title = 'Entity Responses' } = props; + const classes = useStyles(); + const feedbackApi = useApi(entityFeedbackApiRef); + + const { + error, + loading, + value: responses, + } = useAsync(async () => { + if (!entityRef) { + return []; + } + + return feedbackApi.getResponses(entityRef); + }, [entityRef, feedbackApi]); + + const columns = [ + { + title: 'User', + field: 'userRef', + width: '15%', + render: (response: ResponseRow) => + humanizeEntityRef(parseEntityRef(response.userRef), { + defaultKind: 'user', + }), + }, + { + title: 'OK to contact?', + field: 'consent', + width: '10%', + render: (response: ResponseRow) => + response.consent ? : '', + }, + { + title: 'Responses', + field: 'response', + width: '35%', + render: (response: ResponseRow) => ( + <> + {response.response?.split(',').map(res => ( + + ))} + + ), + }, + { title: 'Comments', field: 'comments', width: '40%' }, + ]; + + if (error) { + return ( + + ); + } + + return ( + + columns={columns} + data={(responses ?? []) as ResponseRow[]} + isLoading={loading} + options={{ + emptyRowsWhenPaging: false, + loadingType: 'linear', + pageSize: 20, + pageSizeOptions: [20, 50, 100], + paging: true, + showEmptyDataSourceMessage: !loading, + }} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts new file mode 100644 index 0000000000..73f4632fbf --- /dev/null +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './FeedbackResponseTable'; diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx new file mode 100644 index 0000000000..1edf578d3e --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.test.tsx @@ -0,0 +1,147 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { + ErrorApi, + errorApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { AsyncEntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackRatings, LikeDislikeButtons } from './LikeDislikeButtons'; + +jest.mock('../FeedbackResponseDialog', () => ({ + FeedbackResponseDialog: ({ open }: { open: boolean }) => { + return <>{open && dialog is open}; + }, +})); + +describe('LikeDislikeButtons', () => { + const sampleRatings = [ + { + userRef: 'user:default/me', + rating: FeedbackRatings.like, + }, + { + userRef: 'user:default/someone', + rating: FeedbackRatings.dislike, + }, + ]; + + const testEntity = { + kind: 'component', + metadata: { name: 'test', namespace: 'default' }, + } as Entity; + + const errorApi: Partial = { post: jest.fn() }; + + const feedbackApi: Partial = { + getRatings: jest.fn().mockImplementation(async () => sampleRatings), + recordRating: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: [], + }), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads the previous rating if it exists', async () => { + await render(); + expect(feedbackApi.getRatings).toHaveBeenCalledWith( + 'component:default/test', + ); + }); + + it('applies a rating correctly', async () => { + const rendered = await render(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-dislike-button'), + ); + expect(feedbackApi.recordRating).toHaveBeenCalledWith( + 'component:default/test', + FeedbackRatings.dislike, + ); + + jest.clearAllMocks(); + + await userEvent.click(rendered.getByTestId('entity-feedback-like-button')); + expect(feedbackApi.recordRating).toHaveBeenCalledWith( + 'component:default/test', + FeedbackRatings.like, + ); + }); + + it('removes an existing rating correctly', async () => { + const rendered = await render(); + + await userEvent.click(rendered.getByTestId('entity-feedback-like-button')); + expect(feedbackApi.recordRating).toHaveBeenCalledWith( + 'component:default/test', + FeedbackRatings.neutral, + ); + }); + + it('opens a response dialog if dislike is selected', async () => { + const rendered = await render(); + + expect(rendered.queryByText('dialog is open')).toBeNull(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-dislike-button'), + ); + expect(rendered.getByText('dialog is open')).toBeInTheDocument(); + }); + + it('does not open a response dialog on dislike if configured not to', async () => { + const rendered = await render({ requestResponse: false }); + + expect(rendered.queryByText('dialog is open')).toBeNull(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-dislike-button'), + ); + expect(rendered.queryByText('dialog is open')).toBeNull(); + }); +}); diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx new file mode 100644 index 0000000000..2d38e60ac2 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/LikeDislikeButtons.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core-components'; +import { + ErrorApiError, + errorApiRef, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { useAsyncEntity } from '@backstage/plugin-catalog-react'; +import { IconButton } from '@material-ui/core'; +import ThumbDownIcon from '@material-ui/icons/ThumbDown'; +import ThumbUpIcon from '@material-ui/icons/ThumbUp'; +import ThumbDownOutlinedIcon from '@material-ui/icons/ThumbDownOutlined'; +import ThumbUpOutlinedIcon from '@material-ui/icons/ThumbUpOutlined'; +import React, { ReactNode, useCallback, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { entityFeedbackApiRef } from '../../api'; +import { + EntityFeedbackResponse, + FeedbackResponseDialog, +} from '../FeedbackResponseDialog'; + +export enum FeedbackRatings { + like = 'LIKE', + dislike = 'DISLIKE', + neutral = 'NEUTRAL', +} + +/** + * @public + */ +export interface LikeDislikeButtonsProps { + feedbackDialogResponses?: EntityFeedbackResponse[]; + feedbackDialogTitle?: ReactNode; + requestResponse?: boolean; +} + +export const LikeDislikeButtons = (props: LikeDislikeButtonsProps) => { + const { + feedbackDialogResponses, + feedbackDialogTitle, + requestResponse = true, + } = props; + const errorApi = useApi(errorApiRef); + const feedbackApi = useApi(entityFeedbackApiRef); + const identityApi = useApi(identityApiRef); + const [rating, setRating] = useState( + FeedbackRatings.neutral, + ); + const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false); + const { entity, loading: loadingEntity } = useAsyncEntity(); + + const { loading: loadingFeedback } = useAsync(async () => { + // Wait until entity is loaded + if (!entity) { + return; + } + + try { + const identity = await identityApi.getBackstageIdentity(); + const prevFeedback = await feedbackApi.getRatings( + stringifyEntityRef(entity), + ); + setRating( + (prevFeedback.find(r => r.userRef === identity.userEntityRef)?.rating ?? + rating) as FeedbackRatings, + ); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, [entity, feedbackApi, setRating]); + + const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn( + async (feedback: FeedbackRatings) => { + try { + await feedbackApi.recordRating(stringifyEntityRef(entity!), feedback); + setRating(feedback); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, + [entity, feedbackApi, setRating], + ); + + const applyRating = useCallback( + (feedback: FeedbackRatings) => { + // Clear rating if feedback is same as current + if (feedback === rating) { + saveFeedback(FeedbackRatings.neutral); + return; + } + + saveFeedback(feedback); + if (feedback === FeedbackRatings.dislike && requestResponse) { + setOpenFeedbackDialog(true); + } + }, + [rating, requestResponse, saveFeedback, setOpenFeedbackDialog], + ); + + if (loadingEntity || loadingFeedback || savingFeedback) { + return ; + } + + return ( + <> + applyRating(FeedbackRatings.like)} + > + {rating === FeedbackRatings.like ? ( + + ) : ( + + )} + + applyRating(FeedbackRatings.dislike)} + > + {rating === FeedbackRatings.dislike ? ( + + ) : ( + + )} + + setOpenFeedbackDialog(false)} + feedbackDialogResponses={feedbackDialogResponses} + feedbackDialogTitle={feedbackDialogTitle} + /> + + ); +}; diff --git a/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts new file mode 100644 index 0000000000..58038c32e1 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeButtons/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './LikeDislikeButtons'; diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx new file mode 100644 index 0000000000..9f04d32ece --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2023 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 { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; + +import { FeedbackRatings } from '../LikeDislikeButtons'; +import { LikeDislikeRatingsTable } from './LikeDislikeRatingsTable'; + +jest.mock('../FeedbackRatingsTable', () => ({ + FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => { + return ( + + {ratingValues.map(v => ( + {v} + ))} + + ); + }, +})); + +describe('LikeDislikeRatingsTable', () => { + it('renders like-dislike ratings correctly', async () => { + const rendered = await renderInTestApp(); + expect(rendered.getByText(FeedbackRatings.like)).toBeInTheDocument(); + expect(rendered.queryByText(FeedbackRatings.neutral)).toBeNull(); + expect(rendered.getByText(FeedbackRatings.dislike)).toBeInTheDocument(); + }); +}); diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx new file mode 100644 index 0000000000..5466ba2b03 --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/LikeDislikeRatingsTable.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 React from 'react'; + +import { FeedbackRatingsTable } from '../FeedbackRatingsTable'; +import { FeedbackRatings } from '../LikeDislikeButtons'; + +/** + * @public + */ +export interface LikeDislikeRatingsTableProps { + allEntities?: boolean; + ownerRef?: string; + title?: string; +} + +export const LikeDislikeRatingsTable = ( + props: LikeDislikeRatingsTableProps, +) => { + const { allEntities, ownerRef, title } = props; + + return ( + r !== FeedbackRatings.neutral, + )} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts new file mode 100644 index 0000000000..d093b8bd2c --- /dev/null +++ b/plugins/entity-feedback/src/components/LikeDislikeRatingsTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './LikeDislikeRatingsTable'; diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx new file mode 100644 index 0000000000..82fc843494 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; +import { + ErrorApi, + errorApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { AsyncEntityProvider } from '@backstage/plugin-catalog-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EntityFeedbackApi, entityFeedbackApiRef } from '../../api'; +import { FeedbackRatings, StarredRatingButtons } from './StarredRatingButtons'; + +jest.mock('../FeedbackResponseDialog', () => ({ + FeedbackResponseDialog: ({ open }: { open: boolean }) => { + return <>{open && dialog is open}; + }, +})); + +describe('StarredRatingButtons', () => { + const sampleRatings = [ + { + userRef: 'user:default/me', + rating: FeedbackRatings.two, + }, + { + userRef: 'user:default/someone', + rating: FeedbackRatings.five, + }, + ]; + + const testEntity = { + kind: 'component', + metadata: { name: 'test', namespace: 'default' }, + } as Entity; + + const errorApi: Partial = { post: jest.fn() }; + + const feedbackApi: Partial = { + getRatings: jest.fn().mockImplementation(async () => sampleRatings), + recordRating: jest.fn().mockImplementation(() => Promise.resolve()), + }; + + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: [], + }), + }; + + const render = async (props: any = {}) => + renderInTestApp( + + + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads the previous rating if it exists', async () => { + await render(); + expect(feedbackApi.getRatings).toHaveBeenCalledWith( + 'component:default/test', + ); + }); + + it('applies a rating correctly', async () => { + const rendered = await render(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-3'), + ); + expect(feedbackApi.recordRating).toHaveBeenCalledWith( + 'component:default/test', + FeedbackRatings.three.toString(), + ); + + jest.clearAllMocks(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-5'), + ); + expect(feedbackApi.recordRating).toHaveBeenCalledWith( + 'component:default/test', + FeedbackRatings.five.toString(), + ); + }); + + it('ignores an existing rating correctly', async () => { + const rendered = await render(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-2'), + ); + expect(feedbackApi.recordRating).not.toHaveBeenCalled(); + }); + + it('opens a response dialog if a rating under the threshold is selected', async () => { + const rendered = await render(); + + expect(rendered.queryByText('dialog is open')).toBeNull(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-1'), + ); + expect(rendered.getByText('dialog is open')).toBeInTheDocument(); + }); + + it('opens a response dialog if a rating under a custom threshold is selected', async () => { + const rendered = await render({ requestResponseThreshold: 4 }); + + expect(rendered.queryByText('dialog is open')).toBeNull(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-3'), + ); + expect(rendered.getByText('dialog is open')).toBeInTheDocument(); + }); + + it('does not open a response dialog if configured not to', async () => { + const rendered = await render({ requestResponse: false }); + + expect(rendered.queryByText('dialog is open')).toBeNull(); + + await userEvent.click( + rendered.getByTestId('entity-feedback-star-button-1'), + ); + expect(rendered.queryByText('dialog is open')).toBeNull(); + }); +}); diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx new file mode 100644 index 0000000000..51f7da1099 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/StarredRatingButtons.tsx @@ -0,0 +1,160 @@ +/* + * Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { Progress } from '@backstage/core-components'; +import { + ErrorApiError, + errorApiRef, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { useAsyncEntity } from '@backstage/plugin-catalog-react'; +import { IconButton } from '@material-ui/core'; +import StarOutlineIcon from '@material-ui/icons/StarOutline'; +import StarIcon from '@material-ui/icons/Star'; +import React, { ReactNode, useCallback, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { entityFeedbackApiRef } from '../../api'; +import { + EntityFeedbackResponse, + FeedbackResponseDialog, +} from '../FeedbackResponseDialog'; + +export enum FeedbackRatings { + one = 1, + two = 2, + three = 3, + four = 4, + five = 5, +} + +/** + * @public + */ +export interface StarredRatingButtonsProps { + feedbackDialogResponses?: EntityFeedbackResponse[]; + feedbackDialogTitle?: ReactNode; + requestResponse?: boolean; + requestResponseThreshold?: number; +} + +export const StarredRatingButtons = (props: StarredRatingButtonsProps) => { + const { + feedbackDialogResponses, + feedbackDialogTitle, + requestResponse = true, + requestResponseThreshold = FeedbackRatings.two, + } = props; + const errorApi = useApi(errorApiRef); + const feedbackApi = useApi(entityFeedbackApiRef); + const identityApi = useApi(identityApiRef); + const [rating, setRating] = useState(); + const [openFeedbackDialog, setOpenFeedbackDialog] = useState(false); + const { entity, loading: loadingEntity } = useAsyncEntity(); + + const { loading: loadingFeedback } = useAsync(async () => { + // Wait until entity is loaded + if (!entity) { + return; + } + + try { + const identity = await identityApi.getBackstageIdentity(); + const prevFeedback = await feedbackApi.getRatings( + stringifyEntityRef(entity), + ); + + const prevRating = prevFeedback.find( + r => r.userRef === identity.userEntityRef, + )?.rating; + if (prevRating) { + setRating(parseInt(prevRating, 10)); + } + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, [entity, feedbackApi, setRating]); + + const [{ loading: savingFeedback }, saveFeedback] = useAsyncFn( + async (feedback: FeedbackRatings) => { + try { + await feedbackApi.recordRating( + stringifyEntityRef(entity!), + feedback.toString(), + ); + setRating(feedback); + } catch (e) { + errorApi.post(e as ErrorApiError); + } + }, + [entity, feedbackApi, setRating], + ); + + const applyRating = useCallback( + (feedback: FeedbackRatings) => { + // Ignore rating if feedback is same as current + if (feedback === rating) { + return; + } + + saveFeedback(feedback); + if (feedback <= requestResponseThreshold && requestResponse) { + setOpenFeedbackDialog(true); + } + }, + [ + rating, + requestResponse, + requestResponseThreshold, + saveFeedback, + setOpenFeedbackDialog, + ], + ); + + if (loadingEntity || loadingFeedback || savingFeedback) { + return ; + } + + return ( + <> + {Object.values(FeedbackRatings) + .filter(o => typeof o === 'number') + .map(starRating => ( + applyRating(starRating as FeedbackRatings)} + > + {rating && rating >= starRating ? ( + + ) : ( + + )} + + ))} + setOpenFeedbackDialog(false)} + feedbackDialogResponses={feedbackDialogResponses} + feedbackDialogTitle={feedbackDialogTitle} + /> + + ); +}; diff --git a/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts new file mode 100644 index 0000000000..659c914749 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingButtons/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './StarredRatingButtons'; diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx new file mode 100644 index 0000000000..be53d59d5f --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2023 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 { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; + +import { FeedbackRatings } from '../StarredRatingButtons'; +import { StarredRatingsTable } from './StarredRatingsTable'; + +jest.mock('../FeedbackRatingsTable', () => ({ + FeedbackRatingsTable: ({ ratingValues }: { ratingValues: string[] }) => { + return ( + + {ratingValues.map(v => ( + {v} + ))} + + ); + }, +})); + +describe('StarredRatingsTable', () => { + it('renders starred ratings correctly', async () => { + const rendered = await renderInTestApp(); + expect(rendered.getByText(FeedbackRatings.one)).toBeInTheDocument(); + expect(rendered.getByText(FeedbackRatings.two)).toBeInTheDocument(); + expect(rendered.getByText(FeedbackRatings.three)).toBeInTheDocument(); + expect(rendered.getByText(FeedbackRatings.four)).toBeInTheDocument(); + expect(rendered.getByText(FeedbackRatings.five)).toBeInTheDocument(); + }); +}); diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx new file mode 100644 index 0000000000..d52fd64640 --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/StarredRatingsTable.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2023 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 React from 'react'; + +import { FeedbackRatingsTable } from '../FeedbackRatingsTable'; +import { FeedbackRatings } from '../StarredRatingButtons'; + +/** + * @public + */ +export interface StarredRatingsTableProps { + allEntities?: boolean; + ownerRef?: string; + title?: string; +} + +export const StarredRatingsTable = (props: StarredRatingsTableProps) => { + const { allEntities, ownerRef, title } = props; + + return ( + typeof o === 'number') + .map(r => r.toString())} + title={title} + /> + ); +}; diff --git a/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts new file mode 100644 index 0000000000..9621712e0b --- /dev/null +++ b/plugins/entity-feedback/src/components/StarredRatingsTable/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 * from './StarredRatingsTable'; diff --git a/plugins/entity-feedback/src/components/index.ts b/plugins/entity-feedback/src/components/index.ts new file mode 100644 index 0000000000..a88d525f09 --- /dev/null +++ b/plugins/entity-feedback/src/components/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2023 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 type { + EntityFeedbackResponse, + FeedbackResponseDialogProps, +} from './FeedbackResponseDialog'; +export type { FeedbackResponseTableProps } from './FeedbackResponseTable'; +export type { LikeDislikeButtonsProps } from './LikeDislikeButtons'; +export type { LikeDislikeRatingsTableProps } from './LikeDislikeRatingsTable'; +export type { StarredRatingButtonsProps } from './StarredRatingButtons'; +export type { StarredRatingsTableProps } from './StarredRatingsTable'; diff --git a/plugins/entity-feedback/src/index.ts b/plugins/entity-feedback/src/index.ts new file mode 100644 index 0000000000..bbcf6bc626 --- /dev/null +++ b/plugins/entity-feedback/src/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 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. + */ + +/** + * Entity Feedback frontend plugin + * + * @packageDocumentation + */ +export type { + LikeDislikeButtonsProps, + LikeDislikeRatingsTableProps, + EntityFeedbackResponse, + FeedbackResponseDialogProps, + FeedbackResponseTableProps, + StarredRatingButtonsProps, + StarredRatingsTableProps, +} from './components'; +export * from './plugin'; +export * from './api'; diff --git a/plugins/entity-feedback/src/plugin.test.ts b/plugins/entity-feedback/src/plugin.test.ts new file mode 100644 index 0000000000..a56d37f9ab --- /dev/null +++ b/plugins/entity-feedback/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 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 { entityFeedbackPlugin } from './plugin'; + +describe('entity-feedback', () => { + it('should export plugin', () => { + expect(entityFeedbackPlugin).toBeDefined(); + }); +}); diff --git a/plugins/entity-feedback/src/plugin.tsx b/plugins/entity-feedback/src/plugin.tsx new file mode 100644 index 0000000000..9ebdc5d7af --- /dev/null +++ b/plugins/entity-feedback/src/plugin.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model'; +import { + createApiFactory, + createComponentExtension, + createPlugin, + createRoutableExtension, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; +import { useAsyncEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; + +import { entityFeedbackApiRef, EntityFeedbackClient } from './api'; +import { rootRouteRef } from './routes'; + +/** + * @public + */ +export const entityFeedbackPlugin = createPlugin({ + id: 'entity-feedback', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: entityFeedbackApiRef, + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, fetchApi }) => + new EntityFeedbackClient({ discoveryApi, fetchApi }), + }), + ], +}); + +/** + * @public + */ +export const LikeDislikeButtons = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'LikeDislikeButtons', + component: { + lazy: () => + import('./components/LikeDislikeButtons').then( + m => m.LikeDislikeButtons, + ), + }, + }), +); + +/** + * @public + */ +export const StarredRatingButtons = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'StarredRatingButtons', + component: { + lazy: () => + import('./components/StarredRatingButtons').then( + m => m.StarredRatingButtons, + ), + }, + }), +); + +/** + * @public + */ +export const FeedbackResponseDialog = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'FeedbackResponseDialog', + component: { + lazy: () => + import('./components/FeedbackResponseDialog').then( + m => m.FeedbackResponseDialog, + ), + }, + }), +); + +/** + * @public + */ +export const EntityFeedbackResponseContent = entityFeedbackPlugin.provide( + createRoutableExtension({ + name: 'EntityFeedbackResponseContent', + mountPoint: rootRouteRef, + component: () => + import('./components/FeedbackResponseTable').then( + ({ FeedbackResponseTable }) => { + return () => { + const { entity } = useAsyncEntity(); + return ( + + ); + }; + }, + ), + }), +); + +/** + * @public + */ +export const FeedbackResponseTable = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'FeedbackResponseTable', + component: { + lazy: () => + import('./components/FeedbackResponseTable').then( + m => m.FeedbackResponseTable, + ), + }, + }), +); + +/** + * @public + */ +export const EntityLikeDislikeRatingsCard = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'EntityLikeDislikeRatingsCard', + component: { + lazy: () => + import('./components/LikeDislikeRatingsTable').then( + ({ LikeDislikeRatingsTable }) => { + return () => { + const { entity } = useAsyncEntity(); + return ( + + ); + }; + }, + ), + }, + }), +); + +/** + * @public + */ +export const LikeDislikeRatingsTable = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'LikeDislikeRatingsTable', + component: { + lazy: () => + import('./components/LikeDislikeRatingsTable').then( + m => m.LikeDislikeRatingsTable, + ), + }, + }), +); + +/** + * @public + */ +export const EntityStarredRatingsCard = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'EntityStarredRatingsCard', + component: { + lazy: () => + import('./components/StarredRatingsTable').then( + ({ StarredRatingsTable }) => { + return () => { + const { entity } = useAsyncEntity(); + return ( + + ); + }; + }, + ), + }, + }), +); + +/** + * @public + */ +export const StarredRatingsTable = entityFeedbackPlugin.provide( + createComponentExtension({ + name: 'StarredRatingsTable', + component: { + lazy: () => + import('./components/StarredRatingsTable').then( + m => m.StarredRatingsTable, + ), + }, + }), +); diff --git a/plugins/entity-feedback/src/routes.ts b/plugins/entity-feedback/src/routes.ts new file mode 100644 index 0000000000..cde7241931 --- /dev/null +++ b/plugins/entity-feedback/src/routes.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 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 { createRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'entity-feedback', +}); diff --git a/plugins/entity-feedback/src/setupTests.ts b/plugins/entity-feedback/src/setupTests.ts new file mode 100644 index 0000000000..bf4855cb6e --- /dev/null +++ b/plugins/entity-feedback/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 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 '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/yarn.lock b/yarn.lock index 1621cdc5b1..148b33fa81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5845,6 +5845,72 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-entity-feedback-backend@workspace:^, @backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback-backend@workspace:plugins/entity-feedback-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-entity-feedback-common": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.12 + express: ^4.18.1 + express-promise-router: ^4.1.0 + knex: ^2.0.0 + msw: ^0.49.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-entity-feedback-common@workspace:^, @backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback-common@workspace:plugins/entity-feedback-common" + dependencies: + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + +"@backstage/plugin-entity-feedback@workspace:^, @backstage/plugin-entity-feedback@workspace:plugins/entity-feedback": + version: 0.0.0-use.local + resolution: "@backstage/plugin-entity-feedback@workspace:plugins/entity-feedback" + dependencies: + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-entity-feedback-common": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": 4.0.0-alpha.57 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.1 + "@testing-library/user-event": ^14.0.0 + "@types/node": "*" + cross-fetch: ^3.1.5 + msw: ^0.49.0 + react-use: ^17.2.4 + peerDependencies: + "@types/react": ^16.13.1 || ^17.0.0 + react: ^16.13.1 || ^17.0.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-entity-validation@workspace:plugins/entity-validation": version: 0.0.0-use.local resolution: "@backstage/plugin-entity-validation@workspace:plugins/entity-validation" @@ -11211,7 +11277,7 @@ __metadata: languageName: node linkType: hard -"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4": +"@material-ui/core@npm:^4.11.0, @material-ui/core@npm:^4.11.3, @material-ui/core@npm:^4.12.1, @material-ui/core@npm:^4.12.2, @material-ui/core@npm:^4.12.4, @material-ui/core@npm:^4.9.13": version: 4.12.4 resolution: "@material-ui/core@npm:4.12.4" dependencies: @@ -13595,7 +13661,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-hooks@npm:^8.0.0": +"@testing-library/react-hooks@npm:^8.0.0, @testing-library/react-hooks@npm:^8.0.1": version: 8.0.1 resolution: "@testing-library/react-hooks@npm:8.0.1" dependencies: @@ -22190,6 +22256,7 @@ __metadata: "@backstage/plugin-code-coverage": "workspace:^" "@backstage/plugin-cost-insights": "workspace:^" "@backstage/plugin-dynatrace": "workspace:^" + "@backstage/plugin-entity-feedback": "workspace:^" "@backstage/plugin-explore": "workspace:^" "@backstage/plugin-gcalendar": "workspace:^" "@backstage/plugin-gcp-projects": "workspace:^" @@ -22291,6 +22358,7 @@ __metadata: "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/plugin-code-coverage-backend": "workspace:^" + "@backstage/plugin-entity-feedback-backend": "workspace:^" "@backstage/plugin-events-backend": "workspace:^" "@backstage/plugin-events-node": "workspace:^" "@backstage/plugin-explore-backend": "workspace:^" From 1aec041c3451f0e7ac33b01035c0bc7d86007bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 10 Feb 2023 19:31:53 +0100 Subject: [PATCH 10/17] Handle eager deletion even in the face of duplicate references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/itchy-rings-rule.md | 5 + .../database/DefaultCatalogDatabase.test.ts | 1 + .../database/DefaultProviderDatabase.test.ts | 34 +++ .../deleteWithEagerPruningOfChildren.test.ts | 278 ++++++++++++++++++ .../deleteWithEagerPruningOfChildren.ts | 178 ++++++----- 5 files changed, 419 insertions(+), 77 deletions(-) create mode 100644 .changeset/itchy-rings-rule.md create mode 100644 plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts diff --git a/.changeset/itchy-rings-rule.md b/.changeset/itchy-rings-rule.md new file mode 100644 index 0000000000..3e8b9e8b2b --- /dev/null +++ b/.changeset/itchy-rings-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixed an issue where entities sometimes were not properly deleted during a full mutation. diff --git a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts index b873f6a985..827ab58ebe 100644 --- a/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultCatalogDatabase.test.ts @@ -105,6 +105,7 @@ describe('DefaultCatalogDatabase', () => { 'location:default/root-2', ]); }, + 60_000, ); }); }); diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts index 140925b6fd..09bec187fa 100644 --- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts @@ -745,5 +745,39 @@ describe('DefaultProviderDatabase', () => { }, 60_000, ); + + it.each(databases.eachSupportedId())( + 'should gracefully handle accidental duplicate refresh state references when deletion happens during a full sync, %p', + async databaseId => { + const fakeLogger = { debug: jest.fn() }; + const { knex, db } = await createDatabase( + databaseId, + fakeLogger as any, + ); + + await createLocations(knex, ['component:default/a']); + + await insertRefRow(knex, { + source_key: 'a', + target_entity_ref: 'component:default/a', + }); + await insertRefRow(knex, { + source_key: 'a', + target_entity_ref: 'component:default/a', + }); + + await db.transaction(async tx => { + await db.replaceUnprocessedEntities(tx, { + type: 'full', + sourceKey: 'a', + items: [], + }); + }); + + const state = await knex('refresh_state').select(); + expect(state).toEqual([]); + }, + 60_000, + ); }); }); diff --git a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts new file mode 100644 index 0000000000..8ab94df64a --- /dev/null +++ b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.test.ts @@ -0,0 +1,278 @@ +/* + * Copyright 2023 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 { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; +import * as uuid from 'uuid'; +import { applyDatabaseMigrations } from '../../migrations'; +import { DbRefreshStateReferencesRow, DbRefreshStateRow } from '../../tables'; +import { deleteWithEagerPruningOfChildren } from './deleteWithEagerPruningOfChildren'; + +describe('deleteWithEagerPruningOfChildren', () => { + const databases = TestDatabases.create({ + ids: ['MYSQL_8', 'POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], + }); + + async function createDatabase(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + return knex; + } + + async function run( + knex: Knex, + options: { entityRefs: string[]; sourceKey: string }, + ): Promise { + let result: number; + await knex.transaction( + async tx => { + // We can't return here, as knex swallows the return type in case the + // transaction is rolled back: + // https://github.com/knex/knex/blob/e37aeaa31c8ef9c1b07d2e4d3ec6607e557d800d/lib/transaction.js#L136 + result = await deleteWithEagerPruningOfChildren({ tx, ...options }); + }, + { + // If we explicitly trigger a rollback, don't fail. + doNotRejectOnRollback: true, + }, + ); + return result!; + } + + async function insertReference( + knex: Knex, + ...refs: DbRefreshStateReferencesRow[] + ) { + return knex('refresh_state_references').insert( + refs, + ); + } + + async function insertEntity(knex: Knex, ...entityRefs: string[]) { + for (const ref of entityRefs) { + await knex('refresh_state').insert({ + entity_id: uuid.v4(), + entity_ref: ref, + unprocessed_entity: '{}', + processed_entity: '{}', + errors: '[]', + next_update_at: '2021-04-01 13:37:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + } + } + + async function remainingEntities(knex: Knex) { + const rows = await knex('refresh_state') + .orderBy('entity_ref') + .select('entity_ref'); + return rows.map(r => r.entity_ref); + } + + it.each(databases.eachSupportedId())( + 'works for the simple path, %p', + async databaseId => { + /* + P1 - E1 - E2 + + P1 - E3 + + P1 - E4 + + P2 - E5 + + Scenario: P1 issues delete for E1 and E3 + + Result: E1, E2, and E3 deleted; E4 and E5 remain + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_entity_ref: 'E1', target_entity_ref: 'E2' }, + { source_key: 'P1', target_entity_ref: 'E3' }, + { source_key: 'P1', target_entity_ref: 'E4' }, + { source_key: 'P2', target_entity_ref: 'E5' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E1', 'E3'] }); + await expect(remainingEntities(knex)).resolves.toEqual(['E4', 'E5']); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'works when there are multiple identical references, %p', + async databaseId => { + /* + P1 + \ + E1 + / + P1 + + P1 - E2 + + Scenario: P1 issues delete for E1 + + Result: E1 deleted; E2 remains + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_key: 'P1', target_entity_ref: 'E2' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] }); + await expect(remainingEntities(knex)).resolves.toEqual(['E2']); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'leaves out things that have roots in other source keys, %p', + async databaseId => { + /* + P1 - E1 + \ + E2 + / + P2 - E3 + + Scenario: P1 issues delete for E1 + + Result: E1 deleted; E2 and E3 remain + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2', 'E3'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_entity_ref: 'E1', target_entity_ref: 'E2' }, + { source_key: 'P2', target_entity_ref: 'E3' }, + { source_entity_ref: 'E3', target_entity_ref: 'E2' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] }); + await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'leaves out things that have several different roots for the same source key, %p', + async databaseId => { + /* + P1 - E1 + \ + E2 + / + P1 - E3 + + Scenario: P1 issues delete for E1 + + Result: E1 deleted; E2 and E3 remain + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2', 'E3'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_entity_ref: 'E1', target_entity_ref: 'E2' }, + { source_key: 'P1', target_entity_ref: 'E3' }, + { source_entity_ref: 'E3', target_entity_ref: 'E2' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] }); + await expect(remainingEntities(knex)).resolves.toEqual(['E2', 'E3']); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'handles cycles and diamonds gracefully, %p', + async databaseId => { + /* + P1 - E1 <-> E2 + \ + E4 E6 + / \ / + P1 -- E3 -- E5 + + Scenario: P1 issues delete for E1, then for E3 + + Result: Everything deleted, but in two steps + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2', 'E3', 'E4', 'E5', 'E6'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_entity_ref: 'E1', target_entity_ref: 'E2' }, + { source_entity_ref: 'E2', target_entity_ref: 'E1' }, + { source_entity_ref: 'E2', target_entity_ref: 'E6' }, + { source_key: 'P1', target_entity_ref: 'E3' }, + { source_entity_ref: 'E3', target_entity_ref: 'E4' }, + { source_entity_ref: 'E4', target_entity_ref: 'E5' }, + { source_entity_ref: 'E3', target_entity_ref: 'E5' }, + { source_entity_ref: 'E5', target_entity_ref: 'E6' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E1'] }); + await expect(remainingEntities(knex)).resolves.toEqual([ + 'E3', + 'E4', + 'E5', + 'E6', + ]); + await run(knex, { sourceKey: 'P1', entityRefs: ['E3'] }); + await expect(remainingEntities(knex)).resolves.toEqual([]); + }, + 60_000, + ); + + it.each(databases.eachSupportedId())( + 'silently ignores attempts to delete things that are not your own and/or are not roots, %p', + async databaseId => { + /* + P1 - E1 - E2 + + P1 - E3 + + P2 - E4 + + Scenario: P1 issues delete for E2, E3, and E4 + + Result: E3 is deleted; E1, E2 and E4 remain + */ + const knex = await createDatabase(databaseId); + await insertEntity(knex, 'E1', 'E2', 'E3', 'E4'); + await insertReference( + knex, + { source_key: 'P1', target_entity_ref: 'E1' }, + { source_entity_ref: 'E1', target_entity_ref: 'E2' }, + { source_key: 'P1', target_entity_ref: 'E3' }, + { source_key: 'P2', target_entity_ref: 'E4' }, + ); + await run(knex, { sourceKey: 'P1', entityRefs: ['E2', 'E3', 'E4'] }); + await expect(remainingEntities(knex)).resolves.toEqual([ + 'E1', + 'E2', + 'E4', + ]); + }, + 60_000, + ); +}); diff --git a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts index 3d642cbf3a..c600825d6f 100644 --- a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts +++ b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts @@ -31,113 +31,137 @@ export async function deleteWithEagerPruningOfChildren(options: { sourceKey: string; }): Promise { const { tx, entityRefs, sourceKey } = options; - let removedCount = 0; - - const rootId = () => - tx.raw( - tx.client.config.client.includes('mysql') - ? 'CAST(NULL as UNSIGNED INT)' - : 'CAST(NULL as INT)', - [], - ); // Split up the operation by (large) chunks, so that we do not hit database // limits for the number of permitted bindings on a precompiled statement + let removedCount = 0; for (const refs of lodash.chunk(entityRefs, 1000)) { /* WITH RECURSIVE -- All the nodes that can be reached downwards from our root - descendants(root_id, entity_ref) AS ( - SELECT id, target_entity_ref + descendants(entity_ref) AS ( + SELECT target_entity_ref FROM refresh_state_references - WHERE source_key = "R1" AND target_entity_ref = "A" + WHERE source_key = "R1" AND target_entity_ref IN [...refs] UNION - SELECT descendants.root_id, target_entity_ref + SELECT target_entity_ref FROM descendants JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref ), - -- All the nodes that can be reached upwards from the descendants - ancestors(root_id, via_entity_ref, to_entity_ref) AS ( - SELECT CAST(NULL as INT), entity_ref, entity_ref + -- All the individual relations that can be reached upwards from each descendant + ancestors(source_key, source_entity_ref, target_entity_ref, subject) AS ( + SELECT source_key, source_entity_ref, target_entity_ref, descendants.entity_ref FROM descendants + JOIN refresh_state_references ON refresh_state_references.target_entity_ref = descendants.entity_ref UNION SELECT - CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END, - source_entity_ref, - ancestors.to_entity_ref + refresh_state_references.source_key, + refresh_state_references.source_entity_ref, + refresh_state_references.target_entity_ref, + ancestors.subject FROM ancestors - JOIN refresh_state_references ON target_entity_ref = ancestors.via_entity_ref + JOIN refresh_state_references ON refresh_state_references.target_entity_ref = ancestors.source_entity_ref ) -- Start out with all of the descendants SELECT descendants.entity_ref FROM descendants - -- Expand with all ancestors that point to those, but aren't the current root - LEFT OUTER JOIN ancestors - ON ancestors.to_entity_ref = descendants.entity_ref - AND ancestors.root_id IS NOT NULL - AND ancestors.root_id != descendants.root_id - -- Exclude all lines that had such a foreign ancestor - WHERE ancestors.root_id IS NULL; + -- Exclude those who seem to have a root relation somewhere upwards that's not part of our own deletion + WHERE NOT EXISTS ( + SELECT * FROM ancestors + WHERE ancestors.subject = descendants.entity_ref + AND ancestors.source_key IS NOT NULL + AND (ancestors.source_key != "R1" OR ancestors.target_entity_ref NOT IN [...refs]) + ) */ removedCount += await tx('refresh_state') .whereIn('entity_ref', function orphanedEntityRefs(orphans) { return ( orphans // All the nodes that can be reached downwards from our root - .withRecursive('descendants', function descendants(outer) { - return outer - .select({ root_id: 'id', entity_ref: 'target_entity_ref' }) - .from('refresh_state_references') - .where('source_key', sourceKey) - .whereIn('target_entity_ref', refs) - .union(function recursive(inner) { - return inner - .select({ - root_id: 'descendants.root_id', - entity_ref: 'refresh_state_references.target_entity_ref', - }) - .from('descendants') - .join('refresh_state_references', { - 'descendants.entity_ref': - 'refresh_state_references.source_entity_ref', - }); - }); - }) - // All the nodes that can be reached upwards from the descendants - .withRecursive('ancestors', function ancestors(outer) { - return outer - .select({ - root_id: rootId(), - via_entity_ref: 'entity_ref', - to_entity_ref: 'entity_ref', - }) - .from('descendants') - .union(function recursive(inner) { - return inner - .select({ - root_id: tx.raw( - 'CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END', - [], - ), - via_entity_ref: 'source_entity_ref', - to_entity_ref: 'ancestors.to_entity_ref', - }) - .from('ancestors') - .join('refresh_state_references', { - target_entity_ref: 'ancestors.via_entity_ref', - }); - }); - }) + .withRecursive( + 'descendants', + ['entity_ref'], + function descendants(outer) { + return outer + .select({ entity_ref: 'target_entity_ref' }) + .from('refresh_state_references') + .where('source_key', '=', sourceKey) + .whereIn('target_entity_ref', refs) + .union(function recursive(inner) { + return inner + .select({ + entity_ref: + 'refresh_state_references.target_entity_ref', + }) + .from('descendants') + .join('refresh_state_references', { + 'descendants.entity_ref': + 'refresh_state_references.source_entity_ref', + }); + }); + }, + ) + // All the relations that can be reached upwards from each descendant + .withRecursive( + 'ancestors', + [ + 'source_key', + 'source_entity_ref', + 'target_entity_ref', + 'subject', + ], + function ancestors(outer) { + return outer + .select({ + source_key: 'refresh_state_references.source_key', + source_entity_ref: + 'refresh_state_references.source_entity_ref', + target_entity_ref: + 'refresh_state_references.target_entity_ref', + subject: 'descendants.entity_ref', + }) + .from('descendants') + .join('refresh_state_references', { + 'refresh_state_references.target_entity_ref': + 'descendants.entity_ref', + }) + .union(function recursive(inner) { + return inner + .select({ + source_key: 'refresh_state_references.source_key', + source_entity_ref: + 'refresh_state_references.source_entity_ref', + target_entity_ref: + 'refresh_state_references.target_entity_ref', + subject: 'ancestors.subject', + }) + .from('ancestors') + .join('refresh_state_references', { + 'refresh_state_references.target_entity_ref': + 'ancestors.source_entity_ref', + }); + }); + }, + ) // Start out with all of the descendants .select('descendants.entity_ref') .from('descendants') - // Expand with all ancestors that point to those, but aren't the current root - .leftOuterJoin('ancestors', function keepaliveRoots() { - this.on('ancestors.to_entity_ref', '=', 'descendants.entity_ref'); - this.andOnNotNull('ancestors.root_id'); - this.andOn('ancestors.root_id', '!=', 'descendants.root_id'); + // Exclude those who seem to have a root relation somewhere upwards that's not part of our own deletion + .whereNotExists(function otherAncestors(outer) { + outer + .from('ancestors') + .where( + 'ancestors.subject', + '=', + tx.ref('descendants.entity_ref'), + ) + .whereNotNull('ancestors.source_key') + .andWhere(function differentRoot(inner) { + inner + .where('ancestors.source_key', '!=', sourceKey) + .orWhereNotIn('ancestors.target_entity_ref', refs); + }); }) - .whereNull('ancestors.root_id') ); }) .delete(); From 33d042bcaf0b6dc4c045ab63e655c51203871170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 12 Feb 2023 12:13:16 +0100 Subject: [PATCH 11/17] simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../deleteWithEagerPruningOfChildren.ts | 273 ++++++++++-------- 1 file changed, 145 insertions(+), 128 deletions(-) diff --git a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts index c600825d6f..33da37bc55 100644 --- a/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts +++ b/plugins/catalog-backend/src/database/operations/provider/deleteWithEagerPruningOfChildren.ts @@ -16,7 +16,7 @@ import { Knex } from 'knex'; import lodash from 'lodash'; -import { DbRefreshStateReferencesRow, DbRefreshStateRow } from '../../tables'; +import { DbRefreshStateReferencesRow } from '../../tables'; /** * Given a number of entity refs originally created by a given entity provider @@ -36,135 +36,152 @@ export async function deleteWithEagerPruningOfChildren(options: { // limits for the number of permitted bindings on a precompiled statement let removedCount = 0; for (const refs of lodash.chunk(entityRefs, 1000)) { - /* - WITH RECURSIVE - -- All the nodes that can be reached downwards from our root - descendants(entity_ref) AS ( - SELECT target_entity_ref - FROM refresh_state_references - WHERE source_key = "R1" AND target_entity_ref IN [...refs] - UNION - SELECT target_entity_ref - FROM descendants - JOIN refresh_state_references ON source_entity_ref = descendants.entity_ref - ), - -- All the individual relations that can be reached upwards from each descendant - ancestors(source_key, source_entity_ref, target_entity_ref, subject) AS ( - SELECT source_key, source_entity_ref, target_entity_ref, descendants.entity_ref - FROM descendants - JOIN refresh_state_references ON refresh_state_references.target_entity_ref = descendants.entity_ref - UNION - SELECT - refresh_state_references.source_key, - refresh_state_references.source_entity_ref, - refresh_state_references.target_entity_ref, - ancestors.subject - FROM ancestors - JOIN refresh_state_references ON refresh_state_references.target_entity_ref = ancestors.source_entity_ref - ) - -- Start out with all of the descendants - SELECT descendants.entity_ref - FROM descendants - -- Exclude those who seem to have a root relation somewhere upwards that's not part of our own deletion - WHERE NOT EXISTS ( - SELECT * FROM ancestors - WHERE ancestors.subject = descendants.entity_ref - AND ancestors.source_key IS NOT NULL - AND (ancestors.source_key != "R1" OR ancestors.target_entity_ref NOT IN [...refs]) - ) - */ - removedCount += await tx('refresh_state') - .whereIn('entity_ref', function orphanedEntityRefs(orphans) { - return ( - orphans - // All the nodes that can be reached downwards from our root - .withRecursive( - 'descendants', - ['entity_ref'], - function descendants(outer) { - return outer - .select({ entity_ref: 'target_entity_ref' }) - .from('refresh_state_references') - .where('source_key', '=', sourceKey) - .whereIn('target_entity_ref', refs) - .union(function recursive(inner) { - return inner - .select({ - entity_ref: - 'refresh_state_references.target_entity_ref', - }) - .from('descendants') - .join('refresh_state_references', { - 'descendants.entity_ref': - 'refresh_state_references.source_entity_ref', - }); - }); - }, - ) - // All the relations that can be reached upwards from each descendant - .withRecursive( - 'ancestors', - [ - 'source_key', - 'source_entity_ref', - 'target_entity_ref', - 'subject', - ], - function ancestors(outer) { - return outer - .select({ - source_key: 'refresh_state_references.source_key', - source_entity_ref: - 'refresh_state_references.source_entity_ref', - target_entity_ref: - 'refresh_state_references.target_entity_ref', - subject: 'descendants.entity_ref', - }) + removedCount += await tx + .delete() + .from('refresh_state') + .whereIn('entity_ref', orphans => + orphans + // First find all nodes that can be reached downwards from the roots + // (deletion targets), including the roots themselves, by traversing + // down the refresh_state_references table. Note that this query + // starts with a condition that source_key = our source key, and + // target_entity_ref is one of the deletion targets. This has two + // effects: it won't match attempts at deleting something that didn't + // originate from us in the first place, and also won't match non-root + // entities (source_key would be null for those). + // + // KeyA - R1 - R2 Legend: + // \ ----------------------------------------- + // R3 Key* Source key + // / R* Entity ref + // KeyA - R4 - R5 lines Individual references; sources to + // / the left and targets to the right + // KeyB --- R6 + // + // The scenario is that KeyA wants to delete R1. + // + // The query starts with the KeyA-R1 reference, and then traverses + // down to also find R2 and R3. It uses union instead of union all, + // because it wants to find the set of unique descendants even if + // the tree has unexpected loops etc. + .withRecursive('descendants', ['entity_ref'], initial => + initial + .select('target_entity_ref') + .from('refresh_state_references') + .where('source_key', '=', sourceKey) + .whereIn('target_entity_ref', refs) + .union(recursive => + recursive + .select('refresh_state_references.target_entity_ref') .from('descendants') - .join('refresh_state_references', { - 'refresh_state_references.target_entity_ref': - 'descendants.entity_ref', - }) - .union(function recursive(inner) { - return inner - .select({ - source_key: 'refresh_state_references.source_key', - source_entity_ref: - 'refresh_state_references.source_entity_ref', - target_entity_ref: - 'refresh_state_references.target_entity_ref', - subject: 'ancestors.subject', - }) - .from('ancestors') - .join('refresh_state_references', { - 'refresh_state_references.target_entity_ref': - 'ancestors.source_entity_ref', - }); - }); - }, - ) - // Start out with all of the descendants - .select('descendants.entity_ref') - .from('descendants') - // Exclude those who seem to have a root relation somewhere upwards that's not part of our own deletion - .whereNotExists(function otherAncestors(outer) { - outer - .from('ancestors') - .where( - 'ancestors.subject', - '=', - tx.ref('descendants.entity_ref'), + .join( + 'refresh_state_references', + 'descendants.entity_ref', + 'refresh_state_references.source_entity_ref', + ), + ), + ) + // Then for each descendant, traverse all the way back upwards through + // the refresh_state_references table to get an exhaustive list of all + // references that are part of keeping that particular descendant + // alive. + // + // Continuing the scenario from above, starting from R3, it goes + // upwards to find every pair along every relation line. + // + // Top branch: R2-R3, R1-R2, KeyA-R1 + // Middle branch: R5-R3, R4-R5, KeyA-R4 + // Bottom branch: R6-R5, KeyB-R6 + // + // Note that this all applied to the subject R3. The exact same thing + // will be done starting from each other descendant (R2 and R1). They + // only have one and two references to find, respectively. + // + // This query also uses union instead of union all, to get the set of + // distinct relations even if the tree has unexpected loops etc. + .withRecursive( + 'ancestors', + ['source_key', 'source_entity_ref', 'target_entity_ref', 'subject'], + initial => + initial + .select( + 'refresh_state_references.source_key', + 'refresh_state_references.source_entity_ref', + 'refresh_state_references.target_entity_ref', + 'descendants.entity_ref', ) - .whereNotNull('ancestors.source_key') - .andWhere(function differentRoot(inner) { - inner - .where('ancestors.source_key', '!=', sourceKey) - .orWhereNotIn('ancestors.target_entity_ref', refs); - }); - }) - ); - }) - .delete(); + .from('descendants') + .join( + 'refresh_state_references', + 'refresh_state_references.target_entity_ref', + 'descendants.entity_ref', + ) + .union(recursive => + recursive + .select( + 'refresh_state_references.source_key', + 'refresh_state_references.source_entity_ref', + 'refresh_state_references.target_entity_ref', + 'ancestors.subject', + ) + .from('ancestors') + .join( + 'refresh_state_references', + 'refresh_state_references.target_entity_ref', + 'ancestors.source_entity_ref', + ), + ), + ) + // Finally, from that list of ancestor relations per descendant, pick + // out the ones that are roots (have a source_key). Specifically, find + // ones that seem to be be either (1) from another source, or (2) + // aren't part of the deletion targets. Those are markers that tell us + // that the corresponding descendant should be kept alive and NOT + // subject to eager deletion, because there's "something else" (not + // targeted for deletion) that has references down through the tree to + // it. + // + // Continuing the scenario from above, for R3 we have + // + // KeyA-R1, KeyA-R4, KeyB-R6 + // + // This tells us that R3 should be kept alive for two reasons: it's + // referenced by a node that isn't being deleted (R4), and also by + // another source (KeyB). What about R1 and R2? They both have + // + // KeyA-R1 + // + // So those should be deleted, since they are definitely only being + // kept alive by something that's about to be deleted. + // + // Final shape of the tree: + // + // R3 + // / + // KeyA - R4 - R5 + // / + // KeyB --- R6 + .with('retained', ['entity_ref'], notPartOfDeletion => + notPartOfDeletion + .select('subject') + .from('ancestors') + .whereNotNull('ancestors.source_key') + .where(foreignKeyOrRef => + foreignKeyOrRef + .where('ancestors.source_key', '!=', sourceKey) + .orWhereNotIn('ancestors.target_entity_ref', refs), + ), + ) + // Return all descendants minus the retained ones + .select('descendants.entity_ref') + .from('descendants') + .leftOuterJoin( + 'retained', + 'retained.entity_ref', + 'descendants.entity_ref', + ) + .whereNull('retained.entity_ref'), + ); // Delete the references that originate only from this entity provider. Note // that there may be more than one entity provider making a "claim" for a From 0c1d0229aa6530dc7bf2fdaf18380c75e2a27a71 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 13 Feb 2023 13:46:03 +0100 Subject: [PATCH 12/17] remove not needed test dependencies Signed-off-by: Katharina Sick --- plugins/auth-backend/package.json | 2 -- .../src/providers/bitbucketServer/provider.test.ts | 5 ++--- plugins/auth-backend/src/setupTests.ts | 3 +-- yarn.lock | 4 +--- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 91fb5bed5b..a95465dbad 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -78,7 +78,6 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@testing-library/jest-dom": "^5.16.5", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", @@ -90,7 +89,6 @@ "@types/passport-saml": "^1.1.3", "@types/passport-strategy": "^0.2.35", "@types/xml2js": "^0.4.7", - "cross-fetch": "^3.1.5", "msw": "^0.49.0", "supertest": "^6.1.3" }, diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts index 3934cafd36..1a661b82bc 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts @@ -15,7 +15,7 @@ */ import * as helpers from '../../lib/passport/PassportStrategyHelper'; -import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper'; +import { makeProfileInfo } from '../../lib/passport'; import { AuthResolverContext } from '../types'; import { bitbucketServer, @@ -24,10 +24,9 @@ import { } from './provider'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/test-utils'; -import { fetch } from 'cross-fetch'; import { rest } from 'msw'; -global.fetch = fetch; +global.fetch = require('node-fetch'); jest.mock('../../lib/passport/PassportStrategyHelper', () => { return { diff --git a/plugins/auth-backend/src/setupTests.ts b/plugins/auth-backend/src/setupTests.ts index c1d649f2ad..d3232290a7 100644 --- a/plugins/auth-backend/src/setupTests.ts +++ b/plugins/auth-backend/src/setupTests.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -import '@testing-library/jest-dom'; -import 'cross-fetch/polyfill'; +export {}; diff --git a/yarn.lock b/yarn.lock index 7bfbb5ea1f..1621cdc5b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4537,7 +4537,6 @@ __metadata: "@backstage/types": "workspace:^" "@davidzemon/passport-okta-oauth": ^0.0.5 "@google-cloud/firestore": ^6.0.0 - "@testing-library/jest-dom": ^5.16.5 "@types/body-parser": ^1.19.0 "@types/cookie-parser": ^1.4.2 "@types/express": ^4.17.6 @@ -4554,7 +4553,6 @@ __metadata: compression: ^1.7.4 cookie-parser: ^1.4.5 cors: ^2.8.5 - cross-fetch: ^3.1.5 express: ^4.17.1 express-promise-router: ^4.1.0 express-session: ^1.17.1 @@ -13580,7 +13578,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4, @testing-library/jest-dom@npm:^5.16.5": +"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4": version: 5.16.5 resolution: "@testing-library/jest-dom@npm:5.16.5" dependencies: From dfbe698743d83672e4d0092d46e77a451a676cfa Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Mon, 13 Feb 2023 09:38:48 -0500 Subject: [PATCH 13/17] chore(review): address comments Signed-off-by: Phil Kuang --- .github/CODEOWNERS | 2 ++ plugins/entity-feedback-backend/package.json | 2 +- .../src/service/DatabaseHandler.ts | 9 +++++--- plugins/entity-feedback-common/api-report.md | 23 +++++++++---------- plugins/entity-feedback-common/package.json | 2 +- plugins/entity-feedback-common/src/index.ts | 2 +- plugins/entity-feedback/api-report.md | 14 +++++++---- plugins/entity-feedback/package.json | 2 +- .../src/api/EntityFeedbackApi.ts | 8 ++++--- .../src/api/EntityFeedbackClient.ts | 6 ++--- .../FeedbackResponseTable.tsx | 4 ++-- 11 files changed, 42 insertions(+), 32 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bb6f8da19b..d4d5f91a0d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -37,6 +37,8 @@ yarn.lock @backstage/maintainers @back /plugins/code-coverage-backend @backstage/maintainers @alde @nissayeva /plugins/cost-insights @backstage/maintainers @backstage/silver-lining /plugins/cost-insights-* @backstage/maintainers @backstage/silver-lining +/plugins/entity-feedback @backstage/maintainers @kuangp +/plugins/entity-feedback-* @backstage/maintainers @kuangp /plugins/events-backend @backstage/maintainers @pjungermann /plugins/events-backend-module-aws-sqs @backstage/maintainers @pjungermann /plugins/events-backend-module-azure @backstage/maintainers @pjungermann diff --git a/plugins/entity-feedback-backend/package.json b/plugins/entity-feedback-backend/package.json index 487d5f5d5e..7fbc7b9f7e 100644 --- a/plugins/entity-feedback-backend/package.json +++ b/plugins/entity-feedback-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-entity-feedback-backend", - "version": "0.1.0", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts index aeeaffd694..9b81cd94bb 100644 --- a/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts +++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts @@ -15,7 +15,10 @@ */ import { resolvePackagePath } from '@backstage/backend-common'; -import { Rating, Response } from '@backstage/plugin-entity-feedback-common'; +import { + FeedbackResponse, + Rating, +} from '@backstage/plugin-entity-feedback-common'; import { Knex } from 'knex'; const migrationsDir = resolvePackagePath( @@ -119,7 +122,7 @@ export class DatabaseHandler { ).map(rating => ({ userRef: rating.user_ref, rating: rating.rating })); } - async recordResponse(response: Response) { + async recordResponse(response: FeedbackResponse) { await this.database('responses').insert({ entity_ref: response.entityRef, response: response.response, @@ -131,7 +134,7 @@ export class DatabaseHandler { async getResponses( entityRef: string, - ): Promise[]> { + ): Promise[]> { return ( await this.database('responses') .where('entity_ref', entityRef) diff --git a/plugins/entity-feedback-common/api-report.md b/plugins/entity-feedback-common/api-report.md index 35da32c1da..118e6c78f2 100644 --- a/plugins/entity-feedback-common/api-report.md +++ b/plugins/entity-feedback-common/api-report.md @@ -16,17 +16,7 @@ export interface EntityRatingsData { } // @public (undocumented) -export interface Rating { - // (undocumented) - entityRef: string; - // (undocumented) - rating: string; - // (undocumented) - userRef: string; -} - -// @public (undocumented) -interface Response_2 { +export interface FeedbackResponse { // (undocumented) comments?: string; // (undocumented) @@ -38,5 +28,14 @@ interface Response_2 { // (undocumented) userRef: string; } -export { Response_2 as Response }; + +// @public (undocumented) +export interface Rating { + // (undocumented) + entityRef: string; + // (undocumented) + rating: string; + // (undocumented) + userRef: string; +} ``` diff --git a/plugins/entity-feedback-common/package.json b/plugins/entity-feedback-common/package.json index 45a5a2b131..6316b72943 100644 --- a/plugins/entity-feedback-common/package.json +++ b/plugins/entity-feedback-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-entity-feedback-common", "description": "Common functionalities for the entity-feedback plugin", - "version": "0.1.0", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/entity-feedback-common/src/index.ts b/plugins/entity-feedback-common/src/index.ts index f19fcdd648..eea289a899 100644 --- a/plugins/entity-feedback-common/src/index.ts +++ b/plugins/entity-feedback-common/src/index.ts @@ -32,7 +32,7 @@ export interface Rating { /** * @public */ -export interface Response { +export interface FeedbackResponse { entityRef: string; response?: string; comments?: string; diff --git a/plugins/entity-feedback/api-report.md b/plugins/entity-feedback/api-report.md index 399e84e674..06b0166b0d 100644 --- a/plugins/entity-feedback/api-report.md +++ b/plugins/entity-feedback/api-report.md @@ -10,10 +10,10 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityRatingsData } from '@backstage/plugin-entity-feedback-common'; +import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common'; import { FetchApi } from '@backstage/core-plugin-api'; import { Rating } from '@backstage/plugin-entity-feedback-common'; import { ReactNode } from 'react'; -import { Response as Response_2 } from '@backstage/plugin-entity-feedback-common'; import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) @@ -25,13 +25,15 @@ export interface EntityFeedbackApi { // (undocumented) getRatings(entityRef: string): Promise[]>; // (undocumented) - getResponses(entityRef: string): Promise[]>; + getResponses( + entityRef: string, + ): Promise[]>; // (undocumented) recordRating(entityRef: string, rating: string): Promise; // (undocumented) recordResponse( entityRef: string, - response: Omit, + response: Omit, ): Promise; } @@ -48,13 +50,15 @@ export class EntityFeedbackClient implements EntityFeedbackApi { // (undocumented) getRatings(entityRef: string): Promise[]>; // (undocumented) - getResponses(entityRef: string): Promise[]>; + getResponses( + entityRef: string, + ): Promise[]>; // (undocumented) recordRating(entityRef: string, rating: string): Promise; // (undocumented) recordResponse( entityRef: string, - response: Omit, + response: Omit, ): Promise; } diff --git a/plugins/entity-feedback/package.json b/plugins/entity-feedback/package.json index 6d71b04cc2..f8560a9237 100644 --- a/plugins/entity-feedback/package.json +++ b/plugins/entity-feedback/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-entity-feedback", - "version": "0.1.0", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", diff --git a/plugins/entity-feedback/src/api/EntityFeedbackApi.ts b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts index f8f6b4fccc..d2f1a46f55 100644 --- a/plugins/entity-feedback/src/api/EntityFeedbackApi.ts +++ b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts @@ -17,8 +17,8 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { EntityRatingsData, + FeedbackResponse, Rating, - Response, } from '@backstage/plugin-entity-feedback-common'; /** @@ -42,8 +42,10 @@ export interface EntityFeedbackApi { recordResponse( entityRef: string, - response: Omit, + response: Omit, ): Promise; - getResponses(entityRef: string): Promise[]>; + getResponses( + entityRef: string, + ): Promise[]>; } diff --git a/plugins/entity-feedback/src/api/EntityFeedbackClient.ts b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts index 43d3cbe95a..34856473c8 100644 --- a/plugins/entity-feedback/src/api/EntityFeedbackClient.ts +++ b/plugins/entity-feedback/src/api/EntityFeedbackClient.ts @@ -18,8 +18,8 @@ import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { EntityRatingsData, + FeedbackResponse, Rating, - Response, } from '@backstage/plugin-entity-feedback-common'; import { EntityFeedbackApi } from './EntityFeedbackApi'; @@ -99,7 +99,7 @@ export class EntityFeedbackClient implements EntityFeedbackApi { async recordResponse( entityRef: string, - response: Omit, + response: Omit, ) { const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); const resp = await this.fetchApi.fetch( @@ -118,7 +118,7 @@ export class EntityFeedbackClient implements EntityFeedbackApi { async getResponses( entityRef: string, - ): Promise[]> { + ): Promise[]> { const baseUrl = await this.discoveryApi.getBaseUrl('entity-feedback'); const resp = await this.fetchApi.fetch( `${baseUrl}/responses/${encodeURIComponent(entityRef)}`, diff --git a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx index 2bc03b8537..ba954c7747 100644 --- a/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx +++ b/plugins/entity-feedback/src/components/FeedbackResponseTable/FeedbackResponseTable.tsx @@ -18,7 +18,7 @@ import { parseEntityRef } from '@backstage/catalog-model'; import { ErrorPanel, Table } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { humanizeEntityRef } from '@backstage/plugin-catalog-react'; -import { Response } from '@backstage/plugin-entity-feedback-common'; +import { FeedbackResponse } from '@backstage/plugin-entity-feedback-common'; import { BackstageTheme } from '@backstage/theme'; import { Chip, makeStyles } from '@material-ui/core'; import CheckIcon from '@material-ui/icons/Check'; @@ -27,7 +27,7 @@ import useAsync from 'react-use/lib/useAsync'; import { entityFeedbackApiRef } from '../../api'; -type ResponseRow = Omit; +type ResponseRow = Omit; const useStyles = makeStyles(theme => ({ consentCheck: { From 0303587bcefb28694bd9d43d2fb6e8af2796ef7d Mon Sep 17 00:00:00 2001 From: Justin De Burgo <57914589+jpdeburgo@users.noreply.github.com> Date: Mon, 13 Feb 2023 09:21:44 -0600 Subject: [PATCH 14/17] Update .changeset/famous-squids-wonder.md Co-authored-by: Patrik Oldsberg Signed-off-by: Justin De Burgo <57914589+jpdeburgo@users.noreply.github.com> --- .changeset/famous-squids-wonder.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/famous-squids-wonder.md b/.changeset/famous-squids-wonder.md index 11ca76ddc1..5b027e9a63 100644 --- a/.changeset/famous-squids-wonder.md +++ b/.changeset/famous-squids-wonder.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': patch --- -Adding dot option to mini match used in catalog rules to allow for catalog imports with hidden folders +Location rule target patterns now also match hidden files, i.e. path components with a leading dot. From 1fad33684e4f6aac04b3865b924a19e16a110b5a Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 13 Feb 2023 16:31:49 +0100 Subject: [PATCH 15/17] use node-fetch Signed-off-by: Katharina Sick --- plugins/auth-backend/package.json | 2 +- .../bitbucketServer/provider.test.ts | 2 - .../src/providers/bitbucketServer/provider.ts | 3 +- yarn.lock | 49 ++++++++++++++++--- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a95465dbad..67a532e6fd 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -59,7 +59,7 @@ "minimatch": "^5.0.0", "morgan": "^1.10.0", "node-cache": "^5.1.2", - "node-fetch": "^2.6.7", + "node-fetch": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", "passport-auth0": "^1.4.3", diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts index 1a661b82bc..fc48cdbe8b 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts @@ -26,8 +26,6 @@ import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; -global.fetch = require('node-fetch'); - jest.mock('../../lib/passport/PassportStrategyHelper', () => { return { ...jest.requireActual('../../lib/passport/PassportStrategyHelper'), diff --git a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts index cd082ee953..fb8dd3d5b8 100644 --- a/plugins/auth-backend/src/providers/bitbucketServer/provider.ts +++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts @@ -42,6 +42,7 @@ import express from 'express'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { Profile as PassportProfile } from 'passport'; import { commonByEmailResolver } from '../resolvers'; +import fetch from 'node-fetch'; type PrivateInfo = { refreshToken: string; @@ -215,7 +216,7 @@ export class BitbucketServerAuthProvider implements OAuthHandlers { throw new Error(`Failed to retrieve the user '${username}'`); } - const user = await userResponse.json(); + const user = (await userResponse.json()) as any; const passportProfile = { provider: 'bitbucketServer', diff --git a/yarn.lock b/yarn.lock index 1621cdc5b1..232efe185c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4567,7 +4567,7 @@ __metadata: morgan: ^1.10.0 msw: ^0.49.0 node-cache: ^5.1.2 - node-fetch: ^2.6.7 + node-fetch: ^3.3.0 openid-client: ^5.2.1 passport: ^0.6.0 passport-auth0: ^1.4.3 @@ -20160,6 +20160,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c + languageName: node + linkType: hard + "data-urls@npm:^2.0.0": version: 2.0.0 resolution: "data-urls@npm:2.0.0" @@ -22814,6 +22821,16 @@ __metadata: languageName: node linkType: hard +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: ^1.0.0 + web-streams-polyfill: ^3.0.3 + checksum: f19bc28a2a0b9626e69fd7cf3a05798706db7f6c7548da657cbf5026a570945f5eeaedff52007ea35c8bcd3d237c58a20bf1543bc568ab2422411d762dd3d5bf + languageName: node + linkType: hard + "figures@npm:^3.0.0, figures@npm:^3.2.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -23204,6 +23221,15 @@ __metadata: languageName: node linkType: hard +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" + dependencies: + fetch-blob: ^3.1.2 + checksum: 82a34df292afadd82b43d4a740ce387bc08541e0a534358425193017bf9fb3567875dc5f69564984b1da979979b70703aa73dee715a17b6c229752ae736dd9db + languageName: node + linkType: hard + "formidable@npm:^2.0.1": version: 2.1.2 resolution: "formidable@npm:2.1.2" @@ -29791,7 +29817,7 @@ __metadata: languageName: node linkType: hard -"node-domexception@npm:1.0.0": +"node-domexception@npm:1.0.0, node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" checksum: ee1d37dd2a4eb26a8a92cd6b64dfc29caec72bff5e1ed9aba80c294f57a31ba4895a60fd48347cf17dd6e766da0ae87d75657dfd1f384ebfa60462c2283f5c7f @@ -29836,6 +29862,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^3.3.0": + version: 3.3.0 + resolution: "node-fetch@npm:3.3.0" + dependencies: + data-uri-to-buffer: ^4.0.0 + fetch-blob: ^3.1.4 + formdata-polyfill: ^4.0.10 + checksum: e9936908d2783d3c48a038e187f8062de294d75ef43ec8ab812d7cbd682be2b67605868758d2e9cad6103706dcfe4a9d21d78f6df984e8edf10e7a5ce2e665f8 + languageName: node + linkType: hard + "node-forge@npm:^1, node-forge@npm:^1.3.1": version: 1.3.1 resolution: "node-forge@npm:1.3.1" @@ -38005,10 +38042,10 @@ __metadata: languageName: node linkType: hard -"web-streams-polyfill@npm:^3.2.0": - version: 3.2.0 - resolution: "web-streams-polyfill@npm:3.2.0" - checksum: e23ad0649392fa0159dbfc6bb27474c308c3f332d9078cfef3c06c154165bef18732c5814126147c6c712f604216ddc950c171c854e3821f020e0d2d721a5958 +"web-streams-polyfill@npm:^3.0.3, web-streams-polyfill@npm:^3.2.0": + version: 3.2.1 + resolution: "web-streams-polyfill@npm:3.2.1" + checksum: b119c78574b6d65935e35098c2afdcd752b84268e18746606af149e3c424e15621b6f1ff0b42b2676dc012fc4f0d313f964b41a4b5031e525faa03997457da02 languageName: node linkType: hard From 02ac23ea90cf369af8c2eda9b721dd311236e3a8 Mon Sep 17 00:00:00 2001 From: Katharina Sick Date: Mon, 13 Feb 2023 16:34:37 +0100 Subject: [PATCH 16/17] reset node-fetch version Signed-off-by: Katharina Sick --- plugins/auth-backend/package.json | 2 +- yarn.lock | 43 +++---------------------------- 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 67a532e6fd..a95465dbad 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -59,7 +59,7 @@ "minimatch": "^5.0.0", "morgan": "^1.10.0", "node-cache": "^5.1.2", - "node-fetch": "^3.3.0", + "node-fetch": "^2.6.7", "openid-client": "^5.2.1", "passport": "^0.6.0", "passport-auth0": "^1.4.3", diff --git a/yarn.lock b/yarn.lock index 232efe185c..4f9bfd99af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4567,7 +4567,7 @@ __metadata: morgan: ^1.10.0 msw: ^0.49.0 node-cache: ^5.1.2 - node-fetch: ^3.3.0 + node-fetch: ^2.6.7 openid-client: ^5.2.1 passport: ^0.6.0 passport-auth0: ^1.4.3 @@ -20160,13 +20160,6 @@ __metadata: languageName: node linkType: hard -"data-uri-to-buffer@npm:^4.0.0": - version: 4.0.1 - resolution: "data-uri-to-buffer@npm:4.0.1" - checksum: 0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c - languageName: node - linkType: hard - "data-urls@npm:^2.0.0": version: 2.0.0 resolution: "data-urls@npm:2.0.0" @@ -22821,16 +22814,6 @@ __metadata: languageName: node linkType: hard -"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": - version: 3.2.0 - resolution: "fetch-blob@npm:3.2.0" - dependencies: - node-domexception: ^1.0.0 - web-streams-polyfill: ^3.0.3 - checksum: f19bc28a2a0b9626e69fd7cf3a05798706db7f6c7548da657cbf5026a570945f5eeaedff52007ea35c8bcd3d237c58a20bf1543bc568ab2422411d762dd3d5bf - languageName: node - linkType: hard - "figures@npm:^3.0.0, figures@npm:^3.2.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -23221,15 +23204,6 @@ __metadata: languageName: node linkType: hard -"formdata-polyfill@npm:^4.0.10": - version: 4.0.10 - resolution: "formdata-polyfill@npm:4.0.10" - dependencies: - fetch-blob: ^3.1.2 - checksum: 82a34df292afadd82b43d4a740ce387bc08541e0a534358425193017bf9fb3567875dc5f69564984b1da979979b70703aa73dee715a17b6c229752ae736dd9db - languageName: node - linkType: hard - "formidable@npm:^2.0.1": version: 2.1.2 resolution: "formidable@npm:2.1.2" @@ -29817,7 +29791,7 @@ __metadata: languageName: node linkType: hard -"node-domexception@npm:1.0.0, node-domexception@npm:^1.0.0": +"node-domexception@npm:1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" checksum: ee1d37dd2a4eb26a8a92cd6b64dfc29caec72bff5e1ed9aba80c294f57a31ba4895a60fd48347cf17dd6e766da0ae87d75657dfd1f384ebfa60462c2283f5c7f @@ -29862,17 +29836,6 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:^3.3.0": - version: 3.3.0 - resolution: "node-fetch@npm:3.3.0" - dependencies: - data-uri-to-buffer: ^4.0.0 - fetch-blob: ^3.1.4 - formdata-polyfill: ^4.0.10 - checksum: e9936908d2783d3c48a038e187f8062de294d75ef43ec8ab812d7cbd682be2b67605868758d2e9cad6103706dcfe4a9d21d78f6df984e8edf10e7a5ce2e665f8 - languageName: node - linkType: hard - "node-forge@npm:^1, node-forge@npm:^1.3.1": version: 1.3.1 resolution: "node-forge@npm:1.3.1" @@ -38042,7 +38005,7 @@ __metadata: languageName: node linkType: hard -"web-streams-polyfill@npm:^3.0.3, web-streams-polyfill@npm:^3.2.0": +"web-streams-polyfill@npm:^3.2.0": version: 3.2.1 resolution: "web-streams-polyfill@npm:3.2.1" checksum: b119c78574b6d65935e35098c2afdcd752b84268e18746606af149e3c424e15621b6f1ff0b42b2676dc012fc4f0d313f964b41a4b5031e525faa03997457da02 From 67ed63552446b512d549e911e13cd7d36eb36186 Mon Sep 17 00:00:00 2001 From: Christopher Kruse Date: Mon, 13 Feb 2023 10:28:03 -0800 Subject: [PATCH 17/17] fix: use `NotFoundError` for missing team in org Per [PR comment][1], better to use the provided error type that more closely matches the error encountered. [1]: https://github.com/backstage/backstage/pull/16059#discussion_r1103873709 Signed-off-by: Christopher Kruse --- .../src/scaffolder/actions/builtin/github/helpers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index dc8c4cd856..75c8f25d8b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -15,7 +15,7 @@ */ import { Config } from '@backstage/config'; -import { assertError, InputError } from '@backstage/errors'; +import { assertError, InputError, NotFoundError } from '@backstage/errors'; import { DefaultGithubCredentialsProvider, GithubCredentialsProvider, @@ -370,7 +370,7 @@ async function validateAccessTeam(client: Octokit, access: string) { if (e.response.data.message === 'Not Found') { const message = `Received 'Not Found' from the API; one of org: ${org} or team: ${team_slug} was not found within GitHub.`; - throw new Error(message, { cause: e }); + throw new NotFoundError(message); } } }