Merge branch 'master' into canon-merge-field-input

This commit is contained in:
Charles de Dreuille
2025-04-01 13:56:17 +01:00
81 changed files with 3342 additions and 226 deletions
+9
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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`)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes': patch
---
Added New Frontend System filter for the Kubernetes tab to use `isKubernetesAvailable` to control its visibility
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Internal refactor and fixes to the prop extraction logic for layout components.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
Expose the `UnpackNestedValue` type as it's been removed from `react-hook-form`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend-module-slack': minor
---
Added a new Slack NotificationProcessor for use with the notifications plugin
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
adding translation for `Register an existing component` text
+6
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend': patch
---
Allow throttling notification sending not to block the system with huge amount of receiving users
+5
View File
@@ -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).
+18 -5
View File
@@ -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:
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend/README.md>
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-aws-sqs/README.md>
- <https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-bitbucket-server/README.md>
## Configuration
To use the entity provider, you'll need a [Bitbucket Server integration set up](locations.md).
+1 -1
View File
@@ -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.
+66 -1
View File
@@ -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 <https://github.com/backstage/backstage/blob/master/plugins/notifications-backend-module-email/README.md>
### 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
@@ -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'
+36 -18
View File
@@ -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);
+2 -2
View File
@@ -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.';
@@ -92,7 +92,7 @@ describe('<AlertDisplay />', () => {
</TestApiProvider>,
);
expect(screen.getByText('(2 older messages)')).toBeInTheDocument();
expect(screen.getByText('(2 newer messages)')).toBeInTheDocument();
});
});
@@ -189,19 +189,19 @@ describe('<AlertDisplay />', () => {
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('<AlertDisplay />', () => {
// 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('<AlertDisplay />', () => {
// 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('<AlertDisplay />', () => {
// 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('<AlertDisplay />', () => {
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();
+2 -2
View File
@@ -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: {
@@ -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"
},
@@ -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)
```
@@ -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<BitbucketServerDefaultBranch>;
// (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<void>;
@@ -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)
@@ -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';
@@ -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);
});
});
@@ -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<BitbucketServerDefaultBranch> {
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 }): {
@@ -20,6 +20,9 @@ export type {
BitbucketServerListOptions,
} from './BitbucketServerClient';
export type {
BitbucketServerDefaultBranch,
BitbucketServerRepository,
BitbucketServerEvents,
BitbucketServerProject,
} from './types';
// export * from './types';
@@ -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,
};
@@ -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<BitbucketServerEntityProvider> | 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);
});
});
@@ -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);
@@ -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<any> {
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<string, string> },
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<string, string> },
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);
});
});
@@ -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<void>;
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<void> {
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<Entity[]> {
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<void> {
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<void>[] = [
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<DeferredEntity[]> {
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<LocationEntity[]> {
const filter: Record<string, string> = {};
filter.kind = 'Location';
filter[`metadata.annotations.${this.targetAnnotation}`] = catalogRepoUrl;
return this.catalogApi!.getEntities({ filter }, { credentials }).then(
result => result.items,
) as Promise<LocationEntity[]>;
}
}
export function toDeferredEntities(
targets: Entity[],
locationKey: string,
): DeferredEntity[] {
return targets.map(entity => {
return {
locationKey,
entity,
};
});
}
@@ -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);
});
}
@@ -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.
@@ -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();
});
});
@@ -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'),
@@ -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<
+12 -1
View File
@@ -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> = T extends NestedValue<infer U>
? U
: T extends Date | FileList | File | Blob
? T
: T extends object
? {
[K in keyof T]: UnpackNestedValue<T[K]>;
}
: 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
+2
View File
@@ -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({
@@ -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 (
<Page themeId="home">
<Header title="Register an existing component" />
<Header title={t('pageTitle')} />
<Content>
<ContentHeader title={`Start tracking your component in ${appTitle}`}>
<SupportButton>{supportTitle}</SupportButton>
@@ -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('<ImportInfoCard />', () => {
let apis: TestApiRegistry;
@@ -50,6 +52,14 @@ describe('<ImportInfoCard />', () => {
});
it('renders without exploding', async () => {
let translatedText = '';
const TestComponent = () => {
const { t } = useTranslationRef(catalogImportTranslationRef);
translatedText = t('importInfoCard.title');
return <ImportInfoCard />;
};
await renderInTestApp(
<TestApiProvider
apis={[
@@ -57,13 +67,10 @@ describe('<ImportInfoCard />', () => {
[catalogImportApiRef, catalogImportApi],
]}
>
<ImportInfoCard />
<TestComponent />
</TestApiProvider>,
);
expect(
screen.getByText('Register an existing component'),
).toBeInTheDocument();
expect(screen.getByText(translatedText)).toBeInTheDocument();
});
it('renders section on GitHub discovery if supported', async () => {
@@ -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 (
<InfoCard
title="Register an existing component"
title={t('importInfoCard.title')}
titleTypographyProps={{ component: 'h3' }}
deepLink={{
title: 'Learn more about the Software Catalog',
@@ -27,7 +27,7 @@ import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import React, { useCallback, useEffect, useState } from 'react';
import { UnpackNestedValue, UseFormReturn } from 'react-hook-form';
import { NestedValue, UseFormReturn } from 'react-hook-form';
import useAsync from 'react-use/esm/useAsync';
import YAML from 'yaml';
import { AnalyzeResult, catalogImportApiRef } from '../../api';
@@ -56,6 +56,22 @@ type FormData = {
useCodeowners: boolean;
};
/**
* Helper for unpacking NestedValue into the underlying type.
*
* @public
* @deprecated This is a copy of the type from react-hook-form, and will be removed in a future release
*/
export type UnpackNestedValue<T> = T extends NestedValue<infer U>
? U
: T extends Date | FileList | File | Blob
? T
: T extends object
? {
[K in keyof T]: UnpackNestedValue<T[K]>;
}
: T;
/**
* Props for {@link StepPrepareCreatePullRequest}.
*
@@ -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';
+28
View File
@@ -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',
},
},
});
@@ -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,
}}
/>
);
};
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -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'));
```
@@ -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
@@ -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:^"
}
}
@@ -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;
```
@@ -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';
@@ -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);
});
});
@@ -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;
}
}
@@ -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();
},
});
},
});
@@ -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');
});
});
@@ -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 {};
@@ -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(<m.KubernetesContentPage />),
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -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
@@ -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
+34
View File
@@ -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[];
}>;
};
};
}
@@ -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"
}
@@ -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;
```
@@ -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';
@@ -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);
});
});
});
@@ -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<NotificationSendOptions> {
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<void> {
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<string | undefined> {
const entityLoader = new DataLoader<string, Entity | undefined>(
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<void> {
const response = await this.slack.chat.postMessage(args);
if (!response.ok) {
throw new Error(`Failed to send notification: ${response.error}`);
}
}
}
@@ -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';
@@ -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';
@@ -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;
}
@@ -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
}
}
@@ -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,
}),
);
},
});
},
});
+32
View File
@@ -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;
};
}
+5 -1
View File
@@ -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"
}
@@ -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<unknown>) => {
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
@@ -475,66 +490,79 @@ export async function createRouter(
return notification;
};
const sendUserNotification = async (
baseNotification: Omit<Notification, 'user' | 'id'>,
user: string,
opts: NotificationSendOptions,
origin: string,
scope?: string,
): Promise<Notification | undefined> => {
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<NewNotificationSignal>({
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<Notification, 'user' | 'id'>,
users: string[],
opts: NotificationSendOptions,
origin: string,
) => {
const notifications = [];
): Promise<Notification[]> => {
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<NewNotificationSignal>({
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 (
@@ -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);
});
});
@@ -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' },
],
},
},
],
}),
},
];
@@ -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([
{
@@ -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<PathPart> {
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;
}
}
}
+20 -3
View File
@@ -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 = <P>(
techDocsAddon: ComponentType<P>,
data: TechDocsAddonOptions,
) => {
attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data);
attachComponentData(techDocsAddon, getDataKeyByName(data.name), true);
const element = React.createElement(techDocsAddon as ComponentType);
const isDataAttached = getComponentData<TechDocsAddonOptions>(
element,
TECHDOCS_ADDONS_KEY,
);
if (!isDataAttached) {
attachComponentData(techDocsAddon, TECHDOCS_ADDONS_KEY, data);
}
const dataKey = getDataKeyByName(data.name);
const isDataKeyAttached = getComponentData<boolean>(element, dataKey);
if (!isDataKeyAttached) {
attachComponentData(techDocsAddon, dataKey, true);
}
};
+367 -38
View File
@@ -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: