diff --git a/.changeset/famous-squids-wonder.md b/.changeset/famous-squids-wonder.md
new file mode 100644
index 0000000000..5b027e9a63
--- /dev/null
+++ b/.changeset/famous-squids-wonder.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-backend': patch
+---
+
+Location rule target patterns now also match hidden files, i.e. path components with a leading dot.
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/.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/.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/.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/.github/CODEOWNERS b/.github/CODEOWNERS
index 1c6171cb01..10f1ac188c 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/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/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 0000000000..e172b260a0
Binary files /dev/null and b/microsite/static/img/entity-feedback-logo.png differ
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/package.json b/packages/app/package.json
index 80ee11ea15..afb1738207 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 3ab0e83238..36a8b7553f 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,
@@ -376,6 +381,12 @@ const overviewContent = (
+
+
+
+
+
+
{cicdCard}
@@ -513,6 +524,10 @@ const serviceEntityPage = (
>
+
+
+
+
);
@@ -592,6 +607,10 @@ const websiteEntityPage = (
+
+
+
+
);
@@ -608,6 +627,10 @@ const defaultEntityPage = (
+
+
+
+
);
@@ -644,6 +667,11 @@ const apiPage = (
+
+
+
+
+
@@ -656,6 +684,10 @@ const apiPage = (
+
+
+
+
);
@@ -673,6 +705,9 @@ const userPage = (
entityFilterKind={customEntityFilterKind}
/>
+
+
+
@@ -695,6 +730,9 @@ const groupPage = (
+
+
+
@@ -720,6 +758,11 @@ const systemPage = (
+
+
+
+
+
@@ -748,6 +791,9 @@ const systemPage = (
unidirectional={false}
/>
+
+
+
);
@@ -765,8 +811,16 @@ const domainPage = (
+
+
+
+
+
+
+
+
);
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/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/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/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/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-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/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/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/api-report.md b/plugins/auth-backend/api-report.md
index f6b19189cd..63aa060875 100644
--- a/plugins/auth-backend/api-report.md
+++ b/plugins/auth-backend/api-report.md
@@ -132,6 +132,19 @@ export type BitbucketPassportProfile = Profile & {
};
};
+// @public (undocumented)
+export type BitbucketServerOAuthResult = {
+ fullProfile: Profile;
+ 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 +493,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;
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..fc48cdbe8b
--- /dev/null
+++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.test.ts
@@ -0,0 +1,390 @@
+/*
+ * 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';
+import { AuthResolverContext } from '../types';
+import {
+ bitbucketServer,
+ BitbucketServerAuthProvider,
+ BitbucketServerOAuthResult,
+} from './provider';
+import { setupServer } from 'msw/node';
+import { setupRequestMockHandlers } from '@backstage/test-utils';
+import { rest } from 'msw';
+
+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 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 provider = new BitbucketServerAuthProvider({
+ resolverContext: {
+ signInWithCatalogUser: jest.fn(info => {
+ return {
+ token: `token-for-user:${info.filter['spec.profile.email']}`,
+ };
+ }),
+ } as unknown as AuthResolverContext,
+ signInResolver:
+ bitbucketServer.resolvers.emailMatchingUserEntityProfileEmail(),
+ authHandler: async ({ fullProfile }) => ({
+ profile: makeProfileInfo(fullProfile),
+ }),
+ callbackUrl: 'mock',
+ clientId: 'mock',
+ clientSecret: 'mock',
+ 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 () => {
+ server.use(whoAmIHandler(), getUserHandler());
+
+ 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 () => {
+ server.use(whoAmIHandler({ fail: true }), getUserHandler());
+
+ 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 () => {
+ server.use(whoAmIHandler({ value: '' }), getUserHandler());
+
+ 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 () => {
+ server.use(whoAmIHandler(), getUserHandler({ fail: 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 () => {
+ server.use(whoAmIHandler(), getUserHandler({ status: 500 }));
+ 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 () => {
+ server.use(whoAmIHandler(), getUserHandler({ avatarUrl: '' }));
+ 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 () => {
+ server.use(whoAmIHandler(), getUserHandler({ noDisplayName: 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',
+ 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 () => {
+ server.use(
+ whoAmIHandler(),
+ getUserHandler({ noDisplayName: true, noUserName: 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: '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', () => {
+ const server = setupServer();
+ setupRequestMockHandlers(server);
+
+ it('should forward the refresh token', async () => {
+ server.use(whoAmIHandler(), getUserHandler());
+
+ 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 () => {
+ server.use(whoAmIHandler(), getUserHandler());
+
+ 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..fb8dd3d5b8
--- /dev/null
+++ b/plugins/auth-backend/src/providers/bitbucketServer/provider.ts
@@ -0,0 +1,305 @@
+/*
+ * 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 { Profile as PassportProfile } from 'passport';
+import { commonByEmailResolver } from '../resolvers';
+import fetch from 'node-fetch';
+
+type PrivateInfo = {
+ refreshToken: string;
+};
+
+/** @public */
+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()) as any;
+
+ const passportProfile = {
+ provider: 'bitbucketServer',
+ id: user.id.toString(),
+ displayName: user.displayName,
+ username: user.name,
+ emails: [
+ {
+ value: user.emailAddress,
+ },
+ ],
+ } as PassportProfile;
+
+ if (user.avatarUrl) {
+ passportProfile.photos = [
+ { value: `https://${this.host}${user.avatarUrl}` },
+ ];
+ }
+
+ return passportProfile;
+ }
+}
+
+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:
+ (): SignInResolver => 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/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..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
@@ -31,116 +31,157 @@ 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
- FROM refresh_state_references
- WHERE source_key = "R1" AND target_entity_ref = "A"
- UNION
- SELECT descendants.root_id, 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
- FROM descendants
- UNION
- SELECT
- CASE WHEN source_key IS NOT NULL THEN id ELSE NULL END,
- source_entity_ref,
- ancestors.to_entity_ref
- FROM ancestors
- JOIN refresh_state_references ON target_entity_ref = ancestors.via_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;
- */
- 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',
- })
+ 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',
+ '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',
+ )
.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',
- })
+ .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', {
- target_entity_ref: 'ancestors.via_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');
- })
- .whereNull('ancestors.root_id')
- );
- })
- .delete();
+ .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
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;
}
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..7fbc7b9f7e
--- /dev/null
+++ b/plugins/entity-feedback-backend/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@backstage/plugin-entity-feedback-backend",
+ "version": "0.0.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..9b81cd94bb
--- /dev/null
+++ b/plugins/entity-feedback-backend/src/service/DatabaseHandler.ts
@@ -0,0 +1,164 @@
+/*
+ * 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 {
+ FeedbackResponse,
+ Rating,
+} 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: FeedbackResponse) {
+ 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..118e6c78f2
--- /dev/null
+++ b/plugins/entity-feedback-common/api-report.md
@@ -0,0 +1,41 @@
+## 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 FeedbackResponse {
+ // (undocumented)
+ comments?: string;
+ // (undocumented)
+ consent?: boolean;
+ // (undocumented)
+ entityRef: string;
+ // (undocumented)
+ response?: string;
+ // (undocumented)
+ userRef: string;
+}
+
+// @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
new file mode 100644
index 0000000000..6316b72943
--- /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.0.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..eea289a899
--- /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 FeedbackResponse {
+ 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
+
+
+
+#### Starred rating
+
+
+
+### Request additional feedback when poorly rated
+
+
+
+### View entity feedback responses
+
+
+
+### View aggregated ratings on owned entities
+
+#### Total likes/dislikes
+
+
+
+#### Star breakdowns
+
+
+
+## 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..06b0166b0d
--- /dev/null
+++ b/plugins/entity-feedback/api-report.md
@@ -0,0 +1,184 @@
+## 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 { 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 { 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 0000000000..74270c805a
Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-dialog.png differ
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 0000000000..3795ac809d
Binary files /dev/null and b/plugins/entity-feedback/docs/feedback-response-table.png differ
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 0000000000..0267218243
Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-rating.png differ
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 0000000000..df38883d25
Binary files /dev/null and b/plugins/entity-feedback/docs/like-dislike-table.png differ
diff --git a/plugins/entity-feedback/docs/starred-rating-table.png b/plugins/entity-feedback/docs/starred-rating-table.png
new file mode 100644
index 0000000000..e678c5b68a
Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating-table.png differ
diff --git a/plugins/entity-feedback/docs/starred-rating.png b/plugins/entity-feedback/docs/starred-rating.png
new file mode 100644
index 0000000000..6198f7ae66
Binary files /dev/null and b/plugins/entity-feedback/docs/starred-rating.png differ
diff --git a/plugins/entity-feedback/package.json b/plugins/entity-feedback/package.json
new file mode 100644
index 0000000000..f8560a9237
--- /dev/null
+++ b/plugins/entity-feedback/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@backstage/plugin-entity-feedback",
+ "version": "0.0.0",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "publishConfig": {
+ "access": "public",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts"
+ },
+ "backstage": {
+ "role": "frontend-plugin"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/backstage/backstage",
+ "directory": "plugins/entity-feedback"
+ },
+ "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/catalog-model": "workspace:^",
+ "@backstage/core-components": "workspace:^",
+ "@backstage/core-plugin-api": "workspace:^",
+ "@backstage/errors": "workspace:^",
+ "@backstage/plugin-catalog-react": "workspace:^",
+ "@backstage/plugin-entity-feedback-common": "workspace:^",
+ "@backstage/theme": "workspace:^",
+ "@material-ui/core": "^4.9.13",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.57",
+ "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"
+ },
+ "devDependencies": {
+ "@backstage/cli": "workspace:^",
+ "@backstage/core-app-api": "workspace:^",
+ "@backstage/dev-utils": "workspace:^",
+ "@backstage/test-utils": "workspace:^",
+ "@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"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/entity-feedback/src/api/EntityFeedbackApi.ts b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts
new file mode 100644
index 0000000000..d2f1a46f55
--- /dev/null
+++ b/plugins/entity-feedback/src/api/EntityFeedbackApi.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { createApiRef } from '@backstage/core-plugin-api';
+import {
+ EntityRatingsData,
+ FeedbackResponse,
+ Rating,
+} from '@backstage/plugin-entity-feedback-common';
+
+/**
+ * @public
+ */
+export const entityFeedbackApiRef = createApiRef({
+ 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..34856473c8
--- /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,
+ FeedbackResponse,
+ Rating,
+} 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 (
+
+ );
+};
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..ba954c7747
--- /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 { 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';
+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/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 dae5e16374..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,
@@ -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({
@@ -351,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 NotFoundError(message);
+ }
+ }
+}
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..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);
@@ -518,6 +549,30 @@ describe('publish:github', () => {
});
});
+ it('should provide an adequate failure message when adding access', async () => {
+ mockOctokit.rest.users.getByUsername.mockResolvedValue({
+ data: { type: 'User' },
+ });
+
+ 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 () => {
mockOctokit.rest.users.getByUsername.mockResolvedValue({
data: { type: 'User' },
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') && (
+
+ )}
>
);
};
diff --git a/yarn.lock b/yarn.lock
index 345c28a8b5..ee3c6f94c4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5844,6 +5844,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"
@@ -11244,7 +11310,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:
@@ -13628,7 +13694,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:
@@ -22216,6 +22282,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:^"
@@ -22318,6 +22385,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:^"
@@ -38040,9 +38108,9 @@ __metadata:
linkType: hard
"web-streams-polyfill@npm:^3.2.0":
- version: 3.2.0
- resolution: "web-streams-polyfill@npm:3.2.0"
- checksum: e23ad0649392fa0159dbfc6bb27474c308c3f332d9078cfef3c06c154165bef18732c5814126147c6c712f604216ddc950c171c854e3821f020e0d2d721a5958
+ version: 3.2.1
+ resolution: "web-streams-polyfill@npm:3.2.1"
+ checksum: b119c78574b6d65935e35098c2afdcd752b84268e18746606af149e3c424e15621b6f1ff0b42b2676dc012fc4f0d313f964b41a4b5031e525faa03997457da02
languageName: node
linkType: hard