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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Backstage plugins for embedding RW documentation sites. Yarn 4.12.0 workspace monorepo with five packages:
Backstage plugins for embedding RW documentation sites. Yarn 4.12.0 workspace monorepo with six packages:

- **`@rwdocs/backstage-plugin-rw`** (frontend) — Renders RW docs in Backstage UI via `@rwdocs/viewer`
- **`@rwdocs/backstage-plugin-rw-backend`** (backend) — Express-based API serving docs via `@rwdocs/core`
- **`@rwdocs/backstage-plugin-search-backend-module-rw`** (search) — Indexes RW documentation for Backstage search via a collator module
- **`@rwdocs/backstage-plugin-rw-common`** (common) — Shared utilities: entity path construction, annotation parsing, S3 config reading
- **`@rwdocs/backstage-plugin-rw-backend-module-notifications`** (notifications) — Opt-in backend module: subscribes to `rw.comments` events and delivers doc-comment notifications via the native notifications plugin
- **`@rwdocs/backstage-plugin-rw-node`** (node-library) — Knex-free shared library: the comment-processing extension point (`rwCommentProcessingExtensionPoint`) plus the `CommentProcessor` / `CommentActivity` types. rw-backend registers the extension point and pushes a resolved `CommentActivity`; the notifications module registers a `CommentProcessor`. No `@rwdocs/core`, no DB.
- **`@rwdocs/backstage-plugin-rw-backend-module-notifications`** (notifications) — Opt-in backend module: registers a `CommentProcessor` on rw-backend's comment-processing extension point that formats a resolved `CommentActivity` and delivers doc-comment notifications via the native notifications plugin. No DB, events, or catalog.

## Commands

Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ backend.add(import('@backstage/plugin-search-backend-module-catalog'));
backend.add(import('@rwdocs/backstage-plugin-rw-backend'));
backend.add(import('@rwdocs/backstage-plugin-search-backend-module-rw'));
// Doc-comment notifications: the native notifications + signals backends, plus
// the opt-in rw notifications module that turns rw.comments events into
// notifications. (eventsServiceRef ships in-process with backend-defaults.)
// the opt-in rw notifications module that registers a CommentProcessor on rw-backend's
// comment-processing extension point.
backend.add(import('@backstage/plugin-notifications-backend'));
backend.add(import('@backstage/plugin-signals-backend'));
backend.add(import('@rwdocs/backstage-plugin-rw-backend-module-notifications'));
Expand Down
25 changes: 14 additions & 11 deletions plugins/rw-backend-module-notifications/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# @rwdocs/backstage-plugin-rw-backend-module-notifications

Opt-in backend module that delivers **doc-comment notifications**. It subscribes to the
`rw.comments` domain events published by `@rwdocs/backstage-plugin-rw-backend` and sends
native Backstage notifications:
Opt-in backend module that delivers **doc-comment notifications**. It registers a
`CommentProcessor` on `@rwdocs/backstage-plugin-rw-backend`'s comment-processing extension
point (`rwCommentProcessingExtensionPoint`); rw-backend resolves each comment action into a
`CommentActivity` and hands it to the processor, which sends native Backstage notifications:

