diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 469f6a9f7..542bd8eba 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -8,6 +8,7 @@ import userSettingsPlugin from '@backstage/plugin-user-settings/alpha'; import catalogGraphPlugin from '@backstage/plugin-catalog-graph/alpha'; import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; import apiDocsPlugin from '@backstage/plugin-api-docs/alpha'; +import notificationsPlugin from '@backstage/plugin-notifications/alpha'; import signalsPlugin from '@backstage/plugin-signals/alpha'; import githubActionsPlugin from '@backstage-community/plugin-github-actions/alpha'; import { createApp } from '@backstage/frontend-defaults'; @@ -41,6 +42,7 @@ const app = createApp({ gsPlugin, scaffolderPlugin, scaffolderPluginOverrides, + notificationsPlugin, signalsPlugin, // Upstream NFS plugins (pages provided by routeOverrides or defaults): homePlugin, diff --git a/packages/app/src/modules/nav/Sidebar.tsx b/packages/app/src/modules/nav/Sidebar.tsx index 0de322be3..93cc70c1f 100644 --- a/packages/app/src/modules/nav/Sidebar.tsx +++ b/packages/app/src/modules/nav/Sidebar.tsx @@ -17,6 +17,7 @@ import { UserSettingsSignInAvatar, Settings as SidebarSettings, } from '@backstage/plugin-user-settings'; +import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; export const SidebarContent = NavContentBlueprint.make({ params: { @@ -50,6 +51,7 @@ export const SidebarContent = NavContentBlueprint.make({ {nav.take('page:scaffolder')} + { + await sendReleaseNotification({ + version, + notifications, + logger, + }); + }); + }, + }); + }, +}); diff --git a/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts b/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts new file mode 100644 index 000000000..59b896e22 --- /dev/null +++ b/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts @@ -0,0 +1,102 @@ +import { sendReleaseNotification } from './releaseNotifier'; +import { NotificationService } from '@backstage/plugin-notifications-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +function createMockLogger(): jest.Mocked { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +function createMockNotifications(): jest.Mocked { + return { + send: jest.fn(), + }; +} + +describe('sendReleaseNotification', () => { + let logger: jest.Mocked; + let notifications: jest.Mocked; + + beforeEach(() => { + logger = createMockLogger(); + notifications = createMockNotifications(); + jest.clearAllMocks(); + }); + + it('skips when version is empty', async () => { + await sendReleaseNotification({ version: '', notifications, logger }); + + expect(notifications.send).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('not available'), + ); + }); + + it('skips when version is unparseable', async () => { + await sendReleaseNotification({ + version: 'not-a-version', + notifications, + logger, + }); + + expect(notifications.send).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('could not parse'), + ); + }); + + it('skips patch releases', async () => { + await sendReleaseNotification({ + version: '0.115.1', + notifications, + logger, + }); + + expect(notifications.send).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('patch release'), + ); + }); + + it('sends notification for minor release with link', async () => { + await sendReleaseNotification({ + version: '0.115.0', + notifications, + logger, + }); + + expect(notifications.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: { + title: 'Portal updated to v0.115.0', + description: 'Click to view the release notes.', + link: 'https://github.com/giantswarm/backstage/releases/tag/v0.115.0', + severity: 'normal', + topic: 'release', + scope: 'release-v0.115.0', + }, + }); + }); + + it('sends notification for major release', async () => { + await sendReleaseNotification({ + version: '1.0.0', + notifications, + logger, + }); + + expect(notifications.send).toHaveBeenCalledWith({ + recipients: { type: 'broadcast' }, + payload: expect.objectContaining({ + title: 'Portal updated to v1.0.0', + link: 'https://github.com/giantswarm/backstage/releases/tag/v1.0.0', + scope: 'release-v1.0.0', + }), + }); + }); +}); diff --git a/plugins/notifications-backend-module-gs/src/releaseNotifier.ts b/plugins/notifications-backend-module-gs/src/releaseNotifier.ts new file mode 100644 index 000000000..0008445ec --- /dev/null +++ b/plugins/notifications-backend-module-gs/src/releaseNotifier.ts @@ -0,0 +1,55 @@ +import semver from 'semver'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { NotificationService } from '@backstage/plugin-notifications-node'; + +const RELEASE_URL_BASE = 'https://github.com/giantswarm/backstage/releases/tag'; + +export interface ReleaseNotifierOptions { + version: string; + notifications: NotificationService; + logger: LoggerService; +} + +export async function sendReleaseNotification( + options: ReleaseNotifierOptions, +): Promise { + const { version, notifications, logger } = options; + + if (!version) { + logger.warn('Release notifier: app version not available, skipping.'); + return; + } + + const parsed = semver.parse(version); + if (!parsed) { + logger.warn( + `Release notifier: could not parse version "${version}", skipping.`, + ); + return; + } + + if (parsed.patch !== 0) { + logger.info( + `Release notifier: patch release v${version} — skipping notification.`, + ); + return; + } + + const scope = `release-v${parsed.major}.${parsed.minor}.0`; + + logger.info( + `Release notifier: sending broadcast notification for v${version}.`, + ); + + await notifications.send({ + recipients: { type: 'broadcast' }, + payload: { + title: `Portal updated to v${version}`, + description: 'Click to view the release notes.', + link: `${RELEASE_URL_BASE}/v${version}`, + severity: 'normal', + topic: 'release', + scope, + }, + }); +} diff --git a/yarn.lock b/yarn.lock index e4b65bbd4..da0d83c5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7107,7 +7107,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-notifications-node@npm:^0.2.24": +"@backstage/plugin-notifications-node@backstage:^::backstage=1.49.2&npm=0.2.24, @backstage/plugin-notifications-node@npm:^0.2.24": version: 0.2.24 resolution: "@backstage/plugin-notifications-node@npm:0.2.24" dependencies: @@ -10109,6 +10109,19 @@ __metadata: languageName: unknown linkType: soft +"@giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:^, @giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:plugins/notifications-backend-module-gs": + version: 0.0.0-use.local + resolution: "@giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:plugins/notifications-backend-module-gs" + dependencies: + "@backstage/backend-plugin-api": "backstage:^" + "@backstage/backend-test-utils": "backstage:^" + "@backstage/cli": "backstage:^" + "@backstage/plugin-notifications-node": "backstage:^" + "@types/semver": "npm:^7" + semver: "npm:^7.7.3" + languageName: unknown + linkType: soft + "@giantswarm/backstage-plugin-scaffolder-backend-module-gs@npm:^0.11.0, @giantswarm/backstage-plugin-scaffolder-backend-module-gs@workspace:plugins/scaffolder-backend-module-gs": version: 0.0.0-use.local resolution: "@giantswarm/backstage-plugin-scaffolder-backend-module-gs@workspace:plugins/scaffolder-backend-module-gs" @@ -23554,6 +23567,7 @@ __metadata: "@giantswarm/backstage-plugin-auth-backend-module-gs": "npm:^0.13.0" "@giantswarm/backstage-plugin-catalog-backend-module-gs": "npm:^0.2.0" "@giantswarm/backstage-plugin-gs-backend": "npm:^0.6.0" + "@giantswarm/backstage-plugin-notifications-backend-module-gs": "workspace:^" "@giantswarm/backstage-plugin-scaffolder-backend-module-gs": "npm:^0.11.0" "@giantswarm/backstage-plugin-techdocs-backend-module-gs": "npm:^0.10.0" "@internal/backend-common": "workspace:^"