From aeabb7244615ea4409ce7d846dafd95df74d54db Mon Sep 17 00:00:00 2001 From: Mike Yumatov Date: Tue, 30 Jun 2026 09:33:20 +0300 Subject: [PATCH] feat(notifications): customizable comment-notification recipients Add a code extension seam so a company can replace who is notified about doc comments (e.g. a maintainers user-list instead of all members of the owning group), with today's behavior preserved as the default. - CommentRecipientResolver interface: resolveRecipients(activity) => string[] ([] = notify nobody; no null sentinel). - rwCommentRecipientExtensionPoint: module-owned extension point with a single setRecipientResolver (throws on double-registration), mirroring Backstage's own setNotificationRecipientResolver. A sibling pluginId:"rw" module registers a resolver; the notifications module reads it in its own init. - DefaultCommentRecipientResolver (exported): the prior policy (new thread => section owner; reply/resolve => participants); custom resolvers delegate to it for audiences they don't override. - CommentNotifier delegates recipient selection to the resolver and is fail-closed: if the resolver throws, the notification is discarded and logged rather than falling back to the broader default audience. Coalescing scope and topic stay tied to the activity kind, not the recipients. - Re-document CommentActivity.sectionOwnerRef as a neutral fact (rw-node). - README: 'Customizing who gets notified' section with a worked example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rw-backend-module-notifications/README.md | 88 +++++++++++ .../src/CommentNotifier.test.ts | 68 +++++++++ .../src/CommentNotifier.ts | 51 ++++--- .../src/CommentRecipientResolver.ts | 15 ++ .../DefaultCommentRecipientResolver.test.ts | 76 ++++++++++ .../src/DefaultCommentRecipientResolver.ts | 19 +++ .../src/activityKind.ts | 9 ++ .../src/extensionPoints.ts | 16 ++ .../src/index.ts | 4 + .../src/module.test.ts | 138 +++++++++++++++++- .../src/module.ts | 25 +++- plugins/rw-node/src/CommentActivity.ts | 2 +- 12 files changed, 487 insertions(+), 24 deletions(-) create mode 100644 plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts create mode 100644 plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts create mode 100644 plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts create mode 100644 plugins/rw-backend-module-notifications/src/activityKind.ts create mode 100644 plugins/rw-backend-module-notifications/src/extensionPoints.ts diff --git a/plugins/rw-backend-module-notifications/README.md b/plugins/rw-backend-module-notifications/README.md index fb2b490..03135d1 100644 --- a/plugins/rw-backend-module-notifications/README.md +++ b/plugins/rw-backend-module-notifications/README.md @@ -94,6 +94,94 @@ The owner-side notification therefore reads as _"which docs need attention"_ (do realtime `new_notification` signal on every event even when it only updates an existing row, so the Web badge still blinks per event (one row, repeated signal). +### Customizing who gets notified + +By default a new top-level comment notifies the section's owning group (which Backstage expands +to all members) and a reply/resolve notifies the prior thread participants. To change **who** is +notified — for example, notify a list of entity *maintainers* (users) instead of the whole owning +group — register a `CommentRecipientResolver` from a sibling backend module. + +The resolver owns the recipient decision for every audience and returns the recipient entity refs +(users or groups; `[]` means notify nobody). Delegate the audiences you don't want to change to the +built-in `DefaultCommentRecipientResolver`, so you only write the branch you care about: + +```ts +// packages/backend/src/rwMaintainerRecipients.ts +import { createBackendModule, coreServices } from "@backstage/backend-plugin-api"; +import { catalogServiceRef } from "@backstage/plugin-catalog-node"; +import { + rwCommentRecipientExtensionPoint, + DefaultCommentRecipientResolver, + type CommentRecipientResolver, +} from "@rwdocs/backstage-plugin-rw-backend-module-notifications"; +import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +class MaintainerRecipientResolver implements CommentRecipientResolver { + private readonly base = new DefaultCommentRecipientResolver(); + + constructor(private readonly deps: { catalog: typeof catalogServiceRef.T; auth: typeof coreServices.auth.T }) {} + + getName(): string { + return "maintainer-recipients"; + } + + async resolveRecipients(activity: CommentActivity): Promise { + // Only override new top-level threads; replies/resolves keep the default participant policy. + const isNewThread = activity.action === "created" && activity.parentId === null; + if (!isNewThread || !activity.entityRef) return this.base.resolveRecipients(activity); + + // Read e.g. a `mycompany.com/maintainers` annotation off the owning entity and map it to + // user refs. Fall back to the default if there are none. + const credentials = await this.deps.auth.getOwnServiceCredentials(); + const entity = await this.deps.catalog.getEntityByRef(activity.entityRef, { credentials }); + const maintainers = (entity?.metadata.annotations?.["mycompany.com/maintainers"] ?? "") + .split(",") + .map(s => s.trim()) + .filter(Boolean) + .map(name => `user:default/${name}`); + return maintainers.length ? maintainers : this.base.resolveRecipients(activity); + } +} + +export default createBackendModule({ + pluginId: "rw", // MUST be "rw" — extension points are plugin-scoped + moduleId: "maintainer-recipients", + register(reg) { + reg.registerInit({ + deps: { + recipients: rwCommentRecipientExtensionPoint, + catalog: catalogServiceRef, + auth: coreServices.auth, + }, + async init({ recipients, catalog, auth }) { + recipients.setRecipientResolver(new MaintainerRecipientResolver({ catalog, auth })); + }, + }); + }, +}); +``` + +```ts +// packages/backend/src/index.ts — install alongside the notifications module +backend.add(import("@rwdocs/backstage-plugin-rw-backend-module-notifications")); +backend.add(import("./rwMaintainerRecipients")); +``` + +Notes: + +- **The notifications module must be installed.** `rwCommentRecipientExtensionPoint` is registered + by this module; a resolver module that depends on it will fail fast at backend startup if the + notifications module isn't installed. +- **Only one resolver may be registered** — a second `setRecipientResolver` throws at startup. +- **Recipients only.** The resolver cannot change the coalescing `scope` or the notification + `topic`; those stay tied to the activity kind (per-page for new threads, per-thread for + replies/resolves), so per-page burst-coalescing still applies even when you redirect new-thread + recipients. +- **Fail-closed.** If a resolver throws, the notification is discarded (logged at `error`) rather + than falling back to the default audience — a custom resolver exists to restrict the audience, so + a transient error must not leak the notification to the group you meant to exclude. +- The resolver wires its own `catalog`/`auth`; the notifications module gains no catalog dependency. + ### Optional: Slack delivery The official `@backstage/plugin-notifications-backend-module-slack` (a first-party diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts index ab6840b..556581a 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts @@ -189,4 +189,72 @@ describe("CommentNotifier", () => { expect(send).not.toHaveBeenCalled(); }); + + describe("with a custom recipient resolver", () => { + it("uses the resolver's recipients verbatim (default policy ignored)", async () => { + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => [ + "user:default/maintainer-a", + "user:default/maintainer-b", + ]), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await n.process(makeActivity()); // sectionOwnerRef is group:default/docs — must be ignored + + expect(custom.resolveRecipients).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].recipients).toEqual({ + type: "entity", + entityRef: ["user:default/maintainer-a", "user:default/maintainer-b"], + excludeEntityRef: "user:default/jane", + }); + // scope/topic still derive from the activity kind, not the recipients + expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:created"); + expect(send.mock.calls[0][0].payload.scope).toBe( + "rw:page:component:default/site|sec-1#guide", + ); + }); + + it("resolver returns [] → send NOT called", async () => { + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => [] as string[]), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await n.process(makeActivity()); + + expect(send).not.toHaveBeenCalled(); + }); + + it("resolver throws → discards (send NOT called), logs at error, never throws", async () => { + const boom = new Error("catalog down"); + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => { + throw boom; + }), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await expect(n.process(makeActivity())).resolves.toBeUndefined(); + + expect(send).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("custom")); + }); + }); }); diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts index 36c8a08..95dc1e2 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts @@ -3,20 +3,29 @@ import { parseEntityRef } from "@backstage/catalog-model"; import { NotificationService } from "@backstage/plugin-notifications-node"; import { buildCommentDeepLinkSuffix } from "@rwdocs/backstage-plugin-rw-common"; import { CommentActivity, CommentProcessor } from "@rwdocs/backstage-plugin-rw-node"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; +import { isNewThread } from "./activityKind"; /** CommentProcessor that turns a resolved CommentActivity into a native notification. - * Presentation + delivery only — no DB, events, or catalog. Computes recipients from the - * activity's raw owner/participants fields; the actor is forwarded as excludeEntityRef so the - * notifications resolver drops them after expanding groups (the single point of actor + * Presentation + delivery only — no DB, events, or catalog. Delegates recipient selection to a + * CommentRecipientResolver (custom-or-default); the actor is forwarded as excludeEntityRef so + * the notifications resolver drops them after expanding groups (the single point of actor * exclusion). The one catalog-path convention (`/catalog///`) is isolated here. * Best-effort: catches + logs on send failure, never throws. */ export class CommentNotifier implements CommentProcessor { private readonly notifications: NotificationService; private readonly logger: LoggerService; + private readonly recipientResolver: CommentRecipientResolver; - constructor(opts: { notifications: NotificationService; logger: LoggerService }) { + constructor(opts: { + notifications: NotificationService; + logger: LoggerService; + recipientResolver?: CommentRecipientResolver; + }) { this.notifications = opts.notifications; this.logger = opts.logger; + this.recipientResolver = opts.recipientResolver ?? new DefaultCommentRecipientResolver(); } getName(): string { @@ -25,24 +34,28 @@ export class CommentNotifier implements CommentProcessor { async process(comment: CommentActivity): Promise { let recipients: string[]; - let scope: string; - if (comment.action === "created" && comment.parentId === null) { - // Owner-side burst path: coalesce per-page so a hot doc collapses into one - // self-updating Web inbox row per recipient group, not one row per comment. - recipients = comment.sectionOwnerRef ? [comment.sectionOwnerRef] : []; - scope = `rw:page:${comment.siteRef}|${comment.pageRef}`; - } else { - // Participant-side (replies + resolves): per-thread, so each conversation keeps its - // own self-updating row rather than coalescing across threads on the same page. - recipients = comment.participants; - scope = `rw:comment:${comment.rootId}`; + try { + recipients = await this.recipientResolver.resolveRecipients(comment); + } catch (error) { + // Fail-closed: a custom resolver exists to change/restrict the audience, so never fall + // back to the broader default on error — discard rather than risk notifying the wrong people. + this.logger.error( + `rw.comments recipient resolver ${this.recipientResolver.getName()} threw; discarding notification (comment ${comment.commentId}): ${error}`, + ); + return; } if (recipients.length === 0) return; // nothing to notify + // Coalescing scope is a property of the activity kind, not the recipients: a new top-level + // thread coalesces per-page; replies/resolves coalesce per-thread. + const scope = isNewThread(comment) + ? `rw:page:${comment.siteRef}|${comment.pageRef}` + : `rw:comment:${comment.rootId}`; + try { await this.notifications.send({ - // Single point of actor exclusion: the resolver drops the actor from the resolved - // recipients, including from an expanded group. + // Single point of actor exclusion: Backstage's notification resolver drops the actor + // from the recipients via this field, including from an expanded group. recipients: { type: "entity", entityRef: recipients, @@ -82,7 +95,7 @@ export class CommentNotifier implements CommentProcessor { const s = this.subject(comment); const a = this.actor(comment); if (comment.action === "resolved") return `${a} resolved a thread on ${s}`; - if (comment.parentId === null) return `${a} commented on ${s}`; + if (isNewThread(comment)) return `${a} commented on ${s}`; return `${a} replied on ${s}`; } @@ -91,7 +104,7 @@ export class CommentNotifier implements CommentProcessor { * settings key hash). Mirrors the title() branch. */ private topic(comment: CommentActivity): string { if (comment.action === "resolved") return "comment:thread:resolved"; - if (comment.parentId === null) return "comment:thread:created"; + if (isNewThread(comment)) return "comment:thread:created"; return "comment:reply:created"; } diff --git a/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts b/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts new file mode 100644 index 0000000..0ca33c1 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts @@ -0,0 +1,15 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +/** Decides the notification recipients for a resolved comment activity. A single resolver owns + * the decision for every audience (new threads and replies/resolves); delegate the audiences you + * don't want to change to the built-in DefaultCommentRecipientResolver. Mirrors Backstage's + * NotificationRecipientResolver: a single, full-ownership resolver with no per-call defer + * sentinel. */ +export interface CommentRecipientResolver { + /** Stable name for logging. */ + getName(): string; + /** Recipient entity refs (users or groups; groups are expanded downstream by Backstage's + * notification resolver). Return [] to notify nobody. Should not throw — a throw discards the + * notification (fail-closed). */ + resolveRecipients(activity: CommentActivity): Promise; +} diff --git a/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts new file mode 100644 index 0000000..dce8f90 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts @@ -0,0 +1,76 @@ +import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; +import { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; + +function makeActivity(over: Partial = {}): CommentActivity { + return { + action: "created", + occurredAt: "2026-06-26T00:00:00.000Z", + commentId: "c1", + rootId: "c1", + parentId: null, + siteRef: "component:default/site", + sectionRef: "sec-1", + pageRef: "sec-1#guide", + actorRef: "user:default/jane", + actorName: "Jane Doe", + participants: ["user:default/jane"], + sectionOwnerRef: "group:default/docs", + entityRef: "component:default/my-docs", + pageTitle: "Guide", + sectionTitle: "Docs", + viewerPath: "guide", + bodySnippet: "hello", + ...over, + }; +} + +describe("DefaultCommentRecipientResolver", () => { + const resolver = new DefaultCommentRecipientResolver(); + + it("getName() returns 'rw-default-recipients'", () => { + expect(resolver.getName()).toBe("rw-default-recipients"); + }); + + it("new top-level thread → [sectionOwnerRef]", async () => { + await expect(resolver.resolveRecipients(makeActivity())).resolves.toEqual([ + "group:default/docs", + ]); + }); + + it("new top-level thread with null sectionOwnerRef → []", async () => { + await expect( + resolver.resolveRecipients(makeActivity({ sectionOwnerRef: null })), + ).resolves.toEqual([]); + }); + + it("reply (parentId set) → participants", async () => { + await expect( + resolver.resolveRecipients( + makeActivity({ + commentId: "c2", + parentId: "c1", + participants: ["user:default/jane", "user:default/bob"], + }), + ), + ).resolves.toEqual(["user:default/jane", "user:default/bob"]); + }); + + it("reply with empty participants → []", async () => { + await expect( + resolver.resolveRecipients(makeActivity({ parentId: "c1", participants: [] })), + ).resolves.toEqual([]); + }); + + it("resolve action → participants", async () => { + await expect( + resolver.resolveRecipients( + makeActivity({ + action: "resolved", + commentId: "c2", + rootId: "c1", + participants: ["user:default/jane", "user:default/bob"], + }), + ), + ).resolves.toEqual(["user:default/jane", "user:default/bob"]); + }); +}); diff --git a/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts new file mode 100644 index 0000000..5c27f95 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts @@ -0,0 +1,19 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { isNewThread } from "./activityKind"; + +/** The built-in recipient policy: a new top-level thread notifies the section's effective owner; + * a reply or resolve notifies the thread's prior participants. Stateless and never throws, so a + * custom resolver can construct one and delegate the audiences it doesn't override. */ +export class DefaultCommentRecipientResolver implements CommentRecipientResolver { + getName(): string { + return "rw-default-recipients"; + } + + async resolveRecipients(activity: CommentActivity): Promise { + if (isNewThread(activity)) { + return activity.sectionOwnerRef ? [activity.sectionOwnerRef] : []; + } + return activity.participants; + } +} diff --git a/plugins/rw-backend-module-notifications/src/activityKind.ts b/plugins/rw-backend-module-notifications/src/activityKind.ts new file mode 100644 index 0000000..04d9fe3 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/activityKind.ts @@ -0,0 +1,9 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +/** A "new thread" is a top-level comment creation (not a reply, not a resolve). The owner-side + * vs participant-side notification policy — recipients, coalescing scope, and topic — all branch + * on this single predicate, so it lives in one place to keep the notifier and the default + * resolver from drifting. */ +export function isNewThread(activity: CommentActivity): boolean { + return activity.action === "created" && activity.parentId === null; +} diff --git a/plugins/rw-backend-module-notifications/src/extensionPoints.ts b/plugins/rw-backend-module-notifications/src/extensionPoints.ts new file mode 100644 index 0000000..e7c1b5e --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/extensionPoints.ts @@ -0,0 +1,16 @@ +import { createExtensionPoint } from "@backstage/backend-plugin-api"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; + +export interface RwCommentRecipientExtensionPoint { + /** Replace the built-in comment-notification recipient policy. May only be called once. */ + setRecipientResolver(resolver: CommentRecipientResolver): void; +} + +/** Registered by the notifications module; a sibling backend module (same pluginId "rw") consumes + * it and calls setRecipientResolver in its init to override who is notified about doc comments. + * Single resolver — a second registration throws (mirrors Backstage's setNotificationRecipientResolver + * / Slack's setBlockKitRenderer). */ +export const rwCommentRecipientExtensionPoint = + createExtensionPoint({ + id: "rw.comment-recipients", + }); diff --git a/plugins/rw-backend-module-notifications/src/index.ts b/plugins/rw-backend-module-notifications/src/index.ts index f79b64f..6f5e02f 100644 --- a/plugins/rw-backend-module-notifications/src/index.ts +++ b/plugins/rw-backend-module-notifications/src/index.ts @@ -1 +1,5 @@ export { default } from "./module"; +export { rwCommentRecipientExtensionPoint } from "./extensionPoints"; +export type { RwCommentRecipientExtensionPoint } from "./extensionPoints"; +export type { CommentRecipientResolver } from "./CommentRecipientResolver"; +export { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; diff --git a/plugins/rw-backend-module-notifications/src/module.test.ts b/plugins/rw-backend-module-notifications/src/module.test.ts index 6d5c5b7..6f8c527 100644 --- a/plugins/rw-backend-module-notifications/src/module.test.ts +++ b/plugins/rw-backend-module-notifications/src/module.test.ts @@ -8,7 +8,11 @@ * notifications module, and asserts exactly one processor was registered with * the expected name. */ -import { createBackendPlugin, createServiceFactory } from "@backstage/backend-plugin-api"; +import { + createBackendModule, + createBackendPlugin, + createServiceFactory, +} from "@backstage/backend-plugin-api"; import { mockServices, startTestBackend } from "@backstage/backend-test-utils"; import { notificationService, @@ -22,6 +26,7 @@ import { // The module under test. import notificationsModule from "./module"; +import { rwCommentRecipientExtensionPoint } from "./extensionPoints"; describe("rw notifications module — startTestBackend extension-point capture", () => { it("registers exactly one processor named 'rw-comment-notifications'", async () => { @@ -98,3 +103,134 @@ describe("rw notifications module — startTestBackend extension-point capture", expect((notificationsModule as any).$$type).toBe("@backstage/BackendFeature"); }); }); + +describe("rw notifications module — recipient resolver extension point", () => { + function notificationFactoryWith( + send: jest.Mock, [Parameters[0]]>, + ) { + return createServiceFactory({ + service: notificationService, + deps: {}, + factory: () => ({ send }) as NotificationService, + }); + } + + // Host plugin that owns rwCommentProcessingExtensionPoint and captures registered processors. + function captureHost(capture: { processors: CommentProcessor[] }) { + return createBackendPlugin({ + pluginId: "rw", + register(env) { + const processors: CommentProcessor[] = []; + env.registerExtensionPoint(rwCommentProcessingExtensionPoint, { + addProcessor: (...ps) => { + processors.push(...ps.flat()); + }, + }); + env.registerInit({ + deps: {}, + async init() { + capture.processors = processors; + }, + }); + }, + }); + } + + it("a registered custom resolver overrides recipients", async () => { + const send = jest.fn, [Parameters[0]]>( + async () => undefined, + ); + const capture = { processors: [] as CommentProcessor[] }; + + const activity: CommentActivity = { + action: "created", + occurredAt: "2026-06-26T00:00:00.000Z", + commentId: "c1", + rootId: "c1", + parentId: null, + siteRef: "component:default/site", + sectionRef: "sec-1", + pageRef: "sec-1#guide", + actorRef: "user:default/jane", + actorName: "Jane Doe", + participants: ["user:default/jane"], + sectionOwnerRef: "group:default/docs", + entityRef: "component:default/my-docs", + pageTitle: "Guide", + sectionTitle: "Docs", + viewerPath: "guide", + bodySnippet: "hello", + }; + + // Sibling module (same pluginId "rw") that consumes the recipient extension point. + const companyModule = createBackendModule({ + pluginId: "rw", + moduleId: "company-recipients", + register(reg) { + reg.registerInit({ + deps: { recipients: rwCommentRecipientExtensionPoint }, + async init({ recipients }) { + recipients.setRecipientResolver({ + getName: () => "company", + resolveRecipients: async () => ["user:default/maintainer"], + }); + }, + }); + }, + }); + + await startTestBackend({ + features: [ + captureHost(capture), + notificationsModule, + companyModule, + notificationFactoryWith(send), + mockServices.rootLogger.factory({ level: "none" }), + ], + }); + + expect(capture.processors).toHaveLength(1); + await capture.processors[0].process(activity); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].recipients).toEqual({ + type: "entity", + entityRef: ["user:default/maintainer"], // not group:default/docs + excludeEntityRef: "user:default/jane", + }); + }); + + it("a second setRecipientResolver throws → backend startup fails", async () => { + const send = jest.fn, [Parameters[0]]>( + async () => undefined, + ); + const capture = { processors: [] as CommentProcessor[] }; + + const doubleRegisterModule = createBackendModule({ + pluginId: "rw", + moduleId: "double-register", + register(reg) { + reg.registerInit({ + deps: { recipients: rwCommentRecipientExtensionPoint }, + async init({ recipients }) { + const r = { getName: () => "x", resolveRecipients: async () => [] as string[] }; + recipients.setRecipientResolver(r); + recipients.setRecipientResolver(r); // second call must throw + }, + }); + }, + }); + + await expect( + startTestBackend({ + features: [ + captureHost(capture), + notificationsModule, + doubleRegisterModule, + notificationFactoryWith(send), + mockServices.rootLogger.factory({ level: "none" }), + ], + }), + ).rejects.toThrow(/already registered/); + }); +}); diff --git a/plugins/rw-backend-module-notifications/src/module.ts b/plugins/rw-backend-module-notifications/src/module.ts index 502f0e9..0cf3dec 100644 --- a/plugins/rw-backend-module-notifications/src/module.ts +++ b/plugins/rw-backend-module-notifications/src/module.ts @@ -2,15 +2,30 @@ import { coreServices, createBackendModule } from "@backstage/backend-plugin-api import { notificationService } from "@backstage/plugin-notifications-node"; import { rwCommentProcessingExtensionPoint } from "@rwdocs/backstage-plugin-rw-node"; import { CommentNotifier } from "./CommentNotifier"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { rwCommentRecipientExtensionPoint } from "./extensionPoints"; /** Opt-in backend module: registers a CommentProcessor on rw-backend's comment-processing * extension point and delivers native notifications. Installing this module is the opt-in; * omitting it means rw-backend resolves nothing extra. pluginId is `rw` (it augments the rw - * plugin). No DB, events, or catalog — it only formats a resolved CommentActivity and sends. */ + * plugin). No DB, events, or catalog — it only formats a resolved CommentActivity and sends. + * + * It also registers rwCommentRecipientExtensionPoint so a sibling `rw` module can replace who + * is notified (e.g. maintainers instead of the owning group). With none registered, the + * built-in DefaultCommentRecipientResolver is used. */ export default createBackendModule({ pluginId: "rw", moduleId: "notifications", register(env) { + let recipientResolver: CommentRecipientResolver | undefined; + env.registerExtensionPoint(rwCommentRecipientExtensionPoint, { + setRecipientResolver(resolver) { + if (recipientResolver) { + throw new Error("rw comment recipient resolver already registered"); + } + recipientResolver = resolver; + }, + }); env.registerInit({ deps: { logger: coreServices.logger, @@ -18,8 +33,12 @@ export default createBackendModule({ comments: rwCommentProcessingExtensionPoint, }, async init({ logger, notifications, comments }) { - comments.addProcessor(new CommentNotifier({ notifications, logger })); - logger.info("rw notifications module registered a comment processor"); + comments.addProcessor(new CommentNotifier({ notifications, logger, recipientResolver })); + logger.info( + `rw notifications module registered a comment processor${ + recipientResolver ? ` (custom recipient resolver: ${recipientResolver.getName()})` : "" + }`, + ); }, }); }, diff --git a/plugins/rw-node/src/CommentActivity.ts b/plugins/rw-node/src/CommentActivity.ts index b948853..5fdb3bb 100644 --- a/plugins/rw-node/src/CommentActivity.ts +++ b/plugins/rw-node/src/CommentActivity.ts @@ -16,7 +16,7 @@ export interface CommentActivity { actorRef: string; // the user who triggered the activity actorName: string; // resolved display name participants: string[]; // distinct author refs, creation order - sectionOwnerRef: string | null; // recipient for top-level creates + sectionOwnerRef: string | null; // the section's effective owner ref (null => no owner) entityRef: string | null; // deep-link target (null => no link) pageTitle: string | null; sectionTitle: string | null;