Skip to content

Commit 31d6225

Browse files
authored
feat(announcements): register actions for announcements (#8538)
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
1 parent 674ef74 commit 31d6225

11 files changed

Lines changed: 1072 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@backstage-community/plugin-announcements-backend': patch
3+
---
4+
5+
Added actions for announcement management.
6+
7+
Registered four actions that expose the announcements backend via MCP and scaffolder templates:
8+
9+
- `announcements:list-announcements` — list announcements with optional filters for category, tags, active status, and pagination
10+
- `announcements:get-announcement` — fetch full details of a single announcement by ID
11+
- `announcements:create-announcement` — create a new announcement (requires `announcement.entity.create` permission)
12+
- `announcements:delete-announcement` — delete an announcement by ID (requires `announcement.entity.delete` permission)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
17+
import { NotAllowedError } from '@backstage/errors';
18+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
19+
import { DateTime } from 'luxon';
20+
import { createCreateAnnouncementAction } from './createCreateAnnouncementAction';
21+
import { AnnouncementsDatabase } from '../service/persistence/AnnouncementsDatabase';
22+
import { PersistenceContext } from '../service/persistence';
23+
24+
const nowIso = '2025-01-15T10:00:00.000Z';
25+
const now = DateTime.fromISO(nowIso);
26+
27+
const mockInsertedAnnouncement = {
28+
id: 'ann-new',
29+
title: 'My New Announcement',
30+
excerpt: 'An excerpt',
31+
body: 'Full body',
32+
publisher: 'user:default/alice',
33+
active: true,
34+
created_at: now,
35+
start_at: now,
36+
until_date: undefined,
37+
updated_at: now,
38+
category: undefined,
39+
on_behalf_of: undefined,
40+
sendNotification: false,
41+
};
42+
43+
describe('createCreateAnnouncementAction', () => {
44+
let mockActionsRegistry: { register: jest.Mock };
45+
let mockStore: jest.Mocked<AnnouncementsDatabase>;
46+
let mockPersistenceContext: PersistenceContext;
47+
let mockPermissions: { authorize: jest.Mock };
48+
49+
beforeEach(() => {
50+
mockActionsRegistry = { register: jest.fn() };
51+
52+
mockStore = {
53+
insertAnnouncement: jest.fn().mockResolvedValue(mockInsertedAnnouncement),
54+
} as any;
55+
56+
mockPersistenceContext = {
57+
announcementsStore: mockStore,
58+
} as any;
59+
60+
mockPermissions = {
61+
authorize: jest
62+
.fn()
63+
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]),
64+
};
65+
66+
createCreateAnnouncementAction({
67+
actionsRegistry: mockActionsRegistry as any,
68+
persistenceContext: mockPersistenceContext,
69+
permissions: mockPermissions as any,
70+
});
71+
});
72+
73+
it('registers the announcements:create-announcement action', () => {
74+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
75+
const reg = mockActionsRegistry.register.mock.calls[0][0];
76+
expect(reg.name).toBe('announcements:create-announcement');
77+
expect(reg.attributes.readOnly).toBe(false);
78+
expect(reg.attributes.destructive).toBe(false);
79+
expect(reg.attributes.idempotent).toBe(false);
80+
expect(reg.visibilityPermission).toBeDefined();
81+
expect(reg.visibilityPermission.name).toBe('announcement.entity.create');
82+
});
83+
84+
it('creates an announcement and returns its details', async () => {
85+
const reg = mockActionsRegistry.register.mock.calls[0][0];
86+
const credentials = mockCredentials.user();
87+
88+
const result = await reg.action({
89+
input: {
90+
title: 'My New Announcement',
91+
excerpt: 'An excerpt',
92+
body: 'Full body',
93+
publisher: 'user:default/alice',
94+
active: true,
95+
start_at: nowIso,
96+
sendNotification: false,
97+
},
98+
credentials,
99+
logger: mockServices.logger.mock(),
100+
});
101+
102+
expect(mockPermissions.authorize).toHaveBeenCalledWith(
103+
[
104+
expect.objectContaining({
105+
permission: expect.objectContaining({
106+
name: 'announcement.entity.create',
107+
}),
108+
}),
109+
],
110+
{ credentials },
111+
);
112+
expect(mockStore.insertAnnouncement).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
title: 'My New Announcement',
115+
publisher: 'user:default/alice',
116+
}),
117+
);
118+
expect(result.output.id).toBe('ann-new');
119+
expect(result.output.title).toBe('My New Announcement');
120+
expect(result.output.created_at).toBe(nowIso);
121+
});
122+
123+
it('throws NotAllowedError when permission is denied', async () => {
124+
mockPermissions.authorize.mockResolvedValue([
125+
{ result: AuthorizeResult.DENY },
126+
]);
127+
const reg = mockActionsRegistry.register.mock.calls[0][0];
128+
const credentials = mockCredentials.user();
129+
130+
await expect(
131+
reg.action({
132+
input: {
133+
title: 'Unauthorized',
134+
excerpt: 'excerpt',
135+
body: 'body',
136+
publisher: 'user:default/alice',
137+
active: true,
138+
start_at: nowIso,
139+
sendNotification: false,
140+
},
141+
credentials,
142+
logger: mockServices.logger.mock(),
143+
}),
144+
).rejects.toThrow(NotAllowedError);
145+
});
146+
});
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
17+
import { PermissionsService } from '@backstage/backend-plugin-api';
18+
import { NotAllowedError } from '@backstage/errors';
19+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
20+
import { announcementEntityPermissions } from '@backstage-community/plugin-announcements-common';
21+
import { DateTime } from 'luxon';
22+
import { v4 as uuid } from 'uuid';
23+
import { PersistenceContext } from '../service/persistence';
24+
25+
const { announcementCreatePermission } = announcementEntityPermissions;
26+
27+
/**
28+
* Registers the `announcements:create-announcement` action.
29+
* @internal
30+
*/
31+
export function createCreateAnnouncementAction(options: {
32+
actionsRegistry: ActionsRegistryService;
33+
persistenceContext: PersistenceContext;
34+
permissions: PermissionsService;
35+
}) {
36+
const { actionsRegistry, persistenceContext, permissions } = options;
37+
38+
actionsRegistry.register({
39+
name: 'announcements:create-announcement',
40+
title: 'Create Announcement',
41+
description:
42+
'Create a new announcement. Requires the announcement.entity.create permission.',
43+
attributes: {
44+
readOnly: false,
45+
destructive: false,
46+
idempotent: false,
47+
},
48+
visibilityPermission: announcementCreatePermission,
49+
schema: {
50+
input: z =>
51+
z.object({
52+
title: z.string().describe('Title of the announcement'),
53+
excerpt: z.string().describe('Short summary shown in listing views'),
54+
body: z.string().describe('Full body text of the announcement'),
55+
publisher: z
56+
.string()
57+
.describe('User or team publishing the announcement'),
58+
active: z
59+
.boolean()
60+
.default(true)
61+
.describe('Whether the announcement is active'),
62+
start_at: z
63+
.string()
64+
.describe('ISO 8601 datetime when the announcement becomes active'),
65+
until_date: z
66+
.string()
67+
.optional()
68+
.describe(
69+
'ISO 8601 datetime when the announcement expires (optional)',
70+
),
71+
category: z
72+
.string()
73+
.optional()
74+
.describe('Category slug to assign to the announcement'),
75+
on_behalf_of: z
76+
.string()
77+
.optional()
78+
.describe('Optional entity ref the announcement is on behalf of'),
79+
tags: z
80+
.array(z.string())
81+
.optional()
82+
.describe('Tag slugs to attach to the announcement'),
83+
sendNotification: z
84+
.boolean()
85+
.default(false)
86+
.describe(
87+
'Whether to send a notification when the announcement is created',
88+
),
89+
}),
90+
output: z =>
91+
z.object({
92+
id: z.string(),
93+
title: z.string(),
94+
excerpt: z.string(),
95+
body: z.string(),
96+
publisher: z.string(),
97+
active: z.boolean(),
98+
created_at: z.string(),
99+
start_at: z.string(),
100+
until_date: z.string().nullable().optional(),
101+
updated_at: z.string(),
102+
category: z
103+
.object({ slug: z.string(), title: z.string() })
104+
.optional(),
105+
on_behalf_of: z.string().optional(),
106+
}),
107+
},
108+
async action({ input, credentials }) {
109+
const decision = await permissions.authorize(
110+
[{ permission: announcementCreatePermission }],
111+
{ credentials },
112+
);
113+
114+
if (decision[0].result === AuthorizeResult.DENY) {
115+
throw new NotAllowedError(
116+
'Unauthorized: missing announcement.entity.create permission',
117+
);
118+
}
119+
120+
const now = DateTime.now();
121+
const startAt = DateTime.fromISO(input.start_at);
122+
const untilDate = input.until_date
123+
? DateTime.fromISO(input.until_date)
124+
: undefined;
125+
126+
const announcement =
127+
await persistenceContext.announcementsStore.insertAnnouncement({
128+
id: uuid(),
129+
title: input.title,
130+
excerpt: input.excerpt,
131+
body: input.body,
132+
publisher: input.publisher,
133+
active: input.active,
134+
sendNotification: input.sendNotification,
135+
category: input.category,
136+
on_behalf_of: input.on_behalf_of,
137+
tags: input.tags,
138+
created_at: now,
139+
start_at: startAt,
140+
until_date: untilDate,
141+
updated_at: now,
142+
});
143+
144+
return {
145+
output: {
146+
id: announcement.id,
147+
title: announcement.title,
148+
excerpt: announcement.excerpt,
149+
body: announcement.body,
150+
publisher: announcement.publisher,
151+
active: announcement.active,
152+
created_at: announcement.created_at.toUTC().toISO()!,
153+
start_at: announcement.start_at.toUTC().toISO()!,
154+
until_date: announcement.until_date
155+
? announcement.until_date.toUTC().toISO()
156+
: undefined,
157+
updated_at: announcement.updated_at.toUTC().toISO()!,
158+
category: announcement.category,
159+
on_behalf_of: announcement.on_behalf_of,
160+
},
161+
};
162+
},
163+
});
164+
}

0 commit comments

Comments
 (0)