- **Owner-side:** a new top-level comment on docs owned by a group notifies that group.
- **Commenter-side:** a reply to a thread, or resolution of a thread, notifies all prior
Expand All @@ -24,13 +25,15 @@ backend.add(import('@backstage/plugin-signals-backend')); // real-time updates
// packages/app/src/App.tsx — add the notifications + signals frontend plugins
```

### Events
### Same-backend requirement

`rw-backend` publishes `rw.comments` via the core `eventsServiceRef`, which ships in-process
with `backend-defaults` — **no extra package is required** for single-backend deployments.
Install `@backstage/plugin-events-backend` only if your backend runs as multiple instances
and you need cross-process event distribution.
(rw-backend's publish and this module's subscription must run in the same backend process for the in-process events service to route between them — the default single-backend setup.)
This module is wired to rw-backend through an **in-process extension point**, not an event
bus. The processor must therefore run in the **same backend process** as
`@rwdocs/backstage-plugin-rw-backend` (which registers `rwCommentProcessingExtensionPoint`).
There is no `@backstage/plugin-events-backend` dependency and nothing crosses a process
boundary, so no extra package is needed — but a multi-process deployment that splits this
module out from rw-backend would leave its extension-point dependency unsatisfied and is not
supported.

### Notification topics

Expand Down Expand Up @@ -75,5 +78,5 @@ are cleaned up by `notifications.retention` (default `1y`).
### Notes

- Notification links resolve into the entity's RW docs tab at the comment anchor.
- If you don't install this module, `rw-backend` still publishes events harmlessly and
commenting is unaffected.
- If you don't install this module, rw-backend simply has no comment processor registered;
commenting is unaffected and no notifications are sent.
5 changes: 2 additions & 3 deletions plugins/rw-backend-module-notifications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,17 @@
},
"dependencies": {
"@backstage/catalog-model": "^1.7.6",
"@rwdocs/backstage-plugin-rw-common": "workspace:^"
"@rwdocs/backstage-plugin-rw-common": "workspace:^",
"@rwdocs/backstage-plugin-rw-node": "workspace:^"
},
"peerDependencies": {
"@backstage/backend-plugin-api": "^1.0.0",
"@backstage/plugin-events-node": "^0.4.23",
"@backstage/plugin-notifications-node": "^0.2.27"
},
"devDependencies": {
"@backstage/backend-plugin-api": "^1.0.0",
"@backstage/backend-test-utils": "^1.11.0",
"@backstage/cli": "^0.36.0",
"@backstage/plugin-events-node": "^0.4.23",
"@backstage/plugin-notifications-node": "^0.2.27",
"@types/jest": "^30.0.0",
"jest": "^30.2.0",
Expand Down
224 changes: 110 additions & 114 deletions plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts
Original file line number Diff line number Diff line change
@@ -1,173 +1,169 @@
import { mockServices } from "@backstage/backend-test-utils";
import { CommentEventPayload } from "@rwdocs/backstage-plugin-rw-common";
import { type NotificationService } from "@backstage/plugin-notifications-node";
import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node";
import { CommentNotifier } from "./CommentNotifier";

function payload(over: Partial<CommentEventPayload>): CommentEventPayload {
function makeActivity(over: Partial<CommentActivity> = {}): CommentActivity {
return {
kind: "created",
audience: "owner",
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/setup",
actorRef: "user:default/alice",
actorName: "Alice Smith",
pageTitle: "Setup Guide",
sectionTitle: "My Service",
recipients: ["group:default/team"],
entityRef: "component:default/site",
deepLinkSuffix: "/docs/guide/setup#comment-c1",
bodySnippet: "please review",
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("CommentNotifier", () => {
const logger = mockServices.logger.mock();
let send: jest.Mock<Promise<void>, [Parameters<NotificationService["send"]>[0]]>;
let notifier: CommentNotifier;

it("owner/created → '<actor> commented on <page> · <area>'", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
beforeEach(() => {
send = jest.fn<Promise<void>, [Parameters<NotificationService["send"]>[0]]>(
async () => undefined,
);
notifier = new CommentNotifier({ notifications: { send } as any, logger });
});

await notifier.handle(payload({}));
it("top-level create → recipients from sectionOwnerRef, topic:thread:created, title, link", async () => {
await notifier.process(makeActivity());

expect(send).toHaveBeenCalledTimes(1);
const arg = send.mock.calls[0][0];
// The recipient is a group and the actor is forwarded as excludeEntityRef: this is what
// lets Backstage's resolver drop a group-owning actor from the expanded set (so a member
// commenting on their own entity is not self-notified).
expect(arg.recipients).toEqual({
type: "entity",
entityRef: ["group:default/team"],
excludeEntityRef: "user:default/alice",
entityRef: ["group:default/docs"],
excludeEntityRef: "user:default/jane",
});
expect(arg.payload.title).toBe("Alice Smith commented on Setup Guide · My Service");
expect(arg.payload.description).toBe("please review");
expect(arg.payload.link).toBe("/catalog/default/component/site/docs/guide/setup#comment-c1");
expect(arg.payload.topic).toBe("comment:thread:created");
expect(arg.payload.title).toBe("Jane Doe commented on Guide · Docs");
expect(arg.payload.description).toBe("hello");
expect(arg.payload.scope).toBe("rw:comment:c1");
expect(arg.payload.severity).toBe("normal");
expect(arg.payload.link).toBe("/catalog/default/component/my-docs/docs/guide#comment-c1");
});

it("drops a payload with no actorRef without sending (exclusion would be a no-op)", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ actorRef: "" }));
expect(send).not.toHaveBeenCalled();
});

it("participants/created → '<actor> replied on <subject>'", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(
payload({ audience: "participants", parentId: "c1", recipients: ["user:default/bob"] }),
it("reply create → recipients from participants, topic:reply:created, title 'replied on'", async () => {
await notifier.process(
makeActivity({
parentId: "c1",
participants: ["user:default/jane", "user:default/bob"],
}),
);
expect(send.mock.calls[0][0].payload.title).toBe(
"Alice Smith replied on Setup Guide · My Service",
);
});

it("resolved → '<actor> resolved a thread on <subject>', description prefixed Re:", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(
payload({ kind: "resolved", audience: "participants", recipients: ["user:default/alice"] }),
expect(send).toHaveBeenCalledTimes(1);
const arg = send.mock.calls[0][0];
expect(arg.recipients).toEqual({
type: "entity",
entityRef: ["user:default/jane", "user:default/bob"],
excludeEntityRef: "user:default/jane",
});
expect(arg.payload.topic).toBe("comment:reply:created");
expect(arg.payload.title).toBe("Jane Doe replied on Guide · Docs");
});

it("resolve → recipients from participants, topic:thread:resolved, title 'resolved a thread', description prefixed Re:", async () => {
await notifier.process(
makeActivity({
action: "resolved",
rootId: "c1",
participants: ["user:default/jane", "user:default/bob"],
bodySnippet: "all set",
}),
);
const p = send.mock.calls[0][0].payload;
expect(p.title).toBe("Alice Smith resolved a thread on Setup Guide · My Service");
expect(p.description).toBe("Re: please review");

expect(send).toHaveBeenCalledTimes(1);
const arg = send.mock.calls[0][0];
expect(arg.recipients).toEqual({
type: "entity",
entityRef: ["user:default/jane", "user:default/bob"],
excludeEntityRef: "user:default/jane",
});
expect(arg.payload.topic).toBe("comment:thread:resolved");
expect(arg.payload.title).toBe("Jane Doe resolved a thread on Guide · Docs");
expect(arg.payload.description).toBe("Re: all set");
});

it("subject dedup: page === area → uses page only (no · suffix)", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
// When comment is on the section root (subpath ""), pageTitle === sectionTitle → shown once.
await notifier.handle(payload({ pageTitle: "Биллинг", sectionTitle: "Биллинг" }));
expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on Биллинг");
it("null entityRef → link: undefined", async () => {
await notifier.process(makeActivity({ entityRef: null }));

expect(send.mock.calls[0][0].payload.link).toBeUndefined();
});

it("subject: page !== area → 'page · area' format", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
// When comment is on a sub-page: pageTitle="ADRs", sectionTitle="Биллинг" (section root).
await notifier.handle(payload({ pageTitle: "ADRs", sectionTitle: "Биллинг" }));
expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on ADRs · Биллинг");
it("subject dedup: pageTitle === sectionTitle → title contains 'on Guide' (no · suffix)", async () => {
await notifier.process(makeActivity({ pageTitle: "Guide", sectionTitle: "Guide" }));

expect(send.mock.calls[0][0].payload.title).toBe("Jane Doe commented on Guide");
});

it("subject fallback: both null → 'the docs'", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ pageTitle: null, sectionTitle: null }));
expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on the docs");
await notifier.process(makeActivity({ pageTitle: null, sectionTitle: null }));

expect(send.mock.calls[0][0].payload.title).toBe("Jane Doe commented on the docs");
});

it("actor fallback: empty actorName → 'Someone'", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ actorName: "" }));
expect(send.mock.calls[0][0].payload.title).toBe(
"Someone commented on Setup Guide · My Service",
);
});
await notifier.process(makeActivity({ actorName: "" }));

it("omits the link when entityRef is null (degraded)", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ entityRef: null }));
expect(send.mock.calls[0][0].payload.link).toBeUndefined();
expect(send.mock.calls[0][0].payload.title).toBe("Someone commented on Guide · Docs");
});

it("never throws when send rejects", async () => {
const send = jest.fn().mockRejectedValue(new Error("notifications down"));
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await expect(notifier.handle(payload({}))).resolves.toBeUndefined();
});
it("empty recipients (top-level create with sectionOwnerRef:null) → send NOT called", async () => {
await notifier.process(makeActivity({ sectionOwnerRef: null }));

it("drops a payload with empty recipients without sending", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ recipients: [] }));
expect(send).not.toHaveBeenCalled();
});

it("drops a malformed payload (recipients not an array) without sending", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ recipients: undefined as any }));
expect(send).not.toHaveBeenCalled();
});
it("never throws when send rejects (best-effort)", async () => {
send.mockRejectedValue(new Error("notifications down"));

it("drops a malformed payload (unknown kind) without sending", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({ kind: "deleted" as any }));
expect(send).not.toHaveBeenCalled();
await expect(notifier.process(makeActivity())).resolves.toBeUndefined();
});

it("topic: owner/created → comment:thread:created", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(payload({}));
expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:created");
it("getName() returns 'rw-comment-notifications'", () => {
expect(notifier.getName()).toBe("rw-comment-notifications");
});

it("topic: participants/created (reply) → comment:reply:created", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(
payload({ audience: "participants", parentId: "c1", recipients: ["user:default/bob"] }),
it("top-level create: section owner IS the actor → still sends with owner as recipient (excluded at delivery)", async () => {
// Guards against a filter like `sectionOwnerRef !== actorRef` that would suppress
// notifications when the owner posts to their own section. Exclusion is left entirely
// to the delivery layer (excludeEntityRef); the processor always emits.
await notifier.process(
makeActivity({
actorRef: "user:default/jane",
sectionOwnerRef: "user:default/jane",
}),
);
expect(send.mock.calls[0][0].payload.topic).toBe("comment:reply:created");

expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0].recipients).toEqual({
type: "entity",
entityRef: ["user:default/jane"],
excludeEntityRef: "user:default/jane",
});
});

it("topic: resolved → comment:thread:resolved", async () => {
const send = jest.fn().mockResolvedValue(undefined);
const notifier = new CommentNotifier({ notifications: { send } as any, logger });
await notifier.handle(
payload({ kind: "resolved", audience: "participants", recipients: ["user:default/alice"] }),
it("reply with empty participants → send NOT called", async () => {
await notifier.process(
makeActivity({
parentId: "c1",
participants: [],
}),
);
expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:resolved");

expect(send).not.toHaveBeenCalled();
});
});
Loading