diff --git a/.changeset/tangy-clouds-repeat.md b/.changeset/tangy-clouds-repeat.md new file mode 100644 index 0000000000..0999198c21 --- /dev/null +++ b/.changeset/tangy-clouds-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new `list-scaffolder-actions` action that returns all installed scaffolder actions with their schemas and examples diff --git a/app-config.yaml b/app-config.yaml index 08cd0b693c..e88955a39f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -68,6 +68,7 @@ backend: actions: pluginSources: - catalog + - scaffolder # See README.md in the proxy-backend plugin for information on the configuration format proxy: endpoints: diff --git a/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts new file mode 100644 index 0000000000..145e3deeed --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; +import type { ListActionsResponse } from '@backstage/plugin-scaffolder-common'; + +type ListActionsOutput = { + actions: Array<{ + id: string; + description: string; + schema: { input: object; output: object }; + examples: Array<{ description: string; example: string }>; + }>; +}; + +describe('createListScaffolderActionsAction', () => { + it('should list all scaffolder actions sorted by id with full properties', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue(createMockActions()); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toHaveLength(3); + + const actionIds = output.actions.map(a => a.id); + expect(actionIds).toEqual([ + 'catalog:register', + 'debug:log', + 'fetch:template', + ]); + + expect(output.actions[0]).toEqual({ + id: 'catalog:register', + description: 'Registers entities in the catalog', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [{ description: 'Basic usage', example: 'register entity' }], + }); + }); + + it('should handle actions without descriptions, schemas, or examples', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue([ + { id: 'minimal-action' }, + ]); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toHaveLength(1); + expect(output.actions[0]).toEqual({ + id: 'minimal-action', + description: '', + schema: { input: {}, output: {} }, + examples: [], + }); + }); + + it('should return empty array when no actions are registered', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockResolvedValue([]); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }); + + const output = result.output as ListActionsOutput; + expect(output.actions).toEqual([]); + }); + + it('should propagate errors from the scaffolder service', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + mockScaffolderService.listActions.mockRejectedValue( + new Error('Service unavailable'), + ); + + createListScaffolderActionsAction({ + actionsRegistry: mockActionsRegistry, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-actions', + input: {}, + }), + ).rejects.toThrow('Service unavailable'); + }); +}); + +function createMockActions(): ListActionsResponse { + return [ + { + id: 'fetch:template', + description: 'Fetches a template', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [], + }, + { + id: 'catalog:register', + description: 'Registers entities in the catalog', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [{ description: 'Basic usage', example: 'register entity' }], + }, + { + id: 'debug:log', + description: 'Logs debug information', + schema: { + input: { type: 'object' }, + output: { type: 'object' }, + }, + examples: [], + }, + ]; +} diff --git a/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts new file mode 100644 index 0000000000..4450207e16 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/createListScaffolderActionsAction.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; + +export const createListScaffolderActionsAction = ({ + actionsRegistry, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'list-scaffolder-actions', + title: 'List Scaffolder Actions', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: `Lists all installed Scaffolder actions. +Each action includes: +- id: The action identifier +- description: What the action does +- schema: Input and output JSON schemas +- examples: Usage examples when available`, + schema: { + input: z => z.object({}).describe('No input is required'), + output: z => + z.object({ + actions: z.array( + z.object({ + id: z.string(), + description: z.string(), + schema: z.object({ + input: z + .object({}) + .passthrough() + .describe('JSON Schema for input of Action'), + output: z + .object({}) + .passthrough() + .describe('JSON Schema for output of Action'), + }), + examples: z.array(z.any()).optional(), + }), + ), + }), + }, + action: async ({ credentials }) => { + const actions = await scaffolderService.listActions(undefined, { + credentials, + }); + const scaffolderActions = actions.map(action => ({ + id: action.id, + description: action.description ?? '', + schema: { + input: { ...(action.schema?.input ?? {}) }, + output: { ...(action.schema?.output ?? {}) }, + }, + examples: action.examples ?? [], + })); + return { + output: { + actions: scaffolderActions.sort((a, b) => a.id.localeCompare(b.id)), + }, + }; + }, + }); +}; diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 8539ae5b78..94721e94ad 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -16,10 +16,12 @@ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; +import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; scaffolderService: ScaffolderService; }) => { createDryRunTemplateAction(options); + createListScaffolderActionsAction(options); };