diff --git a/.changeset/clear-pigs-share.md b/.changeset/clear-pigs-share.md new file mode 100644 index 0000000000..a8241760c8 --- /dev/null +++ b/.changeset/clear-pigs-share.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-backend-module-github': minor +--- + +**BREAKING**: Explicitly rejects branch names containing a slash character. + +The module now rejects any configuration that contains slashes in branch names. The reason for this is that the ingestion will run into downstream problems if they were let through. If you had configuration with a slash in the branch name in `filters.branch`, your application may fail to start up. + +If you are affected by this, please move over to using branches that do not have slashes in them. diff --git a/.changeset/common-parrots-wink.md b/.changeset/common-parrots-wink.md new file mode 100644 index 0000000000..7e3578771b --- /dev/null +++ b/.changeset/common-parrots-wink.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-react': patch +--- + +Fix catalog entity docs page not loading due to multiple addons data attachment in the New Frontend System. diff --git a/.changeset/crazy-hands-film.md b/.changeset/crazy-hands-film.md new file mode 100644 index 0000000000..1d5933b317 --- /dev/null +++ b/.changeset/crazy-hands-film.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Updated the path field in the `gitlab:group:ensureExists` action to support also strings with multiple segments (e.g. `group/subgroup`) diff --git a/.changeset/dry-cars-build.md b/.changeset/dry-cars-build.md new file mode 100644 index 0000000000..60fd3ece49 --- /dev/null +++ b/.changeset/dry-cars-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +Added New Frontend System filter for the Kubernetes tab to use `isKubernetesAvailable` to control its visibility diff --git a/.changeset/famous-eggs-dance.md b/.changeset/famous-eggs-dance.md new file mode 100644 index 0000000000..8293746402 --- /dev/null +++ b/.changeset/famous-eggs-dance.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +Internal refactor and fixes to the prop extraction logic for layout components. diff --git a/.changeset/funny-lizards-clean.md b/.changeset/funny-lizards-clean.md new file mode 100644 index 0000000000..7c85e28188 --- /dev/null +++ b/.changeset/funny-lizards-clean.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the messaging in the `AlertDisplay` where it was claiming that there were older messages available rather than newer. diff --git a/.changeset/huge-toys-beam.md b/.changeset/huge-toys-beam.md new file mode 100644 index 0000000000..e36cdcc7f0 --- /dev/null +++ b/.changeset/huge-toys-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Expose the `UnpackNestedValue` type as it's been removed from `react-hook-form` diff --git a/.changeset/light-cameras-fold.md b/.changeset/light-cameras-fold.md new file mode 100644 index 0000000000..8ec49c5d02 --- /dev/null +++ b/.changeset/light-cameras-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend-module-slack': minor +--- + +Added a new Slack NotificationProcessor for use with the notifications plugin diff --git a/.changeset/moody-eagles-smile.md b/.changeset/moody-eagles-smile.md new file mode 100644 index 0000000000..3fd8a748ab --- /dev/null +++ b/.changeset/moody-eagles-smile.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +adding translation for `Register an existing component` text diff --git a/.changeset/nine-falcons-repeat.md b/.changeset/nine-falcons-repeat.md new file mode 100644 index 0000000000..a67e925343 --- /dev/null +++ b/.changeset/nine-falcons-repeat.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog-backend-module-bitbucket-server': minor +'@backstage/plugin-events-backend-module-bitbucket-server': minor +--- + +Added the ability for the plugin to receive events coming from Bitbucket Server push webhooks. It then performs a delta mutation on the catalog. diff --git a/.changeset/rotten-bobcats-notice.md b/.changeset/rotten-bobcats-notice.md new file mode 100644 index 0000000000..0f6d1c74f9 --- /dev/null +++ b/.changeset/rotten-bobcats-notice.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications-backend': patch +--- + +Allow throttling notification sending not to block the system with huge amount of receiving users diff --git a/.changeset/tasty-moments-do.md b/.changeset/tasty-moments-do.md new file mode 100644 index 0000000000..716f65088e --- /dev/null +++ b/.changeset/tasty-moments-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Fix the default catalog table search behavior by using the catalog table toolbar for all table variations (including when pagination mode is none). diff --git a/docs/integrations/bitbucketServer/discovery.md b/docs/integrations/bitbucketServer/discovery.md index 9ba6c7a96d..7947913182 100644 --- a/docs/integrations/bitbucketServer/discovery.md +++ b/docs/integrations/bitbucketServer/discovery.md @@ -26,17 +26,30 @@ dependency to `@backstage/plugin-catalog-backend-module-bitbucket-server` to you yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-bitbucket-server ``` -And update your backend by adding the following line: +### Installation with New Backend System -```ts title="packages/backend/src/index.ts" -backend.add(import('@backstage/plugin-catalog-backend')); -/* highlight-add-start */ +```ts +// optional if you want HTTP endpojnts to receive external events +// backend.add(import('@backstage/plugin-events-backend')); +// optional if you want to use AWS SQS instead of HTTP endpoints to receive external events +// backend.add(import('@backstage/plugin-events-backend-module-aws-sqs')); +backend.add(import('@backstage/plugin-events-backend-module-bitbucket-server')); backend.add( import('@backstage/plugin-catalog-backend-module-bitbucket-server'), ); -/* highlight-add-end */ ``` +You need to decide how you want to receive events from external sources like + +- [via HTTP endpoint](https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md) +- [via an AWS SQS queue](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md) + +Further documentation: + +- +- +- + ## Configuration To use the entity provider, you'll need a [Bitbucket Server integration set up](locations.md). diff --git a/docs/integrations/github/discovery.md b/docs/integrations/github/discovery.md index 4da7287383..e2513cdf21 100644 --- a/docs/integrations/github/discovery.md +++ b/docs/integrations/github/discovery.md @@ -152,7 +152,7 @@ If you do so, `default` will be used as provider ID. Wildcards cannot be used if the `validateLocationsExist` option is set to `true`. - **`filters`** _(optional)_: - **`branch`** _(optional)_: - String used to filter results based on the branch name. + String used to filter results based on the branch name. Branch name cannot have any slash (`/`) characters. Defaults to the default Branch of the repository. - **`repository`** _(optional)_: Regular expression used to filter results based on the repository name. diff --git a/docs/notifications/processors.md b/docs/notifications/processors.md index abee7c97c3..ad3e427364 100644 --- a/docs/notifications/processors.md +++ b/docs/notifications/processors.md @@ -104,4 +104,69 @@ Apart from STMP, the email processor also supports the following transmissions: - sendmail - stream (only for debugging purposes) -See more information at https://github.com/backstage/backstage/blob/master/plugins/notifications-backend-module-email/README.md +See more information at + +### Slack Processor + +Slack processor is used to send notifications to users and channels in Slack. + +### Slack Configuration + +To use this you'll need to create a Slack App or use an existing one. It should have at least the following scopes: +`chat:write`, `users:read`, `im:write` (for direct message support). + +Additionally you may include scopes `chat:write.public` in order to send messages to public channels your app is not +a member of. + +These scopes are under OAuth & Permissions. You will also want to save the Bot User OAuth Token. This will be needed +in the following step to configure `app-config.yaml`. + +### Configure Backstage + +To install the Slack processor, add the `@backstage/plugin-notifications-backend-module-slack` package to your backend. + +```bash +yarn workspace backend add @backstage/plugin-notifications-backend-module-slack +``` + +Add the Slack processor to your backend: + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/plugin-notifications-backend'; +const backend = createBackend(); +// ... +backend.add(import('@backstage/plugin-notifications-backend-module-slack')); +``` + +Using the token you obtained from your Slack App, configure the Slack module in your `app-config.yaml`. + +```yaml +notifications: + processors: + slack: + - token: xoxb-XXXXXXXXX + broadcastChannels: # Optional, if you wish to support broadcast notifications. + - C12345678 +``` + +Multiple instances can be added in the `slack` array, allowing you to have multiple configurations if you need to send +messages to more than one Slack workspace. Org-Wide App installation is not currently supported. + +### Entity Requirements + +Entities must be annotated with the following annotation: + +- `slack.com/bot-notify` + +The value may be any Slack ID supported by [chat.postMessage](https://api.slack.com/methods/chat.postMessage), for example a user (U12345678), channel (C12345678), group, or direct message chat. + +It's also possible to use a user's email address or channel name, however IDs are recommended by Slack. +Private channels/chats must use an ID. + +### Observability + +The processor includes the following counter metrics if you are exporting metrics using OpenTelemetry: + +- `notifications.processors.slack.sent.count` - The number of messages sent +- `notifications.processors.slack.error.count` - The number of messages that failed to send diff --git a/microsite/data/plugins/renovate-hoster.yaml b/microsite/data/plugins/renovate-hoster.yaml new file mode 100644 index 0000000000..4cf4f65956 --- /dev/null +++ b/microsite/data/plugins/renovate-hoster.yaml @@ -0,0 +1,9 @@ +--- +title: Renovate Hoster +author: secustor +authorUrl: https://github.com/secustor +category: Quality +description: This plugin enables you to host Renovate CLI yourself inside of Backstage and extract data from it. +documentation: https://github.com/secustor/backstage-plugins/tree/main/plugins/renovate +npmPackageName: '@backstage/backstage-plugin-renovate' +addedDate: '2025-03-31' diff --git a/packages/canon/src/utils/extractProps.ts b/packages/canon/src/utils/extractProps.ts index 8631f4e228..1668117909 100644 --- a/packages/canon/src/utils/extractProps.ts +++ b/packages/canon/src/utils/extractProps.ts @@ -14,6 +14,16 @@ * limitations under the License. */ +type BasePropDef = { + type: string; + values?: readonly unknown[]; + default?: unknown; + required?: boolean; + className?: string; + responsive?: true; + customProperties?: string[]; +}; + export function extractProps( props: { className?: string; @@ -22,45 +32,53 @@ export function extractProps( as?: keyof JSX.IntrinsicElements; [key: string]: any; }, - propDefs: { [key: string]: any }, + propDefs: { [name in string]: BasePropDef }, ) { let className: string[] = (props.className || '').split(' '); let style: React.CSSProperties = { ...props.style }; - const hasProp = (key: string) => props.hasOwnProperty(key); for (const key in propDefs) { + const propDef = propDefs[key]; + // Check if the prop is present or has a default value - if (!hasProp(key) && !propDefs[key].hasOwnProperty('default')) { + if (!Object.hasOwn(props, key) && !propDef.hasOwnProperty('default')) { continue; // Skip processing if neither is present } - const value = hasProp(key) ? props[key] : propDefs[key].default; - const propDefsValues = propDefs[key].values; - const propDefsCustomProperties = propDefs[key].customProperties; - const propDefsClassName = propDefs[key].className; - const isResponsive = propDefs[key].responsive; + const value = Object.hasOwn(props, key) + ? (props[key] as unknown) + : propDefs[key].default; + const propDefsValues = propDef.values; + const propDefsCustomProperties = propDef.customProperties; + const propDefsClassName = propDef.className; + const isResponsive = propDef.responsive; - const handleValue = (val: string, prefix: string = '') => { + const handleValue = (val: unknown, prefix: string = '') => { // Skip adding class name if the key is "as" if (key === 'as') return; - if (propDefsValues.includes(val)) { + if (propDefsValues?.includes(val)) { className.push(`${prefix}${propDefsClassName}-${val}`); } else { - const customPropertyKey = - isResponsive && prefix - ? `${propDefsCustomProperties}-${prefix.slice(0, -1)}` - : propDefsCustomProperties; - (style as any)[customPropertyKey] = val; + if (propDefsCustomProperties) { + for (const customProperty of propDefsCustomProperties) { + const customPropertyKey = + isResponsive && prefix + ? `${customProperty}-${prefix.slice(0, -1)}` + : customProperty; + style[customPropertyKey as keyof typeof style] = val as any; + } + } className.push(`${prefix}${propDefsClassName}`); } }; - if (isResponsive && typeof value === 'object') { + if (isResponsive && typeof value === 'object' && value !== null) { + const breakpointValues = value as { [key: string]: unknown }; // Handle responsive object values - for (const breakpoint in value) { + for (const breakpoint in breakpointValues) { const prefix = breakpoint === 'initial' ? '' : `${breakpoint}:`; - handleValue(value[breakpoint], prefix); + handleValue(breakpointValues[breakpoint], prefix); } } else { handleValue(value); diff --git a/packages/core-components/report-alpha.api.md b/packages/core-components/report-alpha.api.md index 158f9fe14a..73b4194266 100644 --- a/packages/core-components/report-alpha.api.md +++ b/packages/core-components/report-alpha.api.md @@ -55,8 +55,8 @@ export const coreComponentsTranslationRef: TranslationRef< readonly 'oauthRequestDialog.rejectAll': 'Reject All'; readonly 'supportButton.title': 'Support'; readonly 'supportButton.close': 'Close'; - readonly 'alertDisplay.message_one': '({{ count }} older message)'; - readonly 'alertDisplay.message_other': '({{ count }} older messages)'; + readonly 'alertDisplay.message_one': '({{ count }} newer message)'; + readonly 'alertDisplay.message_other': '({{ count }} newer messages)'; readonly 'autoLogout.stillTherePrompt.title': 'Logging out due to inactivity'; readonly 'autoLogout.stillTherePrompt.buttonText': "Yes! Don't log me out"; readonly 'proxiedSignInPage.title': 'You do not appear to be signed in. Please try reloading the browser page.'; diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx index b75f773f90..f2f904a3e0 100644 --- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx +++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx @@ -92,7 +92,7 @@ describe('', () => { , ); - expect(screen.getByText('(2 older messages)')).toBeInTheDocument(); + expect(screen.getByText('(2 newer messages)')).toBeInTheDocument(); }); }); @@ -189,19 +189,19 @@ describe('', () => { jest.advanceTimersByTime(1000); }); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(1000); }); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(2 older messages)')).toBeInTheDocument(); + expect(queryByText('(2 newer messages)')).toBeInTheDocument(); // Validate removing messages act(() => { jest.advanceTimersByTime(5000); }); expect(queryByText('transient message two')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(5000); @@ -231,12 +231,12 @@ describe('', () => { // 1s in, message 1 still shown, message 2 added in background act(() => jest.advanceTimersByTime(1000)); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); // 2s in, message 2 now shown, message 3 added act(() => jest.advanceTimersByTime(1000)); expect(queryByText('transient message two')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); // 3.5s in, message 3 now shown act(() => jest.advanceTimersByTime(1500)); @@ -264,7 +264,7 @@ describe('', () => { // 1s in, message 1 still shown, message 2 added in background act(() => jest.advanceTimersByTime(1000)); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); // manually remove message 1 fireEvent.click(screen.getByTestId('error-button-close')); @@ -273,7 +273,7 @@ describe('', () => { // 2s in, message 2 now shown, message 3 added act(() => jest.advanceTimersByTime(1000)); expect(queryByText('transient message two')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); // 3s in, message 3 now shown act(() => jest.advanceTimersByTime(1500)); @@ -330,19 +330,19 @@ describe('', () => { jest.advanceTimersByTime(1000); }); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(1000); }); expect(queryByText('transient message one')).toBeInTheDocument(); - expect(queryByText('(2 older messages)')).toBeInTheDocument(); + expect(queryByText('(2 newer messages)')).toBeInTheDocument(); // Validate removing messages act(() => { jest.advanceTimersByTime(5000); }); expect(queryByText('permanent message')).toBeInTheDocument(); - expect(queryByText('(1 older message)')).toBeInTheDocument(); + expect(queryByText('(1 newer message)')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('error-button-close')); expect(queryByText('transient message three')).toBeInTheDocument(); diff --git a/packages/core-components/src/translation.ts b/packages/core-components/src/translation.ts index 13bd62bab8..40b6e94798 100644 --- a/packages/core-components/src/translation.ts +++ b/packages/core-components/src/translation.ts @@ -106,8 +106,8 @@ export const coreComponentsTranslationRef = createTranslationRef({ }, }, alertDisplay: { - message_one: '({{ count }} older message)', - message_other: '({{ count }} older messages)', + message_one: '({{ count }} newer message)', + message_other: '({{ count }} newer messages)', }, autoLogout: { stillTherePrompt: { diff --git a/plugins/catalog-backend-module-bitbucket-server/package.json b/plugins/catalog-backend-module-bitbucket-server/package.json index 0198a5ae66..7cb2d6a5da 100644 --- a/plugins/catalog-backend-module-bitbucket-server/package.json +++ b/plugins/catalog-backend-module-bitbucket-server/package.json @@ -21,16 +21,12 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", - "./alpha": "./src/alpha.ts", "./package.json": "./package.json" }, "main": "src/index.ts", "types": "src/index.ts", "typesVersions": { "*": { - "alpha": [ - "src/alpha.ts" - ], "package.json": [ "package.json" ] @@ -51,17 +47,21 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", "p-throttle": "^4.1.1", "uuid": "^11.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/plugin-events-backend-test-utils": "workspace:^", "luxon": "^3.0.0", "msw": "^1.0.0" }, diff --git a/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md b/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md deleted file mode 100644 index 06212c3c60..0000000000 --- a/plugins/catalog-backend-module-bitbucket-server/report-alpha.api.md +++ /dev/null @@ -1,13 +0,0 @@ -## API Report File for "@backstage/plugin-catalog-backend-module-bitbucket-server" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts -import { BackendFeature } from '@backstage/backend-plugin-api'; - -// @alpha (undocumented) -const _feature: BackendFeature; -export default _feature; - -// (No @packageDocumentation comment for this package) -``` diff --git a/plugins/catalog-backend-module-bitbucket-server/report.api.md b/plugins/catalog-backend-module-bitbucket-server/report.api.md index 0da9b78139..7cb13e651f 100644 --- a/plugins/catalog-backend-module-bitbucket-server/report.api.md +++ b/plugins/catalog-backend-module-bitbucket-server/report.api.md @@ -3,13 +3,16 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AuthService } from '@backstage/backend-plugin-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; import { BitbucketServerIntegrationConfig } from '@backstage/integration'; +import { CatalogService } from '@backstage/plugin-catalog-node'; import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; -import { LocationSpec } from '@backstage/plugin-catalog-node'; +import { EventsService } from '@backstage/plugin-events-node'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; import { LoggerService } from '@backstage/backend-plugin-api'; import { SchedulerService } from '@backstage/backend-plugin-api'; import { SchedulerServiceTaskRunner } from '@backstage/backend-plugin-api'; @@ -22,6 +25,11 @@ export class BitbucketServerClient { config: BitbucketServerIntegrationConfig; }): BitbucketServerClient; // (undocumented) + getDefaultBranch(options: { + projectKey: string; + repo: string; + }): Promise; + // (undocumented) getFile(options: { projectKey: string; repo: string; @@ -47,6 +55,16 @@ export class BitbucketServerClient { }; } +// @public (undocumented) +export type BitbucketServerDefaultBranch = { + id: string; + displayId: string; + type: string; + latestCommit: string; + latestChangeset: string; + isDefault: boolean; +}; + // @public export class BitbucketServerEntityProvider implements EntityProvider { connect(connection: EntityProviderConnection): Promise; @@ -55,9 +73,12 @@ export class BitbucketServerEntityProvider implements EntityProvider { config: Config, options: { logger: LoggerService; + events?: EventsService; parser?: BitbucketServerLocationParser; schedule?: SchedulerServiceTaskRunner; scheduler?: SchedulerService; + catalogApi?: CatalogService; + auth?: AuthService; }, ): BitbucketServerEntityProvider[]; getProviderName(): string; @@ -108,6 +129,7 @@ export type BitbucketServerRepository = { }[] >; archived: boolean; + defaultBranch: string; }; // @public (undocumented) diff --git a/plugins/catalog-backend-module-bitbucket-server/src/index.ts b/plugins/catalog-backend-module-bitbucket-server/src/index.ts index f05c9ca0e9..722be03d7a 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/index.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/index.ts @@ -21,12 +21,16 @@ */ export { default } from './module'; + export { BitbucketServerClient } from './lib'; + export type { BitbucketServerProject, BitbucketServerRepository, BitbucketServerPagedResponse, BitbucketServerListOptions, + BitbucketServerDefaultBranch, } from './lib'; + export { BitbucketServerEntityProvider } from './providers'; export type { BitbucketServerLocationParser } from './providers'; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts index 6abcede9de..fbb4054390 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.test.ts @@ -23,7 +23,12 @@ import { BitbucketServerPagedResponse, paginated, } from './BitbucketServerClient'; -import { BitbucketServerProject, BitbucketServerRepository } from './types'; +import { + BitbucketServerDefaultBranch, + BitbucketServerProject, + BitbucketServerRepository, +} from './types'; +import { NotFoundError } from '@backstage/errors'; const server = setupServer(); @@ -122,6 +127,7 @@ describe('BitbucketServerClient', () => { ], }, archived: false, + defaultBranch: 'master', }, ], }; @@ -200,6 +206,7 @@ describe('BitbucketServerClient', () => { ], }, archived: false, + defaultBranch: 'master', }; return res(ctx.json(response)); @@ -217,4 +224,82 @@ describe('BitbucketServerClient', () => { 'https://bitbucket.mycompany.com/projects/test-project', ); }); + + it('getRepository no repo', async () => { + server.use( + rest.get( + `${config.apiBaseUrl}/projects/test-project/repos/wrong-repo`, + (_, res, ctx) => { + return res(ctx.status(404)); + }, + ), + ); + + const error = async () => { + await client.getRepository({ + projectKey: 'test-project', + repo: 'wrong-repo', + }); + }; + + await expect(error).rejects.toThrow( + "Repository 'wrong-repo' in project 'test-project' does not exist.", + ); + await expect(error).rejects.toThrow(NotFoundError); + }); + + it('getDefaultBranch success', async () => { + server.use( + rest.get( + `${config.apiBaseUrl}/projects/test-project/repos/test-repo/default-branch`, + (req, res, ctx) => { + if ( + req.headers.get('authorization') !== + 'Basic dGVzdC11c2VyOnRlc3QtcHc=' + ) { + return res(ctx.status(400)); + } + const response: BitbucketServerDefaultBranch = { + id: 'refs/heads/master', + displayId: 'master', + type: 'BRANCH', + latestCommit: '0f2f3ae484054696568bf4560ba4da280f7df82a', + latestChangeset: '0f2f3ae484054696568bf4560ba4da280f7df82a', + isDefault: true, + }; + + return res(ctx.json(response)); + }, + ), + ); + + const repo = await client.getDefaultBranch({ + projectKey: 'test-project', + repo: 'test-repo', + }); + expect(repo.displayId).toEqual('master'); + }); + + it('getDefaultBranch endpoint', async () => { + server.use( + rest.get( + `${config.apiBaseUrl}/projects/test-project/repos/wrong-repo/default-branch`, + (_, res, ctx) => { + return res(ctx.status(404)); + }, + ), + ); + + const error = async () => { + await client.getDefaultBranch({ + projectKey: 'test-project', + repo: 'wrong-repo', + }); + }; + + await expect(error).rejects.toThrow( + "Your Bitbucket Server version no longer supports the default branch endpoint or 'wrong-repo' in 'test-project' does not exist.", + ); + await expect(error).rejects.toThrow(NotFoundError); + }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts index 3dd61d64e7..70e791c992 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/BitbucketServerClient.ts @@ -18,8 +18,14 @@ import { BitbucketServerIntegrationConfig, getBitbucketServerRequestOptions, } from '@backstage/integration'; -import { BitbucketServerProject, BitbucketServerRepository } from './types'; import pThrottle from 'p-throttle'; +import { + BitbucketServerDefaultBranch, + BitbucketServerRepository, +} from './index'; +import { BitbucketServerProject } from './types'; +import { NotFoundError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; // 1 per second const throttle = pThrottle({ @@ -92,7 +98,35 @@ export class BitbucketServerClient { request, getBitbucketServerRequestOptions(this.config), ); - return response.json(); + if (response.ok) { + return response.json(); + } + if (response.status === 404) { + throw new NotFoundError( + `Repository '${options.repo}' in project '${options.projectKey}' does not exist.`, + ); + } + throw await ResponseError.fromResponse(response); + } + + async getDefaultBranch(options: { + projectKey: string; + repo: string; + }): Promise { + const request = `${this.config.apiBaseUrl}/projects/${options.projectKey}/repos/${options.repo}/default-branch`; + const response = await fetch( + request, + getBitbucketServerRequestOptions(this.config), + ); + if (response.ok) { + return response.json(); + } + if (response.status === 404) { + throw new NotFoundError( + `Your Bitbucket Server version no longer supports the default branch endpoint or '${options.repo}' in '${options.projectKey}' does not exist.`, + ); + } + throw await ResponseError.fromResponse(response); } resolvePath(options: { projectKey: string; repo: string; path: string }): { diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts index 8b72c9335b..4533cb1305 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/index.ts @@ -20,6 +20,9 @@ export type { BitbucketServerListOptions, } from './BitbucketServerClient'; export type { + BitbucketServerDefaultBranch, BitbucketServerRepository, + BitbucketServerEvents, BitbucketServerProject, } from './types'; +// export * from './types'; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts b/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts index 381eecb493..efdf3d7cf8 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/lib/types.ts @@ -15,7 +15,7 @@ */ /** @public */ -export type BitbucketServerRepository = { +type BitbucketServerRepository = { project: { key: string; }; @@ -28,9 +28,55 @@ export type BitbucketServerRepository = { }[] >; archived: boolean; + defaultBranch: string; }; /** @public */ -export type BitbucketServerProject = { +type BitbucketServerProject = { key: string; }; + +/** @public */ +type BitbucketServerDefaultBranch = { + id: string; + displayId: string; + type: string; + latestCommit: string; + latestChangeset: string; + isDefault: boolean; +}; + +namespace BitbucketServerEvents { + interface Event { + eventKey: string; + } + + export interface RefsChangedEvent extends Event { + date: string; + actor: Actor; + repository: Repository; + changes: Change[]; + commits: undefined; + ToCommit: undefined; + } + type Actor = { + name?: string; + id: number; + }; + type Change = { + ref: { id: string; displayId: string; type: string }; + }; + type Repository = { + slug: string; + id: number; + name: string; + project: BitbucketServerProject; + }; +} + +export type { + BitbucketServerDefaultBranch, + BitbucketServerProject, + BitbucketServerEvents, + BitbucketServerRepository, +}; diff --git a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts index fbd4fa7a36..f9345170ed 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.test.ts @@ -16,20 +16,33 @@ import { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api'; import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; import { catalogModuleBitbucketServerEntityProvider } from './catalogModuleBitbucketServerEntityProvider'; -import { BitbucketServerEntityProvider } from '../providers'; +import { BitbucketServerEntityProvider } from '../providers/BitbucketServerEntityProvider'; describe('catalogModuleBitbucketServerEntityProvider', () => { it('should register provider at the catalog extension point', async () => { + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; + }, + }); let addedProviders: Array | undefined; let usedSchedule: SchedulerServiceTaskScheduleDefinition | undefined; - const extensionPoint = { + const catalogExtensionPointImpl = { addEntityProvider: (providers: any) => { addedProviders = providers; }, }; + const connection = jest.fn() as unknown as EntityProviderConnection; const runner = jest.fn(); const scheduler = mockServices.scheduler.mock({ createScheduledTaskRunner(schedule) { @@ -38,33 +51,35 @@ describe('catalogModuleBitbucketServerEntityProvider', () => { }, }); - const config = { - catalog: { - providers: { - bitbucketServer: { - host: 'bitbucket.mycompany.com', - schedule: { - frequency: 'P1M', - timeout: 'PT3M', + await startTestBackend({ + extensionPoints: [ + [catalogProcessingExtensionPoint, catalogExtensionPointImpl], + ], + features: [ + eventsServiceFactory, + catalogModuleBitbucketServerEntityProvider, + mockServices.rootConfig.factory({ + data: { + catalog: { + providers: { + bitbucketServer: { + host: 'bitbucket.mycompany.com', + schedule: { + frequency: 'P1M', + timeout: 'PT3M', + }, + }, + }, + }, + integrations: { + bitbucketServer: [ + { + host: 'bitbucket.mycompany.com', + }, + ], }, }, - }, - }, - integrations: { - bitbucketServer: [ - { - host: 'bitbucket.mycompany.com', - }, - ], - }, - }; - - await startTestBackend({ - extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], - features: [ - catalogModuleBitbucketServerEntityProvider, - mockServices.rootConfig.factory({ data: config }), - mockServices.logger.factory(), + }), scheduler.factory, ], }); @@ -72,9 +87,14 @@ describe('catalogModuleBitbucketServerEntityProvider', () => { expect(usedSchedule?.frequency).toEqual({ months: 1 }); expect(usedSchedule?.timeout).toEqual({ minutes: 3 }); expect(addedProviders?.length).toEqual(1); - expect(addedProviders?.pop()?.getProviderName()).toEqual( + expect(runner).not.toHaveBeenCalled(); + const provider = addedProviders!.pop()!; + expect(provider.getProviderName()).toEqual( 'bitbucketServer-provider:default', ); - expect(runner).not.toHaveBeenCalled(); + await provider.connect(connection); + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('bitbucketServer-provider:default'); + expect(runner).toHaveBeenCalledTimes(1); }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts index 2c78d219d9..d66ef4e2a2 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/module/catalogModuleBitbucketServerEntityProvider.ts @@ -18,8 +18,10 @@ import { coreServices, createBackendModule, } from '@backstage/backend-plugin-api'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; -import { BitbucketServerEntityProvider } from '../providers'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { BitbucketServerEntityProvider } from '../providers/BitbucketServerEntityProvider'; /** * @public @@ -31,14 +33,28 @@ export const catalogModuleBitbucketServerEntityProvider = createBackendModule({ env.registerInit({ deps: { catalog: catalogProcessingExtensionPoint, + catalogApi: catalogServiceRef, config: coreServices.rootConfig, + events: eventsServiceRef, logger: coreServices.logger, scheduler: coreServices.scheduler, + auth: coreServices.auth, }, - async init({ catalog, config, logger, scheduler }) { + async init({ + catalog, + catalogApi, + config, + events, + logger, + scheduler, + auth, + }) { const providers = BitbucketServerEntityProvider.fromConfig(config, { + catalogApi, + events, logger, scheduler, + auth, }); catalog.addEntityProvider(providers); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts index 05e0ea6a01..ba4e3e0411 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.test.ts @@ -18,17 +18,30 @@ import { SchedulerService, SchedulerServiceTaskRunner, SchedulerServiceTaskInvocationDefinition, + BackstageCredentials, } from '@backstage/backend-plugin-api'; import { mockServices, registerMswTestHooks, } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; -import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; +import { + CatalogService, + DeferredEntity, + EntityProviderConnection, + locationSpecToLocationEntity, +} from '@backstage/plugin-catalog-node'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { BitbucketServerEntityProvider } from './BitbucketServerEntityProvider'; +import { + BitbucketServerEntityProvider, + toDeferredEntities, +} from './BitbucketServerEntityProvider'; import { BitbucketServerPagedResponse } from '../lib'; +import { Entity, LocationEntity } from '@backstage/catalog-model'; +import { BitbucketServerEvents } from '../lib/index'; +import { DefaultEventsService } from '@backstage/plugin-events-node'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; class PersistingTaskRunner implements SchedulerServiceTaskRunner { private tasks: SchedulerServiceTaskInvocationDefinition[] = []; @@ -57,9 +70,17 @@ function pagedResponse(values: any): BitbucketServerPagedResponse { const logger = mockServices.logger.mock(); +const authService = mockServices.auth.mock(); + +const events = DefaultEventsService.create({ logger }); + const server = setupServer(); -function setupStubs(projects: Project[], baseUrl: string) { +function setupStubs( + projects: Project[], + baseUrl: string, + defaultBranch: string, +) { // Stub projects server.use( rest.get(`${baseUrl}/rest/api/1.0/projects`, (_, res, ctx) => { @@ -93,6 +114,7 @@ function setupStubs(projects: Project[], baseUrl: string) { ], }, archived: repo.archived ?? false, + defaultBranch: defaultBranch, }); } return res(ctx.json(pagedResponse(response))); @@ -102,6 +124,102 @@ function setupStubs(projects: Project[], baseUrl: string) { } } +const host = 'bitbucket.mycompany.com'; +const targetPath = `/catalog-info.yaml`; +const test1RepoUrl = `https://${host}/projects/TEST/repos/test1/browse`; + +function setupRepositoryReqHandler(defaultBranch: string) { + server.use( + rest.get( + `https://${host}/rest/api/1.0/projects/TEST/repos/test1`, + (_, res, ctx) => { + const response = { + slug: 'test1', + id: 1, + name: 'test1', + project: { + key: 'TEST', + id: 1, + name: 'TEST', + links: { + self: [ + { + href: `https://${host}/projects/TEST`, + }, + ], + }, + }, + links: { + self: [ + { + href: `${test1RepoUrl}`, + }, + ], + }, + defaultBranch: defaultBranch, + }; + return res(ctx.json(response)); + }, + ), + ); +} + +const repoPushEvent: BitbucketServerEvents.RefsChangedEvent = { + eventKey: 'repo:refs_changed', + date: '2017-09-19T09:45:32+1000', + actor: { + name: 'admin', + id: 1, + }, + repository: { + slug: 'test1', + id: 84, + name: 'test1', + project: { + key: 'TEST', + }, + }, + changes: [ + { + ref: { + id: 'refs/heads/master', + displayId: 'master', + type: 'BRANCH', + }, + }, + ], + commits: undefined, + ToCommit: undefined, +}; +const repoPushEventParams = { + topic: 'bitbucketServer.repo:refs_changed', + eventPayload: repoPushEvent, + metadata: { 'x-event-key': 'repo:refs_changed' }, +}; + +const createLocationEntity = ( + repoUrl: string, + path: string, + defaultBranch: string, +): LocationEntity => { + const target = `${repoUrl}${path}`; + + const entity = locationSpecToLocationEntity({ + location: { + type: 'url', + target: target, + presence: 'optional', + }, + }); + entity.metadata.annotations = { + ...entity.metadata.annotations, + [`${host}/repo-url`]: target, + ['bitbucket.org/default-branch']: defaultBranch, + }; + + return entity; +}; + describe('BitbucketServerEntityProvider', () => { registerMswTestHooks(server); afterEach(() => { @@ -157,6 +275,7 @@ describe('BitbucketServerEntityProvider', () => { }); const providers = BitbucketServerEntityProvider.fromConfig(config, { logger, + events, schedule, }); @@ -191,6 +310,7 @@ describe('BitbucketServerEntityProvider', () => { }); const providers = BitbucketServerEntityProvider.fromConfig(config, { logger, + events, schedule, }); @@ -204,7 +324,6 @@ describe('BitbucketServerEntityProvider', () => { }); it('apply full update on scheduled execution with filters', async () => { - const host = 'bitbucket.mycompany.com'; const config = new ConfigReader({ integrations: { bitbucketServer: [ @@ -253,6 +372,7 @@ describe('BitbucketServerEntityProvider', () => { { key: 'other-project', repos: [{ name: 'other-repo' }] }, ], `https://${host}`, + 'master', ); await provider.connect(entityProviderConnection); @@ -270,6 +390,7 @@ describe('BitbucketServerEntityProvider', () => { annotations: { 'backstage.io/managed-by-location': `url:${url}`, 'backstage.io/managed-by-origin-location': `url:${url}`, + 'bitbucket.org/default-branch': 'master', }, name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6', }, @@ -291,7 +412,6 @@ describe('BitbucketServerEntityProvider', () => { }); it('apply full update on scheduled execution without filters', async () => { - const host = 'bitbucket.mycompany.com'; const config = new ConfigReader({ integrations: { bitbucketServer: [ @@ -329,6 +449,7 @@ describe('BitbucketServerEntityProvider', () => { { key: 'other-project', repos: [{ name: 'other-repo' }] }, ], `https://${host}`, + 'master', ); await provider.connect(entityProviderConnection); @@ -345,6 +466,7 @@ describe('BitbucketServerEntityProvider', () => { annotations: { 'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', }, name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6', }, @@ -364,6 +486,7 @@ describe('BitbucketServerEntityProvider', () => { annotations: { 'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', }, name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f', }, @@ -441,7 +564,6 @@ describe('BitbucketServerEntityProvider', () => { }); it('apply full update with schedule in config', async () => { - const host = 'bitbucket.mycompany.com'; const config = new ConfigReader({ integrations: { bitbucketServer: [ @@ -488,6 +610,7 @@ describe('BitbucketServerEntityProvider', () => { { key: 'other-project', repos: [{ name: 'other-repo' }] }, ], `https://${host}`, + 'master', ); await provider.connect(entityProviderConnection); @@ -504,6 +627,7 @@ describe('BitbucketServerEntityProvider', () => { annotations: { 'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', }, name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6', }, @@ -523,6 +647,7 @@ describe('BitbucketServerEntityProvider', () => { annotations: { 'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', }, name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f', }, @@ -542,4 +667,351 @@ describe('BitbucketServerEntityProvider', () => { entities: expectedEntities, }); }); + + it('Multiple location entities to deferred entities', async () => { + const schedule = new PersistingTaskRunner(); + const config = new ConfigReader({ + catalog: { + providers: { + bitbucketServer: { + host: host, + }, + }, + }, + integrations: { + bitbucketServer: [ + { + host: host, + }, + ], + }, + }); + const providers = BitbucketServerEntityProvider.fromConfig(config, { + logger, + events, + schedule, + }); + + expect(providers).toHaveLength(1); + expect(providers[0].getProviderName()).toEqual( + 'bitbucketServer-provider:default', + ); + + const locationEntities = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + [`${host}/repo-url`]: `https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', + }, + name: 'generated-77f4323822420990f8c3e3c981d38c2dec4ae3a6', + }, + spec: { + presence: 'optional', + target: `https://${host}/projects/project-test/repos/repo-test/browse/catalog-info.yaml`, + type: 'url', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Location', + metadata: { + annotations: { + 'backstage.io/managed-by-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + 'backstage.io/managed-by-origin-location': `url:https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + [`${host}/repo-url`]: `https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + 'bitbucket.org/default-branch': 'master', + }, + name: 'generated-d8d4944c30c2906dfee172ddda9537f9893b2c0f', + }, + spec: { + presence: 'optional', + target: `https://${host}/projects/other-project/repos/other-repo/browse/catalog-info.yaml`, + type: 'url', + }, + }, + ]; + + const deferredEntities = toDeferredEntities( + locationEntities, + providers[0].getProviderName(), + ); + + expect(deferredEntities).toEqual([ + { + locationKey: providers[0].getProviderName(), + entity: locationEntities[0], + }, + { + locationKey: providers[0].getProviderName(), + entity: locationEntities[1], + }, + ]); + }); + + it('refresh onRepoPush', async () => { + const schedule = new PersistingTaskRunner(); + const keptModule = createLocationEntity( + test1RepoUrl, + `/kept-module:${targetPath}`, + 'master', + ); + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + setupRepositoryReqHandler('master'); + + // authService.getOwnServiceCredentials(); + + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: host, + }, + ], + }, + catalog: { + providers: { + bitbucketServer: { + mainProvider: { + host: host, + apiBaseUrl: `https://${host}/rest/api/1.0`, + catalogPath: `/kept-module:/catalog-info.yaml`, + defaultBranch: 'master', + }, + }, + }, + }, + }); + + const catalogApi = catalogServiceMock.mock({ + getEntities: async ( + request: { filter: Record }, + credentials: { credentials: BackstageCredentials }, + ): Promise<{ items: Entity[] }> => { + if ( + credentials.credentials !== + (await authService.getOwnServiceCredentials()) || + request.filter.kind !== 'Location' || + request.filter[`metadata.annotations.${host}/repo-url`] !== + `${test1RepoUrl}/kept-module:${targetPath}` + ) { + return { items: [] }; + } + return { + items: [keptModule], + }; + }, + }); + + const provider = BitbucketServerEntityProvider.fromConfig(config, { + catalogApi: catalogApi, + logger, + schedule, + events, + auth: authService, + })[0]; + + await provider.connect(entityProviderConnection); + await events.publish(repoPushEventParams); + + expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.refresh).toHaveBeenCalledWith({ + keys: [`url:${test1RepoUrl}/kept-module:${targetPath}`], + }); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0); + }); + + it('no refresh onRepoPush due to different default branch', async () => { + const schedule = new PersistingTaskRunner(); + const keptModule = createLocationEntity( + test1RepoUrl, + `/kept-module:${targetPath}`, + 'main', + ); + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + setupRepositoryReqHandler('main'); + + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: host, + }, + ], + }, + catalog: { + providers: { + bitbucketServer: { + mainProvider: { + host: host, + apiBaseUrl: `https://${host}/rest/api/1.0`, + catalogPath: `/kept-module:/catalog-info.yaml`, + }, + }, + }, + }, + }); + + const catalogApi = catalogServiceMock.mock({ + getEntities: async ( + request: { filter: Record }, + credentials: { credentials: BackstageCredentials }, + ): Promise<{ items: Entity[] }> => { + if ( + credentials.credentials !== + (await authService.getOwnServiceCredentials()) || + request.filter.kind !== 'Location' || + request.filter[`metadata.annotations.${host}/repo-url`] !== + `${test1RepoUrl}/kept-module:${targetPath}` + ) { + return { items: [] }; + } + return { + items: [keptModule], + }; + }, + }); + + const provider = BitbucketServerEntityProvider.fromConfig(config, { + catalogApi: catalogApi, + logger, + schedule, + events, + auth: authService, + })[0]; + + await provider.connect(entityProviderConnection); + await events.publish(repoPushEventParams); + + expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(0); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0); + }); + + it('add onRepoPush', async () => { + const schedule = new PersistingTaskRunner(); + setupRepositoryReqHandler('master'); + authService.getPluginRequestToken.mockResolvedValue({ + token: 'fake-token', + }); + const addedModule = createLocationEntity( + test1RepoUrl, + `/added-module:${targetPath}`, + 'master', + ); + + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: host, + }, + ], + }, + catalog: { + providers: { + bitbucketServer: { + mainProvider: { + host: host, + apiBaseUrl: `https://${host}/rest/api/1.0`, + catalogPath: `/added-module:/catalog-info.yaml`, + }, + }, + }, + }, + }); + + const catalogApi = catalogServiceMock({ entities: [] }); + const provider = BitbucketServerEntityProvider.fromConfig(config, { + catalogApi: catalogApi, + logger, + schedule, + events, + auth: authService, + })[0]; + + await provider.connect(entityProviderConnection); + await events.publish(repoPushEventParams); + const addedEntities = [ + { + entity: addedModule, + locationKey: 'bitbucketServer-provider:mainProvider', + }, + ]; + const removedEntities: DeferredEntity[] = []; + + expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.refresh).toHaveBeenCalledWith({ + keys: [], + }); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(1); + expect(entityProviderConnection.applyMutation).toHaveBeenCalledWith({ + type: 'delta', + added: addedEntities, + removed: removedEntities, + }); + }); + + it('fail add onRepoPush from wrong default branch', async () => { + const schedule = new PersistingTaskRunner(); + setupRepositoryReqHandler('main'); + + const entityProviderConnection: EntityProviderConnection = { + applyMutation: jest.fn(), + refresh: jest.fn(), + }; + + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: host, + }, + ], + }, + catalog: { + providers: { + bitbucketServer: { + mainProvider: { + host: host, + apiBaseUrl: `https://${host}/rest/api/1.0`, + catalogPath: `/added-module:/catalog-info.yaml`, + }, + }, + }, + }, + }); + + const catalogApi = catalogServiceMock({ entities: [] }); + const provider = BitbucketServerEntityProvider.fromConfig(config, { + catalogApi: catalogApi as any as CatalogService, + logger, + schedule, + events, + auth: authService, + })[0]; + + await provider.connect(entityProviderConnection); + await events.publish(repoPushEventParams); + + expect(entityProviderConnection.refresh).toHaveBeenCalledTimes(0); + + expect(entityProviderConnection.applyMutation).toHaveBeenCalledTimes(0); + }); }); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts index 46f492d64c..b462f8756a 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProvider.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { Entity, LocationEntity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { @@ -24,6 +24,8 @@ import { import { EntityProvider, EntityProviderConnection, + DeferredEntity, + CatalogService, } from '@backstage/plugin-catalog-node'; import * as uuid from 'uuid'; import { BitbucketServerClient, paginated } from '../lib'; @@ -36,10 +38,16 @@ import { defaultBitbucketServerLocationParser, } from './BitbucketServerLocationParser'; import { + AuthService, + BackstageCredentials, LoggerService, SchedulerService, SchedulerServiceTaskRunner, } from '@backstage/backend-plugin-api'; +import { BitbucketServerEvents } from '../lib'; +import { EventsService } from '@backstage/plugin-events-node'; + +const TOPIC_REPO_REFS_CHANGED = 'bitbucketServer.repo:refs_changed'; /** * Discovers catalog files located in Bitbucket Server. @@ -56,14 +64,23 @@ export class BitbucketServerEntityProvider implements EntityProvider { private readonly logger: LoggerService; private readonly scheduleFn: () => Promise; private connection?: EntityProviderConnection; + private readonly catalogApi?: CatalogService; + private readonly events?: EventsService; + private readonly auth?: AuthService; + private eventConfigErrorThrown = false; + private readonly targetAnnotation: string; + private readonly defaultBranchAnnotation: string; static fromConfig( config: Config, options: { logger: LoggerService; + events?: EventsService; parser?: BitbucketServerLocationParser; schedule?: SchedulerServiceTaskRunner; scheduler?: SchedulerService; + catalogApi?: CatalogService; + auth?: AuthService; }, ): BitbucketServerEntityProvider[] { const integrations = ScmIntegrations.fromConfig(config); @@ -98,6 +115,9 @@ export class BitbucketServerEntityProvider implements EntityProvider { options.logger, taskRunner, options.parser, + options.catalogApi, + options.events, + options.auth, ); }); } @@ -108,6 +128,9 @@ export class BitbucketServerEntityProvider implements EntityProvider { logger: LoggerService, taskRunner: SchedulerServiceTaskRunner, parser?: BitbucketServerLocationParser, + catalogApi?: CatalogService, + events?: EventsService, + auth?: AuthService, ) { this.integration = integration; this.config = config; @@ -116,6 +139,11 @@ export class BitbucketServerEntityProvider implements EntityProvider { target: this.getProviderName(), }); this.scheduleFn = this.createScheduleFn(taskRunner); + this.catalogApi = catalogApi; + this.auth = auth; + this.targetAnnotation = `${this.config.host.split(':')[0]}/repo-url`; + this.defaultBranchAnnotation = 'bitbucket.org/default-branch'; + this.events = events; } private createScheduleFn( @@ -154,6 +182,22 @@ export class BitbucketServerEntityProvider implements EntityProvider { async connect(connection: EntityProviderConnection): Promise { this.connection = connection; await this.scheduleFn(); + + if (this.events) { + await this.events.subscribe({ + id: this.getProviderName(), + topics: [TOPIC_REPO_REFS_CHANGED], + onEvent: async params => { + if (params.topic !== TOPIC_REPO_REFS_CHANGED) { + return; + } + + await this.onRepoPush( + params.eventPayload as BitbucketServerEvents.RefsChangedEvent, + ); + }, + }); + } } async refresh(logger: LoggerService) { @@ -218,10 +262,245 @@ export class BitbucketServerEntityProvider implements EntityProvider { presence: 'optional', }, })) { + if (entity.metadata.annotations === undefined) { + entity.metadata.annotations = {}; + } + if (repository.defaultBranch === undefined) { + const defaultBranchResponse = await client.getDefaultBranch({ + repo: repository.slug, + projectKey: project.key, + }); + entity.metadata.annotations[this.defaultBranchAnnotation] = + defaultBranchResponse.displayId; + } else { + entity.metadata.annotations[this.defaultBranchAnnotation] = + repository.defaultBranch; + } result.push(entity); } } } return result; } + + private isDefaultBranchPush( + defaultBranch: String, + event: BitbucketServerEvents.RefsChangedEvent, + ): boolean { + return event.changes.some(c => defaultBranch === c.ref.displayId); + } + + /** + * Checks if the provider is able to handle events + * @returns Boolean + */ + private canHandleEvents(): boolean { + if (this.catalogApi && this.auth) { + return true; + } + + if (!this.eventConfigErrorThrown) { + this.eventConfigErrorThrown = true; + throw new Error( + `${this.getProviderName()} not well configured to handle repo:push. Missing CatalogApi and/or AuthService.`, + ); + } + + return false; + } + + private async getLocationEntity( + event: BitbucketServerEvents.RefsChangedEvent, + ): Promise { + const client = BitbucketServerClient.fromConfig({ + config: this.integration.config, + }); + const result: Entity[] = []; + try { + const repository = await client.getRepository({ + projectKey: event.repository.project.key, + repo: event.repository.slug, + }); + + for await (const entity of this.parser({ + client, + logger: this.logger, + location: { + type: 'url', + target: `${repository.links.self[0].href}${this.config.catalogPath}`, + presence: 'optional', + }, + })) { + entity.metadata.annotations![ + this.targetAnnotation + ] = `${repository.links.self[0].href}${this.config.catalogPath}`; + + if (entity.metadata.annotations === undefined) { + entity.metadata.annotations = {}; + } + + if (repository.defaultBranch === undefined) { + const defaultBranchResponse = await client.getDefaultBranch({ + repo: repository.slug, + projectKey: event.repository.project.key, + }); + entity.metadata.annotations[this.defaultBranchAnnotation] = + defaultBranchResponse.displayId; + } else { + entity.metadata.annotations[this.defaultBranchAnnotation] = + repository.defaultBranch; + } + result.push(entity); + } + } catch (error: any) { + if (error.name === 'NotFoundError') { + this.logger.error(error.message); + } + } + + return result; + } + + private async onRepoPush( + event: BitbucketServerEvents.RefsChangedEvent, + ): Promise { + if (!this.canHandleEvents()) { + this.logger.error( + 'Bitbucket Server catalog entity provider is not set up to handle events. Missing authService or catalogApi.', + ); + return; + } + + if (!this.connection) { + throw new Error('Not initialized'); + } + + const repoSlug = event.repository.slug; + const catalogRepoUrl: string = `https://${this.config.host}/projects/${event.repository.project.key}/repos/${repoSlug}/browse${this.config.catalogPath}`; + this.logger.info(`handle repo:push event for ${catalogRepoUrl}`); + const targets = await this.getLocationEntity(event); + if (targets.length === 0) { + this.logger.error('Failed to create location entity.'); + return; + } + const existing = await this.findExistingLocations( + catalogRepoUrl, + await this.auth!.getOwnServiceCredentials(), + ); + const stillExisting: LocationEntity[] = []; + const removed: DeferredEntity[] = []; + existing.forEach(item => { + if ( + targets.find( + value => + value.metadata.annotations![this.targetAnnotation] === + item.spec.target, + ) + ) { + stillExisting.push(item); + } else { + removed.push({ + locationKey: this.getProviderName(), + entity: item, + }); + } + }); + + const added = await this.getAddedEntities(targets, existing); + + if ( + stillExisting.length > 0 && + stillExisting[0].metadata.annotations![this.defaultBranchAnnotation] !== + undefined && + !this.isDefaultBranchPush( + stillExisting[0].metadata.annotations![this.defaultBranchAnnotation], + event, + ) + ) { + return; + } else if ( + added.length > 0 && + added[0].entity.metadata.annotations![this.defaultBranchAnnotation] !== + undefined && + !this.isDefaultBranchPush( + added[0].entity.metadata.annotations![this.defaultBranchAnnotation], + event, + ) + ) { + return; + } else if ( + removed.length > 0 && + removed[0].entity.metadata.annotations![this.defaultBranchAnnotation] !== + undefined && + !this.isDefaultBranchPush( + removed[0].entity.metadata.annotations![this.defaultBranchAnnotation], + event, + ) + ) { + return; + } + + const promises: Promise[] = [ + this.connection.refresh({ + keys: stillExisting.map(entity => `url:${entity.spec.target}`), + }), + ]; + + if (added.length > 0 || removed.length > 0) { + promises.push( + this.connection.applyMutation({ + type: 'delta', + added: added, + removed: removed, + }), + ); + } + + await Promise.all(promises); + + return; + } + + private async getAddedEntities( + targets: Entity[], + existing: LocationEntity[], + ): Promise { + const added: DeferredEntity[] = toDeferredEntities( + targets.filter( + target => + !existing.find( + item => + item.spec.target === + target.metadata.annotations![this.targetAnnotation], + ), + ), + this.getProviderName(), + ); + return added; + } + + private async findExistingLocations( + catalogRepoUrl: string, + credentials: BackstageCredentials, + ): Promise { + const filter: Record = {}; + filter.kind = 'Location'; + filter[`metadata.annotations.${this.targetAnnotation}`] = catalogRepoUrl; + + return this.catalogApi!.getEntities({ filter }, { credentials }).then( + result => result.items, + ) as Promise; + } +} + +export function toDeferredEntities( + targets: Entity[], + locationKey: string, +): DeferredEntity[] { + return targets.map(entity => { + return { + locationKey, + entity, + }; + }); } diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts index 018ff23069..3b921b6bab 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerEntityProviderConfig.ts @@ -48,10 +48,8 @@ export function readProviderConfigs( // simple/single config variant return [readProviderConfig(DEFAULT_PROVIDER_ID, providersConfig)]; } - return providersConfig.keys().map(id => { const providerConfig = providersConfig.getConfig(id); - return readProviderConfig(id, providerConfig); }); } diff --git a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts index 6aea23387e..1c48d5e7bd 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts +++ b/plugins/catalog-backend-module-bitbucket-server/src/providers/BitbucketServerLocationParser.ts @@ -14,13 +14,11 @@ * limitations under the License. */ -import { - LocationSpec, - locationSpecToLocationEntity, -} from '@backstage/plugin-catalog-node'; +import { locationSpecToLocationEntity } from '@backstage/plugin-catalog-node'; import { Entity } from '@backstage/catalog-model'; -import { BitbucketServerClient } from '../lib'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; +import { BitbucketServerClient } from '../lib'; /** * A custom callback that reacts to finding a location by yielding entities. diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.test.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.test.ts index d00cbc888c..bddb4d27cd 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.test.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.test.ts @@ -311,4 +311,24 @@ describe('readProviderConfigs', () => { expect(() => readProviderConfigs(config)).toThrow(); }); + + it('throws an error when filters.branch contains a slash', () => { + const config = new ConfigReader({ + catalog: { + providers: { + github: { + invalidBranchUser: { + organization: 'test-org', + catalogPath: '/*/catalog-info.yaml', + filters: { + branch: 'test/a', + }, + }, + }, + }, + }, + }); + + expect(() => readProviderConfigs(config)).toThrow(); + }); }); diff --git a/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.ts b/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.ts index 2e2592b70a..781239978f 100644 --- a/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.ts +++ b/plugins/catalog-backend-module-github/src/providers/GithubEntityProviderConfig.ts @@ -104,6 +104,12 @@ function readProviderConfig( ); } + if (branchPattern?.includes('/')) { + throw new Error( + 'Error while processing GitHub provider config. Slash characters (/) are not allowed in filters.branch', + ); + } + const schedule = config.has('schedule') ? readSchedulerServiceTaskScheduleDefinitionFromConfig( config.getConfig('schedule'), diff --git a/plugins/catalog-import/report-alpha.api.md b/plugins/catalog-import/report-alpha.api.md index 91c0e05d30..4cbcc8e7c1 100644 --- a/plugins/catalog-import/report-alpha.api.md +++ b/plugins/catalog-import/report-alpha.api.md @@ -10,6 +10,16 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/frontend-plugin-api'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; + +// @alpha (undocumented) +export const catalogImportTranslationRef: TranslationRef< + 'catalog-import', + { + readonly pageTitle: 'Register an existing component'; + readonly 'importInfoCard.title': 'Register an existing component'; + } +>; // @alpha (undocumented) const _default: FrontendPlugin< diff --git a/plugins/catalog-import/report.api.md b/plugins/catalog-import/report.api.md index e31ef8defe..66a39e8b90 100644 --- a/plugins/catalog-import/report.api.md +++ b/plugins/catalog-import/report.api.md @@ -15,13 +15,13 @@ import { FetchApi } from '@backstage/core-plugin-api'; import { FieldErrors } from 'react-hook-form'; import { InfoCardVariants } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; +import { NestedValue } from 'react-hook-form'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { ScmAuthApi } from '@backstage/integration-react'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { SubmitHandler } from 'react-hook-form'; import { TextFieldProps } from '@material-ui/core/TextField/TextField'; -import { UnpackNestedValue } from 'react-hook-form'; import { UseFormProps } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form'; @@ -361,6 +361,17 @@ export interface StepPrepareCreatePullRequestProps { ) => React_2.ReactNode; } +// @public @deprecated +export type UnpackNestedValue = T extends NestedValue + ? U + : T extends Date | FileList | File | Blob + ? T + : T extends object + ? { + [K in keyof T]: UnpackNestedValue; + } + : T; + // Warnings were encountered during analysis: // // src/api/CatalogImportApi.d.ts:25:5 - (ae-forgotten-export) The symbol "PartialEntity" needs to be exported by the entry point index.d.ts diff --git a/plugins/catalog-import/src/alpha.tsx b/plugins/catalog-import/src/alpha.tsx index cf63c92338..ab6e3f2a68 100644 --- a/plugins/catalog-import/src/alpha.tsx +++ b/plugins/catalog-import/src/alpha.tsx @@ -38,6 +38,8 @@ import { CatalogImportClient, catalogImportApiRef } from './api'; import { rootRouteRef } from './plugin'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +export * from './translation'; + // TODO: It's currently possible to override the import page with a custom one. We need to decide // whether this type of override is typically done with an input or by overriding the entire extension. const catalogImportPage = PageBlueprint.make({ diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index fa89ac2caf..cdff61fe73 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -28,6 +28,8 @@ import { useTheme } from '@material-ui/core/styles'; import React from 'react'; import { ImportInfoCard } from '../ImportInfoCard'; import { ImportStepper } from '../ImportStepper'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { catalogImportTranslationRef } from '../../translation'; /** * The default catalog import page. @@ -35,6 +37,7 @@ import { ImportStepper } from '../ImportStepper'; * @public */ export const DefaultImportPage = () => { + const { t } = useTranslationRef(catalogImportTranslationRef); const theme = useTheme(); const configApi = useApi(configApiRef); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -54,7 +57,7 @@ export const DefaultImportPage = () => { return ( -
+
{supportTitle} diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx index c33de77ffd..caa6be223d 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.test.tsx @@ -25,6 +25,8 @@ import { screen } from '@testing-library/react'; import React from 'react'; import { CatalogImportApi, catalogImportApiRef } from '../../api'; import { ImportInfoCard } from './ImportInfoCard'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { catalogImportTranslationRef } from '../../translation'; describe('', () => { let apis: TestApiRegistry; @@ -50,6 +52,14 @@ describe('', () => { }); it('renders without exploding', async () => { + let translatedText = ''; + + const TestComponent = () => { + const { t } = useTranslationRef(catalogImportTranslationRef); + translatedText = t('importInfoCard.title'); + return ; + }; + await renderInTestApp( ', () => { [catalogImportApiRef, catalogImportApi], ]} > - + , ); - - expect( - screen.getByText('Register an existing component'), - ).toBeInTheDocument(); + expect(screen.getByText(translatedText)).toBeInTheDocument(); }); it('renders section on GitHub discovery if supported', async () => { diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index be8a457572..c0790006e5 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -21,6 +21,8 @@ import Typography from '@material-ui/core/Typography'; import React from 'react'; import { catalogImportApiRef } from '../../api'; import { useCatalogFilename } from '../../hooks'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { catalogImportTranslationRef } from '../../translation'; /** * Props for {@link ImportInfoCard}. @@ -43,6 +45,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { exampleRepositoryUrl = 'https://github.com/backstage/backstage', } = props; + const { t } = useTranslationRef(catalogImportTranslationRef); const configApi = useApi(configApiRef); const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const catalogImportApi = useApi(catalogImportApiRef); @@ -53,7 +56,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { return ( = T extends NestedValue + ? U + : T extends Date | FileList | File | Blob + ? T + : T extends object + ? { + [K in keyof T]: UnpackNestedValue; + } + : T; + /** * Props for {@link StepPrepareCreatePullRequest}. * diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts index ce1cd40e19..3340d40f84 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts @@ -23,4 +23,7 @@ export type { PreviewCatalogInfoComponentProps } from './PreviewCatalogInfoCompo export { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; export type { PreviewPullRequestComponentProps } from './PreviewPullRequestComponent'; export { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest'; -export type { StepPrepareCreatePullRequestProps } from './StepPrepareCreatePullRequest'; +export type { + StepPrepareCreatePullRequestProps, + UnpackNestedValue, +} from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/translation.ts b/plugins/catalog-import/src/translation.ts new file mode 100644 index 0000000000..353e09056b --- /dev/null +++ b/plugins/catalog-import/src/translation.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2024 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha'; + +/** @alpha */ +export const catalogImportTranslationRef = createTranslationRef({ + id: 'catalog-import', + messages: { + pageTitle: 'Register an existing component', + importInfoCard: { + title: 'Register an existing component', + }, + }, +}); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 0b2d2e4a4c..f49222849f 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -49,6 +49,7 @@ import { defaultCatalogTableColumnsFunc } from './defaultCatalogTableColumnsFunc import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { catalogTranslationRef } from '../../alpha/translation'; import { FavoriteToggleIcon } from '@backstage/core-components'; +import { CatalogTableToolbar } from './CatalogTableToolbar'; /** * Props for {@link CatalogTable}. @@ -251,6 +252,9 @@ export const CatalogTable = (props: CatalogTableProps) => { actions={actions} subtitle={subtitle} emptyContent={emptyContent} + components={{ + Toolbar: CatalogTableToolbar, + }} /> ); }; diff --git a/plugins/events-backend-module-bitbucket-server/.eslintrc.js b/plugins/events-backend-module-bitbucket-server/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/events-backend-module-bitbucket-server/README.md b/plugins/events-backend-module-bitbucket-server/README.md new file mode 100644 index 0000000000..4950015010 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/README.md @@ -0,0 +1,36 @@ +# events-backend-module-bitbucket-server + +Welcome to the `events-backend-module-bitbucket-server` backend plugin! + +This package is a module for the `events-backend` backend plugin +and extends the event system with an `BitbucketServerEventRouter`. + +The event router will subscribe to the topic `bitbucketServer` +and route the events to more concrete topics based on the value +of the provided `x-event-key` metadata field. + +Examples: + +| x-event-key | topic | +| ------------------- | ----------------------------------- | +| `repo:refs_changed` | `bitbucketServer.repo:refs_changed` | +| `repo:modified` | `bitbucketServer.repo:modified` | + +Please find all possible webhook event types at the +[official documentation](https://confluence.atlassian.com/bitbucketserver/event-payload-938025882.html). + +## Installation + +Install the [`events-backend` plugin](../events-backend/README.md). + +Install this module: + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-events-backend-module-bitbucket-server +``` + +```ts +// packages/backend/src/index.ts +backend.add(import('@backstage/plugin-events-backend-module-bitbucket-server')); +``` diff --git a/plugins/events-backend-module-bitbucket-server/catalog-info.yaml b/plugins/events-backend-module-bitbucket-server/catalog-info.yaml new file mode 100644 index 0000000000..d2a04ca2a8 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-events-backend-module-bitbucket-server + title: '@backstage/plugin-events-backend-module-bitbucket-server' +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/events-backend-module-bitbucket-server/package.json b/plugins/events-backend-module-bitbucket-server/package.json new file mode 100644 index 0000000000..29945bd16f --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-events-backend-module-bitbucket-server", + "version": "0.0.0", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "events", + "pluginPackage": "@backstage/plugin-events-backend" + }, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/events-backend-module-bitbucket-server" + }, + "license": "Apache-2.0", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-events-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-events-backend-test-utils": "workspace:^" + } +} diff --git a/plugins/events-backend-module-bitbucket-server/report.api.md b/plugins/events-backend-module-bitbucket-server/report.api.md new file mode 100644 index 0000000000..913556cfe9 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@backstage/plugin-events-backend-module-bitbucket-server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +const eventsModuleBitbucketServerEventRouter: BackendFeature; +export default eventsModuleBitbucketServerEventRouter; +``` diff --git a/plugins/events-backend-module-bitbucket-server/src/index.ts b/plugins/events-backend-module-bitbucket-server/src/index.ts new file mode 100644 index 0000000000..8a765ecfdf --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * The module "bitbucket-server" for the Backstage backend plugin "events-backend" + * adding an event router for Bitbucket Server. + * + * @packageDocumentation + */ +export { eventsModuleBitbucketServerEventRouter as default } from './service/eventsModuleBitbucketServerEventRouter'; diff --git a/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts new file mode 100644 index 0000000000..6fdb4020f3 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { BitbucketServerEventRouter } from './BitbucketServerEventRouter'; + +describe('BitbucketServerEventRouter', () => { + const events = new TestEventsService(); + const eventRouter = new BitbucketServerEventRouter({ events }); + const topic = 'bitbucketServer'; + const eventPayload = { test: 'payload' }; + const metadata = { 'x-event-key': 'test:type' }; + + beforeEach(() => { + events.reset(); + }); + + it('subscribed to topic', () => { + eventRouter.subscribe(); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('BitbucketServerEventRouter'); + expect(events.subscribed[0].topics).toEqual([topic]); + }); + + it('no x-event-key', () => { + eventRouter.onEvent({ topic, eventPayload }); + + expect(events.published).toEqual([]); + }); + + it('with x-event-key', () => { + eventRouter.onEvent({ topic, eventPayload, metadata }); + + expect(events.published.length).toBe(1); + expect(events.published[0].topic).toEqual('bitbucketServer.test:type'); + expect(events.published[0].eventPayload).toEqual(eventPayload); + expect(events.published[0].metadata).toEqual(metadata); + }); +}); diff --git a/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts new file mode 100644 index 0000000000..45031cc5f3 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/router/BitbucketServerEventRouter.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EventParams, + EventsService, + SubTopicEventRouter, +} from '@backstage/plugin-events-node'; + +/** + * Subscribes to the generic `bitbucketServer` topic + * and publishes the events under the more concrete sub-topic + * depending on the `x-event-key` provided. + * + * @public + */ +export class BitbucketServerEventRouter extends SubTopicEventRouter { + constructor(options: { events: EventsService }) { + super({ + events: options.events, + topic: 'bitbucketServer', + }); + } + + protected getSubscriberId(): string { + return 'BitbucketServerEventRouter'; + } + + protected determineSubTopic(params: EventParams): string | undefined { + return params.metadata?.['x-event-key'] as string | undefined; + } +} diff --git a/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts new file mode 100644 index 0000000000..2a8a26358b --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerEventRouter.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { BitbucketServerEventRouter } from '../router/BitbucketServerEventRouter'; + +/** + * Module for the events-backend plugin, adding an event router for Bitbucket Server. + * + * Registers the `BitbucketServerEventRouter`. + * + * @public + */ +export const eventsModuleBitbucketServerEventRouter = createBackendModule({ + pluginId: 'events', + moduleId: 'bitbucket-server-event-router', + register(env) { + env.registerInit({ + deps: { + events: eventsServiceRef, + }, + async init({ events }) { + const eventRouter = new BitbucketServerEventRouter({ + events, + }); + await eventRouter.subscribe(); + }, + }); + }, +}); diff --git a/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts new file mode 100644 index 0000000000..a66c000089 --- /dev/null +++ b/plugins/events-backend-module-bitbucket-server/src/service/eventsModuleBitbucketServerRouter.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { TestEventsService } from '@backstage/plugin-events-backend-test-utils'; +import { eventsServiceRef } from '@backstage/plugin-events-node'; +import { eventsModuleBitbucketServerEventRouter } from './eventsModuleBitbucketServerEventRouter'; + +describe('eventsModuleBitbucketServerEventRouter', () => { + it('should be correctly wired and set up', async () => { + const events = new TestEventsService(); + const eventsServiceFactory = createServiceFactory({ + service: eventsServiceRef, + deps: {}, + async factory({}) { + return events; + }, + }); + + await startTestBackend({ + features: [eventsServiceFactory, eventsModuleBitbucketServerEventRouter], + }); + + expect(events.subscribed).toHaveLength(1); + expect(events.subscribed[0].id).toEqual('BitbucketServerEventRouter'); + }); +}); diff --git a/plugins/catalog-backend-module-bitbucket-server/src/alpha.ts b/plugins/events-backend-module-bitbucket-server/src/setupTests.ts similarity index 78% rename from plugins/catalog-backend-module-bitbucket-server/src/alpha.ts rename to plugins/events-backend-module-bitbucket-server/src/setupTests.ts index cba672ce49..d3232290a7 100644 --- a/plugins/catalog-backend-module-bitbucket-server/src/alpha.ts +++ b/plugins/events-backend-module-bitbucket-server/src/setupTests.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * 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. @@ -14,8 +14,4 @@ * limitations under the License. */ -import { default as feature } from './module'; - -/** @alpha */ -const _feature = feature; -export default _feature; +export {}; diff --git a/plugins/kubernetes/src/alpha/entityContents.tsx b/plugins/kubernetes/src/alpha/entityContents.tsx index 7b6dc78e4b..2d27cd66b2 100644 --- a/plugins/kubernetes/src/alpha/entityContents.tsx +++ b/plugins/kubernetes/src/alpha/entityContents.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { compatWrapper } from '@backstage/core-compat-api'; import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import { isKubernetesAvailable } from '../Router'; export const entityKubernetesContent = EntityContentBlueprint.make({ name: 'kubernetes', @@ -24,7 +25,7 @@ export const entityKubernetesContent = EntityContentBlueprint.make({ defaultPath: '/kubernetes', defaultTitle: 'Kubernetes', defaultGroup: 'deployment', - filter: 'kind:component,resource', + filter: isKubernetesAvailable, loader: () => import('./KubernetesContentPage').then(m => compatWrapper(), diff --git a/plugins/notifications-backend-module-slack/.eslintrc.js b/plugins/notifications-backend-module-slack/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/notifications-backend-module-slack/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/notifications-backend-module-slack/README.md b/plugins/notifications-backend-module-slack/README.md new file mode 100644 index 0000000000..f730ede78a --- /dev/null +++ b/plugins/notifications-backend-module-slack/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-notifications-backend-module-slack + +The Slack backend module for the notifications plugin. + +See [Built-in Processors](https://backstage.io/docs/notifications/processors/#built-in-processors) for detailed documentation diff --git a/plugins/notifications-backend-module-slack/catalog-info.yaml b/plugins/notifications-backend-module-slack/catalog-info.yaml new file mode 100644 index 0000000000..327ae8239f --- /dev/null +++ b/plugins/notifications-backend-module-slack/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-notifications-backend-module-slack + title: '@backstage/plugin-notifications-backend-module-slack' + description: The slack backend module for the notifications plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/notifications-backend-module-slack/config.d.ts b/plugins/notifications-backend-module-slack/config.d.ts new file mode 100644 index 0000000000..8cd450940e --- /dev/null +++ b/plugins/notifications-backend-module-slack/config.d.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 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 interface Config { + notifications?: { + processors?: { + slack?: Array<{ + /** + * Slack Bot Token. Usually starts with `xoxb-`. + * @visibility secret + */ + token?: string; + /** + * Broadcast notification receivers when receiver is set to config + * These can be Slack User IDs, Slack User Email addresses, Slack Channel + * Names, or Slack Channel IDs. Any valid identifier that chat.postMessage can accept. + */ + broadcastChannels?: string[]; + }>; + }; + }; +} diff --git a/plugins/notifications-backend-module-slack/package.json b/plugins/notifications-backend-module-slack/package.json new file mode 100644 index 0000000000..7254d3ba6d --- /dev/null +++ b/plugins/notifications-backend-module-slack/package.json @@ -0,0 +1,61 @@ +{ + "name": "@backstage/plugin-notifications-backend-module-slack", + "version": "0.0.0", + "description": "The slack backend module for the notifications plugin.", + "backstage": { + "role": "backend-plugin-module", + "pluginId": "notifications", + "pluginPackage": "@backstage/plugin-notifications-backend" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/notifications-backend-module-slack" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist", + "config.d.ts" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-notifications-common": "workspace:^", + "@backstage/plugin-notifications-node": "workspace:^", + "@backstage/types": "workspace:^", + "@opentelemetry/api": "^1.9.0", + "@slack/bolt": "^3.21.4", + "@slack/types": "^2.14.0", + "@slack/web-api": "^7.5.0", + "dataloader": "^2.0.0", + "p-throttle": "^4.1.1" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@faker-js/faker": "^8.4.1", + "msw": "^2.0.0" + }, + "configSchema": "config.d.ts" +} diff --git a/plugins/notifications-backend-module-slack/report.api.md b/plugins/notifications-backend-module-slack/report.api.md new file mode 100644 index 0000000000..3c4ddb494d --- /dev/null +++ b/plugins/notifications-backend-module-slack/report.api.md @@ -0,0 +1,14 @@ +## API Report File for "@backstage/plugin-notifications-backend-module-slack" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public +export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; + +// @public +const notificationsModuleSlack: BackendFeature; +export default notificationsModuleSlack; +``` diff --git a/plugins/notifications-backend-module-slack/src/index.ts b/plugins/notifications-backend-module-slack/src/index.ts new file mode 100644 index 0000000000..f8a9e248f7 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2025 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. + */ + +/** + * The slack backend module for the notifications plugin. + * + * @packageDocumentation + */ + +export { ANNOTATION_SLACK_BOT_NOTIFY } from './lib'; +export { notificationsModuleSlack as default } from './module'; diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts new file mode 100644 index 0000000000..4c891ed3ab --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.test.ts @@ -0,0 +1,371 @@ +/* + * Copyright 2025 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 { mockServices } from '@backstage/backend-test-utils'; +import { SlackNotificationProcessor } from './SlackNotificationProcessor'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { WebClient } from '@slack/web-api'; +import { Entity } from '@backstage/catalog-model'; + +jest.mock('@slack/web-api', () => { + const mockSlack = { + chat: { + postMessage: jest.fn(() => ({ + ok: true, + ts: '1234567890.123456', + channel: 'C12345678', + })), + }, + conversations: { + list: jest.fn(() => ({ + ok: true, + channels: [{ id: 'C12345678', name: 'test' }], + })), + }, + users: { + list: jest.fn(() => ({ + ok: true, + members: [ + { + id: 'U12345678', + name: 'test', + profile: { email: 'test@example.com' }, + real_name: 'Test User', + is_bot: false, + is_app_user: false, + deleted: false, + }, + ], + })), + }, + }; + return { WebClient: jest.fn(() => mockSlack) }; +}); + +const DEFAULT_ENTITIES_RESPONSE = { + items: [ + { + kind: 'User', + metadata: { + name: 'mock', + namespace: 'default', + annotations: { + 'slack.com/bot-notify': 'U12345678', + }, + }, + spec: { + type: 'service', + owner: 'group:default/mock', + }, + } as unknown as Entity, + { + kind: 'Group', + metadata: { + name: 'mock', + namespace: 'default', + annotations: { + 'slack.com/bot-notify': 'C12345678', + }, + }, + } as unknown as Entity, + ], +}; + +describe('SlackNotificationProcessor', () => { + const logger = mockServices.logger.mock(); + const auth = mockServices.auth(); + const discovery = mockServices.discovery(); + const config = mockServices.rootConfig({ + data: { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + slack: [ + { + token: 'mock-token', + }, + ], + }, + }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should send a notification to a group', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { type: 'entity', entityRef: 'group:default/mock' }, + payload: { title: 'notification' }, + }); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'C12345678', + text: 'notification', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + }); + + describe('when a user notification is sent directly', () => { + it('should send a notification to a user', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'entity', entityRef: 'user:default/mock' }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.chat.postMessage).toHaveBeenCalledWith({ + channel: 'U12345678', + text: 'notification', + attachments: [ + { + color: '#00A699', + blocks: [ + { + type: 'section', + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: 'Severity: normal', + emoji: true, + }, + { + type: 'plain_text', + text: 'Topic: N/A', + emoji: true, + }, + ], + }, + ], + fallback: 'notification', + }, + ], + }); + }); + }); + + describe('when a user notification is expanded from a group', () => { + it('should not send a notification', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: 'user:default/mock', + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'entity', entityRef: 'group:default/group' }, + payload: { title: 'notification' }, + }, + ); + + expect(slack.chat.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe('when broadcast channels are not configured', () => { + it('should not send broadcast messages', async () => { + const slack = new WebClient(); + + const processor = SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: null, + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }, + ); + expect(slack.chat.postMessage).not.toHaveBeenCalled(); + }); + }); + + describe('when broadcast channels are configured', () => { + it('should send broadcast messages', async () => { + const slack = new WebClient(); + const broadcastConfig = mockServices.rootConfig({ + data: { + app: { + baseUrl: 'https://example.org', + }, + notifications: { + processors: { + slack: [ + { + token: 'mock-token', + broadcastChannels: ['C12345678', 'D12345678'], + }, + ], + }, + }, + }, + }); + + const processor = SlackNotificationProcessor.fromConfig(broadcastConfig, { + auth, + discovery, + logger, + catalog: catalogServiceMock({ + entities: DEFAULT_ENTITIES_RESPONSE.items, + }), + slack, + })[0]; + + await processor.processOptions({ + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }); + + await processor.postProcess( + { + origin: 'plugin', + id: '1234', + user: null, + created: new Date(), + payload: { + title: 'notification', + link: '/catalog/user/default/jane.doe', + }, + }, + { + recipients: { type: 'broadcast' }, + payload: { title: 'notification' }, + }, + ); + expect(slack.chat.postMessage).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts new file mode 100644 index 0000000000..3328c55408 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/SlackNotificationProcessor.ts @@ -0,0 +1,291 @@ +/* + * Copyright 2025 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 { + AuthService, + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity, parseEntityRef } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { NotFoundError } from '@backstage/errors'; +import { Notification } from '@backstage/plugin-notifications-common'; +import { + NotificationProcessor, + NotificationSendOptions, +} from '@backstage/plugin-notifications-node'; +import { durationToMilliseconds } from '@backstage/types'; +import { Counter, metrics } from '@opentelemetry/api'; +import { ChatPostMessageArguments, WebClient } from '@slack/web-api'; +import DataLoader from 'dataloader'; +import pThrottle from 'p-throttle'; +import { ANNOTATION_SLACK_BOT_NOTIFY } from './constants'; +import { toChatPostMessageArgs } from './util'; + +export class SlackNotificationProcessor implements NotificationProcessor { + private readonly logger: LoggerService; + private readonly catalog: CatalogApi; + private readonly auth: AuthService; + private readonly slack: WebClient; + private readonly sendNotifications; + private readonly messagesSent: Counter; + private readonly messagesFailed: Counter; + private readonly broadcastChannels?: string[]; + + static fromConfig( + config: Config, + options: { + auth: AuthService; + discovery: DiscoveryService; + logger: LoggerService; + catalog: CatalogApi; + slack?: WebClient; + broadcastChannels?: string[]; + }, + ): SlackNotificationProcessor[] { + const slackConfig = + config.getOptionalConfigArray('notifications.processors.slack') ?? []; + return slackConfig.map(c => { + const token = c.getString('token'); + const slack = options.slack ?? new WebClient(token); + const broadcastChannels = c.getOptionalStringArray('broadcastChannels'); + return new SlackNotificationProcessor({ + slack, + broadcastChannels, + ...options, + }); + }); + } + + private constructor(options: { + slack: WebClient; + auth: AuthService; + discovery: DiscoveryService; + logger: LoggerService; + catalog: CatalogApi; + broadcastChannels?: string[]; + }) { + const { auth, catalog, logger, slack, broadcastChannels } = options; + this.logger = logger; + this.catalog = catalog; + this.auth = auth; + this.slack = slack; + this.broadcastChannels = broadcastChannels; + + const meter = metrics.getMeter('default'); + this.messagesSent = meter.createCounter( + 'notifications.processors.slack.sent.count', + { + description: 'Number of messages sent to Slack successfully', + }, + ); + this.messagesFailed = meter.createCounter( + 'notifications.processors.slack.error.count', + { + description: 'Number of messages that failed to send to Slack', + }, + ); + + const throttle = pThrottle({ + limit: 10, + interval: durationToMilliseconds({ minutes: 1 }), + }); + const throttled = throttle((opts: ChatPostMessageArguments) => + this.sendNotification(opts), + ); + this.sendNotifications = async (opts: ChatPostMessageArguments[]) => { + const results = await Promise.allSettled( + opts.map(message => throttled(message)), + ); + + let successCount = 0; + let failureCount = 0; + + results.forEach(result => { + if (result.status === 'fulfilled') { + successCount++; + } else { + this.logger.error( + `Failed to send Slack channel notification: ${result.reason.message}`, + ); + failureCount++; + } + }); + + this.messagesSent.add(successCount); + this.messagesFailed.add(failureCount); + }; + } + + getName(): string { + return 'SlackNotificationProcessor'; + } + + async processOptions( + options: NotificationSendOptions, + ): Promise { + if (options.recipients.type !== 'entity') { + return options; + } + + const entityRefs = [options.recipients.entityRef].flat(); + + const outbound: ChatPostMessageArguments[] = []; + await Promise.all( + entityRefs.map(async entityRef => { + const compoundEntityRef = parseEntityRef(entityRef); + // skip users as they are sent direct messages + if (compoundEntityRef.kind === 'user') { + return; + } + + let channel; + try { + channel = await this.getSlackNotificationTarget(entityRef); + } catch (error) { + this.logger.error( + `Failed to get Slack channel for entity: ${ + (error as Error).message + }`, + ); + return; + } + + if (!channel) { + this.logger.debug(`No Slack channel found for entity: ${entityRef}`); + return; + } + + this.logger.debug( + `Sending notification with payload: ${JSON.stringify( + options.payload, + )}`, + ); + + const payload = toChatPostMessageArgs({ + channel, + payload: options.payload, + }); + + this.logger.debug( + `Sending Slack channel notification: ${JSON.stringify(payload)}`, + ); + outbound.push(payload); + }), + ); + + console.log('dispatching message'); + await this.sendNotifications(outbound); + + return options; + } + + async postProcess( + notification: Notification, + options: NotificationSendOptions, + ): Promise { + const destinations: string[] = []; + + // Handle broadcast case + if (notification.user === null) { + destinations.push(...(this.broadcastChannels ?? [])); + } else if (options.recipients.type === 'entity') { + // Handle user-specific notification + const entityRefs = [options.recipients.entityRef].flat(); + if (entityRefs.some(e => parseEntityRef(e).kind === 'group')) { + // We've already dispatched a slack channel message, so let's not send a DM. + return; + } + + const destination = await this.getSlackNotificationTarget( + notification.user, + ); + + if (!destination) { + this.logger.error( + `No slack.com/bot-notify annotation found for user: ${notification.user}`, + ); + return; + } + + destinations.push(destination); + } + + // If no destinations, nothing to do + if (destinations.length === 0) { + return; + } + + // Prepare outbound messages + const outbound = destinations.map(channel => + toChatPostMessageArgs({ channel, payload: options.payload }), + ); + + // Log debug info + outbound.forEach(payload => { + this.logger.debug(`Sending notification: ${JSON.stringify(payload)}`); + }); + + // Send notifications + await this.sendNotifications(outbound); + } + + async getEntities( + entityRefs: readonly string[], + ): Promise<(Entity | undefined)[]> { + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + + const response = await this.catalog.getEntitiesByRefs( + { + entityRefs: entityRefs.slice(), + fields: [`metadata.annotations.${ANNOTATION_SLACK_BOT_NOTIFY}`], + }, + { + token, + }, + ); + + return response.items; + } + + async getSlackNotificationTarget( + entityRef: string, + ): Promise { + const entityLoader = new DataLoader( + entityRefs => this.getEntities(entityRefs), + ); + const entity = await entityLoader.load(entityRef); + + if (!entity) { + console.log(`Entity not found: ${entityRef}`); + throw new NotFoundError(`Entity not found: ${entityRef}`); + } + + return entity?.metadata?.annotations?.[ANNOTATION_SLACK_BOT_NOTIFY]; + } + + async sendNotification(args: ChatPostMessageArguments): Promise { + const response = await this.slack.chat.postMessage(args); + + if (!response.ok) { + throw new Error(`Failed to send notification: ${response.error}`); + } + } +} diff --git a/plugins/notifications-backend-module-slack/src/lib/constants.ts b/plugins/notifications-backend-module-slack/src/lib/constants.ts new file mode 100644 index 0000000000..cdf052da7e --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/constants.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2025 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. + */ + +/** + * @public + * The annotation key for the entity's Slack ID. This can be + * any valid chat.postMessage destination including: + * - A user ID (U12345678) + * - A channel ID (C12345678) + * - A DM ID (D12345678) + * - A group ID (S12345678) + * + * It can also be a user's email address or a channel name, + * however IDs are preferred. + */ +export const ANNOTATION_SLACK_BOT_NOTIFY = 'slack.com/bot-notify'; diff --git a/plugins/notifications-backend-module-slack/src/lib/index.ts b/plugins/notifications-backend-module-slack/src/lib/index.ts new file mode 100644 index 0000000000..97835063b3 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 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 { SlackNotificationProcessor } from './SlackNotificationProcessor'; +export * from './constants'; diff --git a/plugins/notifications-backend-module-slack/src/lib/types.ts b/plugins/notifications-backend-module-slack/src/lib/types.ts new file mode 100644 index 0000000000..bd87e9656c --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2025 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 interface SlackNotificationOptions { + url: string; + payload: string; +} diff --git a/plugins/notifications-backend-module-slack/src/lib/util.ts b/plugins/notifications-backend-module-slack/src/lib/util.ts new file mode 100644 index 0000000000..5e2adbeec5 --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/lib/util.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2025 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 { + NotificationPayload, + NotificationSeverity, +} from '@backstage/plugin-notifications-common'; +import { ChatPostMessageArguments, KnownBlock } from '@slack/web-api'; + +export function toChatPostMessageArgs(options: { + channel: string; + payload: NotificationPayload; +}): ChatPostMessageArguments { + const { channel, payload } = options; + + const args: ChatPostMessageArguments = { + channel, + text: payload.title, + attachments: [ + { + color: getColor(payload.severity), + blocks: toSlackBlockKit(payload), + fallback: payload.title, + }, + ], + }; + + return args; +} + +export function toSlackBlockKit(payload: NotificationPayload): KnownBlock[] { + const { description, link, severity, topic } = payload; + return [ + { + type: 'section', + ...(description && { + text: { + type: 'mrkdwn', + text: description ?? 'No description provided', + }, + }), + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'View More', + }, + ...(link && { url: link }), + action_id: 'button-action', + }, + }, + { + type: 'context', + elements: [ + { + type: 'plain_text', + text: `Severity: ${severity ?? 'normal'}`, + emoji: true, + }, + { + type: 'plain_text', + text: `Topic: ${topic ?? 'N/A'}`, + emoji: true, + }, + ], + }, + ]; +} + +function getColor(severity: NotificationSeverity | undefined) { + switch (severity) { + case 'critical': + return '#FF0000'; // Red + case 'high': + return '#FFA500'; // Orange + case 'low': + case 'normal': + default: + return '#00A699'; // Neutral color + } +} diff --git a/plugins/notifications-backend-module-slack/src/module.ts b/plugins/notifications-backend-module-slack/src/module.ts new file mode 100644 index 0000000000..cb096dabba --- /dev/null +++ b/plugins/notifications-backend-module-slack/src/module.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2025 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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { CatalogClient } from '@backstage/catalog-client'; +import { notificationsProcessingExtensionPoint } from '@backstage/plugin-notifications-node'; +import { SlackNotificationProcessor } from './lib/SlackNotificationProcessor'; + +/** + * The Slack notification processor for use with the notifications plugin. + * This allows sending of notifications via Slack DMs or to channels. + * + * @public + */ +export const notificationsModuleSlack = createBackendModule({ + pluginId: 'notifications', + moduleId: 'slack', + register(reg) { + reg.registerInit({ + deps: { + auth: coreServices.auth, + config: coreServices.rootConfig, + discovery: coreServices.discovery, + logger: coreServices.logger, + notifications: notificationsProcessingExtensionPoint, + }, + async init({ auth, config, discovery, logger, notifications }) { + const catalogClient = new CatalogClient({ + discoveryApi: discovery, + }); + + notifications.addProcessor( + SlackNotificationProcessor.fromConfig(config, { + auth, + discovery, + logger, + catalog: catalogClient, + }), + ); + }, + }); + }, +}); diff --git a/plugins/notifications-backend/config.d.ts b/plugins/notifications-backend/config.d.ts new file mode 100644 index 0000000000..5dd795f8d3 --- /dev/null +++ b/plugins/notifications-backend/config.d.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2024 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 { HumanDuration } from '@backstage/types'; + +export interface Config { + /** + * Configuration options for notifications-backend + */ + notifications?: { + /** + * Concurrency limit for notification sending, defaults to 10 + */ + concurrencyLimit?: number; + /** + * Throttle duration between notification sending, defaults to 50ms + */ + throttleInterval?: HumanDuration | string; + }; +} diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index e770465e89..071c64dab9 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -26,6 +26,7 @@ "types": "src/index.ts", "files": [ "dist", + "config.d.ts", "migrations/**/*.{js,d.ts}" ], "scripts": { @@ -49,9 +50,11 @@ "@backstage/plugin-notifications-common": "workspace:^", "@backstage/plugin-notifications-node": "workspace:^", "@backstage/plugin-signals-node": "workspace:^", + "@backstage/types": "workspace:^", "express": "^4.17.1", "express-promise-router": "^4.1.0", "knex": "^3.0.0", + "p-throttle": "^4.1.1", "uuid": "^11.0.0", "winston": "^3.2.1", "yn": "^4.0.0" @@ -68,5 +71,6 @@ "@types/supertest": "^2.0.8", "msw": "^1.0.0", "supertest": "^7.0.0" - } + }, + "configSchema": "config.d.ts" } diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 28defa9081..7f0279b437 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -48,7 +48,9 @@ import { } from '@backstage/plugin-notifications-common'; import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams'; import { getUsersForEntityRef } from './getUsersForEntityRef'; -import { Config } from '@backstage/config'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; +import pThrottle from 'p-throttle'; /** @internal */ export interface RouterOptions { @@ -82,6 +84,19 @@ export async function createRouter( const WEB_NOTIFICATION_CHANNEL = 'Web'; const store = await DatabaseNotificationsStore.create({ database }); const frontendBaseUrl = config.getString('app.baseUrl'); + const concurrencyLimit = + config.getOptionalNumber('notifications.concurrencyLimit') ?? 10; + const throttleInterval = config.has('notifications.throttleInterval') + ? durationToMilliseconds( + readDurationFromConfig(config, { + key: 'notifications.throttleInterval', + }), + ) + : 50; + const throttle = pThrottle({ + limit: concurrencyLimit, + interval: throttleInterval, + }); const getUser = async (req: Request) => { const credentials = await httpAuth.credentials(req, { allow: ['user'] }); @@ -475,66 +490,79 @@ export async function createRouter( return notification; }; + const sendUserNotification = async ( + baseNotification: Omit, + user: string, + opts: NotificationSendOptions, + origin: string, + scope?: string, + ): Promise => { + const userNotification = { + ...baseNotification, + id: uuid(), + user, + }; + const notification = await preProcessNotification(userNotification, opts); + + const enabled = await isNotificationsEnabled({ + user, + channel: WEB_NOTIFICATION_CHANNEL, + origin: userNotification.origin, + }); + + let ret = notification; + + if (!enabled) { + postProcessNotification(ret, opts); + return undefined; + } + + let existingNotification; + if (scope) { + existingNotification = await store.getExistingScopeNotification({ + user, + scope, + origin, + }); + } + + if (existingNotification) { + const restored = await store.restoreExistingNotification({ + id: existingNotification.id, + notification, + }); + ret = restored ?? notification; + } else { + await store.saveNotification(notification); + } + + if (signals) { + await signals.publish({ + recipients: { type: 'user', entityRef: [user] }, + message: { + action: 'new_notification', + notification_id: ret.id, + }, + channel: 'notifications', + }); + } + postProcessNotification(ret, opts); + return ret; + }; + const sendUserNotifications = async ( baseNotification: Omit, users: string[], opts: NotificationSendOptions, origin: string, - ) => { - const notifications = []; + ): Promise => { const { scope } = opts.payload; const uniqueUsers = [...new Set(users)]; - for (const user of uniqueUsers) { - const userNotification = { - ...baseNotification, - id: uuid(), - user, - }; - const notification = await preProcessNotification(userNotification, opts); - - const enabled = await isNotificationsEnabled({ - user, - channel: WEB_NOTIFICATION_CHANNEL, - origin: userNotification.origin, - }); - - let ret = notification; - if (enabled) { - let existingNotification; - if (scope) { - existingNotification = await store.getExistingScopeNotification({ - user, - scope, - origin, - }); - } - - if (existingNotification) { - const restored = await store.restoreExistingNotification({ - id: existingNotification.id, - notification, - }); - ret = restored ?? notification; - } else { - await store.saveNotification(notification); - } - - notifications.push(ret); - - if (signals) { - await signals.publish({ - recipients: { type: 'user', entityRef: [user] }, - message: { - action: 'new_notification', - notification_id: ret.id, - }, - channel: 'notifications', - }); - } - } - postProcessNotification(ret, opts); - } - return notifications; + const throttled = throttle((user: string) => + sendUserNotification(baseNotification, user, opts, origin, scope), + ); + const sent = await Promise.all(uniqueUsers.map(user => throttled(user))); + return sent.filter(n => n !== undefined); }; const createNotificationHandler = async ( diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts index eaeb65ae9c..4d8e0fd0c0 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts @@ -243,4 +243,55 @@ describe('gitlab:group:ensureExists', () => { expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); }); + + it(`Should ${examples[5].description}`, async () => { + mockGitlabClient.Groups.search.mockResolvedValue([ + { + id: 1, + full_path: 'group1', + }, + { + id: 2, + full_path: 'group1/group2', + }, + { + id: 3, + full_path: 'group1/group2/group3', + }, + ]); + mockGitlabClient.Groups.create.mockResolvedValue({ + id: 4, + full_path: 'group1/group2/group3/group4', + }); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'tokenlols', + apiBaseUrl: 'https://api.gitlab.com', + }, + ], + }, + }); + const integrations = ScmIntegrations.fromConfig(config); + + const action = createGitlabGroupEnsureExistsAction({ integrations }); + + await action.handler({ + ...mockContext, + input: yaml.parse(examples[5].example).steps[0].input, + }); + + expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith( + 'Group 4', + 'group4', + { + parentId: 3, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 4); + }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts index bbe7ad4722..f379bd38ec 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts @@ -102,4 +102,25 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: + 'Create a group nested within another group using path and objects', + example: yaml.stringify({ + steps: [ + { + id: 'gitlabGroup', + name: 'Group', + action: 'gitlab:group:ensureExists', + input: { + repoUrl: 'gitlab.com', + path: [ + 'group1/group2', + { name: 'Group 3', slug: 'group3' }, + { name: 'Group 4', slug: 'group4' }, + ], + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts index f9dc12eb54..3d703eb91f 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts @@ -94,6 +94,38 @@ describe('gitlab:group:ensureExists', () => { expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); }); + it('should create a new group from pathstring if it does not exists', async () => { + mockGitlabClient.Groups.search.mockResolvedValue([ + { + id: 1, + full_path: 'bar', + }, + { + id: 2, + full_path: 'foo', + }, + ]); + + mockGitlabClient.Groups.create.mockResolvedValue({ + id: 3, + full_path: 'foo/bar', + }); + + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: ['foo/bar', 'baz'], + }, + }); + + expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', { + parentId: 2, + }); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); + }); + it('should create a new group from object if it does not exists', async () => { mockGitlabClient.Groups.search.mockResolvedValue([ { diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts index 5be5b7c10d..7e5b03ce04 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts @@ -76,11 +76,7 @@ export const createGitlabGroupEnsureExistsAction = (options: { let currentPath: string | null = null; let parentId: number | null = null; - for (const pathElement of path) { - const slug = - typeof pathElement === 'string' ? pathElement : pathElement.slug; - const name = - typeof pathElement === 'string' ? pathElement : pathElement.name; + for (const { name, slug } of pathIterator(path)) { const fullPath: string = currentPath ? `${currentPath}/${slug}` : slug; const result = (await api.Groups.search( fullPath, @@ -119,3 +115,19 @@ export const createGitlabGroupEnsureExistsAction = (options: { }, }); }; + +type PathPart = { name: string; slug: string }; +type PathItem = string | PathPart; + +function* pathIterator(items: PathItem[]): Generator { + for (const item of items) { + if (typeof item === 'string') { + const parts = item.split('/'); + for (const part of parts) { + yield { name: part, slug: part }; + } + } else { + yield item; + } + } +} diff --git a/plugins/techdocs-react/src/alpha.ts b/plugins/techdocs-react/src/alpha.ts index 689ec41977..130479561a 100644 --- a/plugins/techdocs-react/src/alpha.ts +++ b/plugins/techdocs-react/src/alpha.ts @@ -13,8 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import React from 'react'; import { TechDocsAddonOptions } from './types'; -import { attachComponentData } from '@backstage/core-plugin-api'; +import { + attachComponentData, + getComponentData, +} from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; import { getDataKeyByName, TECHDOCS_ADDONS_KEY } from './addons'; import { @@ -53,6 +57,19 @@ export const attachTechDocsAddonComponentData =

( techDocsAddon: ComponentType

, data: TechDocsAddonOptions, ) => { - attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data); - attachComponentData(techDocsAddon, getDataKeyByName(data.name), true); + const element = React.createElement(techDocsAddon as ComponentType); + + const isDataAttached = getComponentData( + element, + TECHDOCS_ADDONS_KEY, + ); + if (!isDataAttached) { + attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data); + } + + const dataKey = getDataKeyByName(data.name); + const isDataKeyAttached = getComponentData(element, dataKey); + if (!isDataKeyAttached) { + attachComponentData(techDocsAddon, dataKey, true); + } }; diff --git a/yarn.lock b/yarn.lock index 7e12004ebd..02cf528776 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5535,12 +5535,16 @@ __metadata: dependencies: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-events-backend-test-utils": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" luxon: ^3.0.0 msw: ^1.0.0 p-throttle: ^4.1.1 @@ -6355,6 +6359,18 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-events-backend-module-bitbucket-server@workspace:plugins/events-backend-module-bitbucket-server": + version: 0.0.0-use.local + resolution: "@backstage/plugin-events-backend-module-bitbucket-server@workspace:plugins/events-backend-module-bitbucket-server" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-events-backend-test-utils": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" + languageName: unknown + linkType: soft + "@backstage/plugin-events-backend-module-gerrit@workspace:plugins/events-backend-module-gerrit": version: 0.0.0-use.local resolution: "@backstage/plugin-events-backend-module-gerrit@workspace:plugins/events-backend-module-gerrit" @@ -6778,6 +6794,33 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-notifications-backend-module-slack@workspace:plugins/notifications-backend-module-slack": + version: 0.0.0-use.local + resolution: "@backstage/plugin-notifications-backend-module-slack@workspace:plugins/notifications-backend-module-slack" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" + "@backstage/plugin-notifications-common": "workspace:^" + "@backstage/plugin-notifications-node": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@faker-js/faker": ^8.4.1 + "@opentelemetry/api": ^1.9.0 + "@slack/bolt": ^3.21.4 + "@slack/types": ^2.14.0 + "@slack/web-api": ^7.5.0 + dataloader: ^2.0.0 + msw: ^2.0.0 + p-throttle: ^4.1.1 + languageName: unknown + linkType: soft + "@backstage/plugin-notifications-backend@workspace:^, @backstage/plugin-notifications-backend@workspace:plugins/notifications-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-notifications-backend@workspace:plugins/notifications-backend" @@ -6800,12 +6843,14 @@ __metadata: "@backstage/plugin-notifications-node": "workspace:^" "@backstage/plugin-signals-backend": "workspace:^" "@backstage/plugin-signals-node": "workspace:^" + "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 express: ^4.17.1 express-promise-router: ^4.1.0 knex: ^3.0.0 msw: ^1.0.0 + p-throttle: ^4.1.1 supertest: ^7.0.0 uuid: ^11.0.0 winston: ^3.2.1 @@ -9643,6 +9688,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^8.4.1": + version: 8.4.1 + resolution: "@faker-js/faker@npm:8.4.1" + checksum: d802d531f8929562715adc279cfec763c9a4bc596ec67b0ce43fd0ae61b285d2b0eec6f1f4aa852452a63721a842fe7e81926dce7bd92acca94b01e2a1f55f5a + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.1 resolution: "@fastify/busboy@npm:2.1.1" @@ -16402,6 +16454,121 @@ __metadata: languageName: node linkType: hard +"@slack/bolt@npm:^3.21.4": + version: 3.22.0 + resolution: "@slack/bolt@npm:3.22.0" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/oauth": ^2.6.3 + "@slack/socket-mode": ^1.3.6 + "@slack/types": ^2.13.0 + "@slack/web-api": ^6.13.0 + "@types/express": ^4.16.1 + "@types/promise.allsettled": ^1.0.3 + "@types/tsscmp": ^1.0.0 + axios: ^1.7.4 + express: ^4.21.0 + path-to-regexp: ^8.1.0 + promise.allsettled: ^1.0.2 + raw-body: ^2.3.3 + tsscmp: ^1.0.6 + checksum: edd5c7cf658808effde87c936f19a0cc2b7d49ac97471651f2b1bb3db0074b92dc8ad3c9657577105d93c48df9ba16c382902c0d90082854cbbe86bfc7753827 + languageName: node + linkType: hard + +"@slack/logger@npm:^3.0.0": + version: 3.0.0 + resolution: "@slack/logger@npm:3.0.0" + dependencies: + "@types/node": ">=12.0.0" + checksum: 6512d0e9e4be47ea465705ab9b6e6901f36fa981da0d4a657fde649d452b567b351002049b5ee0a22569b5119bf6c2f61befd5b8022d878addb7a99c91b03389 + languageName: node + linkType: hard + +"@slack/logger@npm:^4.0.0": + version: 4.0.0 + resolution: "@slack/logger@npm:4.0.0" + dependencies: + "@types/node": ">=18.0.0" + checksum: dc79e9d2032c4bf9ce01d96cc72882f003dd376d036f172d4169662cfc2c9b384a80d5546b06021578dd473e7059f064303f0ba851eeb153387f2081a1e3062e + languageName: node + linkType: hard + +"@slack/oauth@npm:^2.6.3": + version: 2.6.3 + resolution: "@slack/oauth@npm:2.6.3" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/jsonwebtoken": ^8.3.7 + "@types/node": ">=12" + jsonwebtoken: ^9.0.0 + lodash.isstring: ^4.0.1 + checksum: 6b556da01bd2b026177b4074cd44bdeff00165fb4297ef8f350035ca79ababfff0c0993a297a46ab742bb97469c6c1c8f5790c328ecf6370290fe31014ba3c5e + languageName: node + linkType: hard + +"@slack/socket-mode@npm:^1.3.6": + version: 1.3.6 + resolution: "@slack/socket-mode@npm:1.3.6" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/web-api": ^6.12.1 + "@types/node": ">=12.0.0" + "@types/ws": ^7.4.7 + eventemitter3: ^5 + finity: ^0.5.4 + ws: ^7.5.3 + checksum: a84c15a6d25a21f76258d1ccebeec1d78b0a0dac0b02ffdfcb3596e7acda5459e4b99a42207eab7e57bed7a2a1d85ac173adf5e07aa66949eac9cc9df3b43947 + languageName: node + linkType: hard + +"@slack/types@npm:^2.11.0, @slack/types@npm:^2.13.0, @slack/types@npm:^2.14.0, @slack/types@npm:^2.9.0": + version: 2.14.0 + resolution: "@slack/types@npm:2.14.0" + checksum: fbef74d50d0de8f16125f7178bd2e664a69eeefd827b09c6f78153957278fc4400049685c756076e7dbcabd04c22730ac783bcc5d36fd588c7749d35f02c2afd + languageName: node + linkType: hard + +"@slack/web-api@npm:^6.12.1, @slack/web-api@npm:^6.13.0": + version: 6.13.0 + resolution: "@slack/web-api@npm:6.13.0" + dependencies: + "@slack/logger": ^3.0.0 + "@slack/types": ^2.11.0 + "@types/is-stream": ^1.1.0 + "@types/node": ">=12.0.0" + axios: ^1.7.4 + eventemitter3: ^3.1.0 + form-data: ^2.5.0 + is-electron: 2.2.2 + is-stream: ^1.1.0 + p-queue: ^6.6.1 + p-retry: ^4.0.0 + checksum: 77f0d506bbb011ae43d322e5152e8b1ec2b88aa01256da6b3c9ff8ce106d2284f887cad2d9f044e0fe34dc865d60f2bce1c6bb5c4117150ff71a7ef341f5dfeb + languageName: node + linkType: hard + +"@slack/web-api@npm:^7.5.0": + version: 7.8.0 + resolution: "@slack/web-api@npm:7.8.0" + dependencies: + "@slack/logger": ^4.0.0 + "@slack/types": ^2.9.0 + "@types/node": ">=18.0.0" + "@types/retry": 0.12.0 + axios: ^1.7.8 + eventemitter3: ^5.0.1 + form-data: ^4.0.0 + is-electron: 2.2.2 + is-stream: ^2 + p-queue: ^6 + p-retry: ^4 + retry: ^0.13.1 + checksum: d76fcb6cfe8a8ebdaf71aaee7dfef54f3ce4fb5958f9f841bcba095097349f649c47e5e79adbaabbd4051fe0c8d6d96445adeeab3d67416c161ae20868f637f0 + languageName: node + linkType: hard + "@smithy/abort-controller@npm:^3.1.2, @smithy/abort-controller@npm:^3.1.9": version: 3.1.9 resolution: "@smithy/abort-controller@npm:3.1.9" @@ -19451,7 +19618,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.16.1, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -19613,6 +19780,15 @@ __metadata: languageName: node linkType: hard +"@types/is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "@types/is-stream@npm:1.1.0" + dependencies: + "@types/node": "*" + checksum: 23fcb06cd8adc0124d4c44071bd4b447c41f5e4c2eccb6166789c7fc0992b566e2e8b628a3800ff4472b686d9085adbec203925068bf72e350e085650e83adec + languageName: node + linkType: hard + "@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.1 resolution: "@types/istanbul-lib-coverage@npm:2.0.1" @@ -19731,6 +19907,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^8.3.7": + version: 8.5.9 + resolution: "@types/jsonwebtoken@npm:8.5.9" + dependencies: + "@types/node": "*" + checksum: 33815ab02d1371b423118316b7706d2f2ec03eeee5e1494be72da50425d2384e5e0a09ea193f7a5ab4b4f6a9c5847147305f50e965f3d927a95bdf8adb471b2a + languageName: node + linkType: hard + "@types/jsonwebtoken@npm:^9.0.0": version: 9.0.0 resolution: "@types/jsonwebtoken@npm:9.0.0" @@ -19939,7 +20124,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.0.0": +"@types/node@npm:*, @types/node@npm:>=12, @types/node@npm:>=12.0.0, @types/node@npm:>=13.7.0, @types/node@npm:>=18.0.0, @types/node@npm:^22.0.0": version: 22.13.10 resolution: "@types/node@npm:22.13.10" dependencies: @@ -20166,6 +20351,13 @@ __metadata: languageName: node linkType: hard +"@types/promise.allsettled@npm:^1.0.3": + version: 1.0.6 + resolution: "@types/promise.allsettled@npm:1.0.6" + checksum: 07dca8da25b49c0dc323201095552d86159483dc910dc61c345357c9c196b8498e6be4bf260cc2a9a539a725108df61b53db1d82723ed9886bb7c72fedd65f14 + languageName: node + linkType: hard + "@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": version: 15.7.14 resolution: "@types/prop-types@npm:15.7.14" @@ -20370,6 +20562,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 61a072c7639f6e8126588bf1eb1ce8835f2cb9c2aba795c4491cf6310e013267b0c8488039857c261c387e9728c1b43205099223f160bb6a76b4374f741b5603 + languageName: node + linkType: hard + "@types/retry@npm:0.12.2": version: 0.12.2 resolution: "@types/retry@npm:0.12.2" @@ -20665,6 +20864,13 @@ __metadata: languageName: node linkType: hard +"@types/tsscmp@npm:^1.0.0": + version: 1.0.2 + resolution: "@types/tsscmp@npm:1.0.2" + checksum: c02c0bb9f14f550947fea9fa6f9f3c28e6b2d47a6d049a5450ed466fb0c8a685b6ff37d070d4c43d930a5affc9d828f5e16e35cde1e734de228ffd2df76ac2a8 + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^2.0.0": version: 2.0.6 resolution: "@types/unist@npm:2.0.6" @@ -20741,6 +20947,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^7.4.7": + version: 7.4.7 + resolution: "@types/ws@npm:7.4.7" + dependencies: + "@types/node": "*" + checksum: b4c9b8ad209620c9b21e78314ce4ff07515c0cadab9af101c1651e7bfb992d7fd933bd8b9c99d110738fd6db523ed15f82f29f50b45510288da72e964dedb1a3 + languageName: node + linkType: hard + "@types/xml-encryption@npm:^1.2.4": version: 1.2.4 resolution: "@types/xml-encryption@npm:1.2.4" @@ -22946,6 +23161,21 @@ __metadata: languageName: node linkType: hard +"array.prototype.map@npm:^1.0.5": + version: 1.0.8 + resolution: "array.prototype.map@npm:1.0.8" + dependencies: + call-bind: ^1.0.8 + call-bound: ^1.0.3 + define-properties: ^1.2.1 + es-abstract: ^1.23.6 + es-array-method-boxes-properly: ^1.0.0 + es-object-atoms: ^1.0.0 + is-string: ^1.1.1 + checksum: df321613636ec8461965d72421569ece78f269460535ced5ec88db9aaa4fc58a9f26e597d72e726f105c55fa4b4b6db0d3156489dc13dfbc7a098b4f1d17b5ab + languageName: node + linkType: hard + "array.prototype.tosorted@npm:^1.1.4": version: 1.1.4 resolution: "array.prototype.tosorted@npm:1.1.4" @@ -23300,7 +23530,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7": +"axios@npm:^1.0.0, axios@npm:^1.6.0, axios@npm:^1.7.4, axios@npm:^1.7.7, axios@npm:^1.7.8": version: 1.8.4 resolution: "axios@npm:1.8.4" dependencies: @@ -24344,13 +24574,13 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1": - version: 1.0.1 - resolution: "call-bind-apply-helpers@npm:1.0.1" +"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" dependencies: es-errors: ^1.3.0 function-bind: ^1.1.2 - checksum: 3c55343261bb387c58a4762d15ad9d42053659a62681ec5eb50690c6b52a4a666302a01d557133ce6533e8bd04530ee3b209f23dd06c9577a1925556f8fcccdf + checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b languageName: node linkType: hard @@ -27648,6 +27878,13 @@ __metadata: languageName: node linkType: hard +"es-array-method-boxes-properly@npm:^1.0.0": + version: 1.0.0 + resolution: "es-array-method-boxes-properly@npm:1.0.0" + checksum: 2537fcd1cecf187083890bc6f5236d3a26bf39237433587e5bf63392e88faae929dbba78ff0120681a3f6f81c23fe3816122982c160d63b38c95c830b633b826 + languageName: node + linkType: hard + "es-define-property@npm:^1.0.0, es-define-property@npm:^1.0.1": version: 1.0.1 resolution: "es-define-property@npm:1.0.1" @@ -27662,6 +27899,23 @@ __metadata: languageName: node linkType: hard +"es-get-iterator@npm:^1.0.2": + version: 1.1.3 + resolution: "es-get-iterator@npm:1.1.3" + dependencies: + call-bind: ^1.0.2 + get-intrinsic: ^1.1.3 + has-symbols: ^1.0.3 + is-arguments: ^1.1.1 + is-map: ^2.0.2 + is-set: ^2.0.2 + is-string: ^1.0.7 + isarray: ^2.0.5 + stop-iteration-iterator: ^1.0.0 + checksum: 8fa118da42667a01a7c7529f8a8cca514feeff243feec1ce0bb73baaa3514560bd09d2b3438873cf8a5aaec5d52da248131de153b28e2638a061b6e4df13267d + languageName: node + linkType: hard + "es-iterator-helpers@npm:^1.2.1": version: 1.2.1 resolution: "es-iterator-helpers@npm:1.2.1" @@ -27693,12 +27947,12 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.0.0": - version: 1.0.0 - resolution: "es-object-atoms@npm:1.0.0" +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" dependencies: es-errors: ^1.3.0 - checksum: 26f0ff78ab93b63394e8403c353842b2272836968de4eafe97656adfb8a7c84b9099bf0fe96ed58f4a4cddc860f6e34c77f91649a58a5daa4a9c40b902744e3c + checksum: 214d3767287b12f36d3d7267ef342bbbe1e89f899cfd67040309fc65032372a8e60201410a99a1645f2f90c1912c8c49c8668066f6bdd954bcd614dda2e3da97 languageName: node linkType: hard @@ -28539,7 +28793,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^5.0.1": +"eventemitter3@npm:^5, eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" checksum: 543d6c858ab699303c3c32e0f0f47fc64d360bf73c3daf0ac0b5079710e340d6fe9f15487f94e66c629f5f82cd1a8678d692f3dbb6f6fcd1190e1b97fcad36f8 @@ -28984,7 +29238,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2, express@npm:^4.21.2": +"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2, express@npm:^4.21.0, express@npm:^4.21.2": version: 4.21.2 resolution: "express@npm:4.21.2" dependencies: @@ -29545,6 +29799,13 @@ __metadata: languageName: node linkType: hard +"finity@npm:^0.5.4": + version: 0.5.4 + resolution: "finity@npm:0.5.4" + checksum: eeea74de356ba963231108c3f8e2de44b4114497389121d603f8c3e8316b8d0772ff06b731af08ef5d6ca6b0e3a0fffab452122eca48837a98a2f7e5548b6be2 + languageName: node + linkType: hard + "first-chunk-stream@npm:^2.0.0": version: 2.0.0 resolution: "first-chunk-stream@npm:2.0.0" @@ -30131,21 +30392,21 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": - version: 1.2.6 - resolution: "get-intrinsic@npm:1.2.6" +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6": + version: 1.3.0 + resolution: "get-intrinsic@npm:1.3.0" dependencies: - call-bind-apply-helpers: ^1.0.1 - dunder-proto: ^1.0.0 + call-bind-apply-helpers: ^1.0.2 es-define-property: ^1.0.1 es-errors: ^1.3.0 - es-object-atoms: ^1.0.0 + es-object-atoms: ^1.1.1 function-bind: ^1.1.2 + get-proto: ^1.0.1 gopd: ^1.2.0 has-symbols: ^1.1.0 hasown: ^2.0.2 - math-intrinsics: ^1.0.0 - checksum: a7592a0b7f023a2e83c0121fa9449ca83780e370a5feeebe8452119474d148016e43b455049134ae7a683b9b11b93d3f65eac199a0ad452ab740d5f0c299de47 + math-intrinsics: ^1.1.0 + checksum: 301008e4482bb9a9cb49e132b88fee093bff373b4e6def8ba219b1e96b60158a6084f273ef5cafe832e42cd93462f4accb46a618d35fe59a2b507f2388c5b79d languageName: node linkType: hard @@ -30170,6 +30431,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" @@ -32024,13 +32295,13 @@ __metadata: languageName: node linkType: hard -"is-arguments@npm:^1.0.4": - version: 1.1.1 - resolution: "is-arguments@npm:1.1.1" +"is-arguments@npm:^1.0.4, is-arguments@npm:^1.1.1": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" dependencies: - call-bind: ^1.0.2 - has-tostringtag: ^1.0.0 - checksum: 7f02700ec2171b691ef3e4d0e3e6c0ba408e8434368504bb593d0d7c891c0dbfda6d19d30808b904a6cb1929bca648c061ba438c39f296c2a8ca083229c49f27 + call-bound: ^1.0.2 + has-tostringtag: ^1.0.2 + checksum: aae9307fedfe2e5be14aebd0f48a9eeedf6b8c8f5a0b66257b965146d1e94abdc3f08e3dce3b1d908e1fa23c70039a88810ee1d753905758b9b6eebbab0bafeb languageName: node linkType: hard @@ -32192,6 +32463,13 @@ __metadata: languageName: node linkType: hard +"is-electron@npm:2.2.2": + version: 2.2.2 + resolution: "is-electron@npm:2.2.2" + checksum: de5aa8bd8d72c96675b8d0f93fab4cc21f62be5440f65bc05c61338ca27bd851a64200f31f1bf9facbaa01b3dbfed7997b2186741d84b93b63e0aff1db6a9494 + languageName: node + linkType: hard + "is-elevated@npm:^3.0.0": version: 3.0.0 resolution: "is-elevated@npm:3.0.0" @@ -32322,7 +32600,7 @@ __metadata: languageName: node linkType: hard -"is-map@npm:^2.0.3": +"is-map@npm:^2.0.2, is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" checksum: e6ce5f6380f32b141b3153e6ba9074892bbbbd655e92e7ba5ff195239777e767a976dcd4e22f864accaf30e53ebf961ab1995424aef91af68788f0591b7396cc @@ -32528,7 +32806,7 @@ __metadata: languageName: node linkType: hard -"is-set@npm:^2.0.3": +"is-set@npm:^2.0.2, is-set@npm:^2.0.3": version: 2.0.3 resolution: "is-set@npm:2.0.3" checksum: 36e3f8c44bdbe9496c9689762cc4110f6a6a12b767c5d74c0398176aa2678d4467e3bf07595556f2dba897751bde1422480212b97d973c7b08a343100b0c0dfe @@ -32560,7 +32838,7 @@ __metadata: languageName: node linkType: hard -"is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": +"is-stream@npm:^2, is-stream@npm:^2.0.0, is-stream@npm:^2.0.1": version: 2.0.1 resolution: "is-stream@npm:2.0.1" checksum: b8e05ccdf96ac330ea83c12450304d4a591f9958c11fd17bed240af8d5ffe08aedafa4c0f4cfccd4d28dc9d4d129daca1023633d5c11601a6cbc77521f6fae66 @@ -32934,6 +33212,23 @@ __metadata: languageName: node linkType: hard +"iterate-iterator@npm:^1.0.1": + version: 1.0.2 + resolution: "iterate-iterator@npm:1.0.2" + checksum: 97b3ed4f2bebe038be57d03277879e406b2c537ceeeab7f82d4167f9a3cff872cc2cc5da3dc9920ff544ca247329d2a4d44121bb8ef8d0807a72176bdbc17c84 + languageName: node + linkType: hard + +"iterate-value@npm:^1.0.2": + version: 1.0.2 + resolution: "iterate-value@npm:1.0.2" + dependencies: + es-get-iterator: ^1.0.2 + iterate-iterator: ^1.0.1 + checksum: 446a4181657df1872e5020713206806757157db6ab375dee05eb4565b66e1244d7a99cd36ce06862261ad4bd059e66ba8192f62b5d1ff41d788c3b61953af6c3 + languageName: node + linkType: hard + "iterator.prototype@npm:^1.1.4": version: 1.1.4 resolution: "iterator.prototype@npm:1.1.4" @@ -35626,7 +35921,7 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.0.0, math-intrinsics@npm:^1.1.0": +"math-intrinsics@npm:^1.1.0": version: 1.1.0 resolution: "math-intrinsics@npm:1.1.0" checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 @@ -38457,7 +38752,7 @@ __metadata: languageName: node linkType: hard -"p-queue@npm:^6.6.2": +"p-queue@npm:^6, p-queue@npm:^6.6.1, p-queue@npm:^6.6.2": version: 6.6.2 resolution: "p-queue@npm:6.6.2" dependencies: @@ -38467,6 +38762,16 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^4, p-retry@npm:^4.0.0": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": 0.12.0 + retry: ^0.13.1 + checksum: 45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + "p-retry@npm:^6.2.0": version: 6.2.0 resolution: "p-retry@npm:6.2.0" @@ -40192,6 +40497,20 @@ __metadata: languageName: node linkType: hard +"promise.allsettled@npm:^1.0.2": + version: 1.0.7 + resolution: "promise.allsettled@npm:1.0.7" + dependencies: + array.prototype.map: ^1.0.5 + call-bind: ^1.0.2 + define-properties: ^1.2.0 + es-abstract: ^1.22.1 + get-intrinsic: ^1.2.1 + iterate-value: ^1.0.2 + checksum: 96186392286e5ab9aef1a1a725c061c8cf268b6cf141f151daa3834bb8e1680f3b159af6536ce59cf80d4a6a5ad1d8371d05759980cc6c90d58800ddb0a7c119 + languageName: node + linkType: hard + "promise.series@npm:^0.2.0": version: 0.2.0 resolution: "promise.series@npm:0.2.0" @@ -40612,7 +40931,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:2.5.2, raw-body@npm:^2.4.1": +"raw-body@npm:2.5.2, raw-body@npm:^2.3.3, raw-body@npm:^2.4.1": version: 2.5.2 resolution: "raw-body@npm:2.5.2" dependencies: @@ -40934,11 +41253,11 @@ __metadata: linkType: hard "react-hook-form@npm:^7.12.2": - version: 7.53.2 - resolution: "react-hook-form@npm:7.53.2" + version: 7.55.0 + resolution: "react-hook-form@npm:7.55.0" peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 - checksum: 37e2dd0e79cd8d3376a7a2cb72ad7b59f0594be499daa898d2c6bec896fc30c2f86e62e8b41bc9d325f77220bd8d76cb31e917f77f1c92ad5740adb1a4cc69e2 + checksum: ff37fba242c0e94911bf8515bfe1b0670f93bf0ed0c44c923108859244f41da3f0f6dffcf70d570b152ef6b1d56936d943b45fba11c63f21b913fba8f0da862b languageName: node linkType: hard @@ -43875,6 +44194,16 @@ __metadata: languageName: node linkType: hard +"stop-iteration-iterator@npm:^1.0.0": + version: 1.1.0 + resolution: "stop-iteration-iterator@npm:1.1.0" + dependencies: + es-errors: ^1.3.0 + internal-slot: ^1.1.0 + checksum: be944489d8829fb3bdec1a1cc4a2142c6b6eb317305eeace1ece978d286d6997778afa1ae8cb3bd70e2b274b9aa8c69f93febb1e15b94b1359b11058f9d3c3a1 + languageName: node + linkType: hard + "stoppable@npm:^1.1.0": version: 1.1.0 resolution: "stoppable@npm:1.1.0" @@ -45517,7 +45846,7 @@ __metadata: languageName: node linkType: hard -"tsscmp@npm:1.0.6": +"tsscmp@npm:1.0.6, tsscmp@npm:^1.0.6": version: 1.0.6 resolution: "tsscmp@npm:1.0.6" checksum: 1512384def36bccc9125cabbd4c3b0e68608d7ee08127ceaa0b84a71797263f1a01c7f82fa69be8a3bd3c1396e2965d2f7b52d581d3a5eeaf3967fbc52e3b3bf @@ -47449,7 +47778,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7, ws@npm:^7.5.5": +"ws@npm:^7, ws@npm:^7.5.3, ws@npm:^7.5.5": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: