Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions plugins/rw-backend-module-notifications/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
});
});
51 changes: 32 additions & 19 deletions plugins/rw-backend-module-notifications/src/CommentNotifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ns>/<kind>/<name>`) 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 {
Expand All @@ -25,24 +34,28 @@ export class CommentNotifier implements CommentProcessor {

async process(comment: CommentActivity): Promise<void> {
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,
Expand Down Expand Up @@ -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}`;
}

Expand All @@ -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";
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string[]>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node";
import { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver";

function makeActivity(over: Partial<CommentActivity> = {}): 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"]);
});
});
Original file line number Diff line number Diff line change
@@ -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<string[]> {
if (isNewThread(activity)) {
return activity.sectionOwnerRef ? [activity.sectionOwnerRef] : [];
}
return activity.participants;
}
}
9 changes: 9 additions & 0 deletions plugins/rw-backend-module-notifications/src/activityKind.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading