From c9b11eb0cf56d759eed5762dede9e93a13728c0d Mon Sep 17 00:00:00 2001 From: John Collier Date: Tue, 3 Mar 2026 03:38:09 -0500 Subject: [PATCH] feat(scaffolder): Create basic scaffolder task query action (#32989) * feat(scaffolder): Create basic scaffolder task query action Signed-off-by: John Collier * Add changeset Signed-off-by: John Collier * Fix test Signed-off-by: John Collier * Address review feedback Signed-off-by: John Collier * Update plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * Update plugins/scaffolder-backend/src/actions/listScaffolderTaskAction.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: John Collier * lint Signed-off-by: John Collier * fix conflict markers, remove unused discovery dep, add input validation bounds Signed-off-by: benjdlambert * address PR feedback: fix status enum, add int validation, improve changeset Signed-off-by: benjdlambert --------- Signed-off-by: John Collier Signed-off-by: benjdlambert Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: benjdlambert --- .changeset/free-pigs-itch.md | 5 + .../src/ScaffolderPlugin.ts | 1 + .../scaffolder-backend/src/actions/index.ts | 8 + .../actions/listScaffolderTasksAction.test.ts | 268 ++++++++++++++++++ .../src/actions/listScaffolderTasksAction.ts | 133 +++++++++ 5 files changed, 415 insertions(+) create mode 100644 .changeset/free-pigs-itch.md create mode 100644 plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts create mode 100644 plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts diff --git a/.changeset/free-pigs-itch.md b/.changeset/free-pigs-itch.md new file mode 100644 index 0000000000..c1e22f686e --- /dev/null +++ b/.changeset/free-pigs-itch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Added a new `list-scaffolder-tasks` action that allows querying scaffolder tasks with optional ownership filtering and pagination support diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index 6fdbb3e269..17997da485 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -223,6 +223,7 @@ export const scaffolderPlugin = createBackendPlugin({ createScaffolderActions({ actionsRegistry: actionsRegistryService, scaffolderService, + auth, }); const router = await createRouter({ diff --git a/plugins/scaffolder-backend/src/actions/index.ts b/plugins/scaffolder-backend/src/actions/index.ts index 94721e94ad..4beb39d832 100644 --- a/plugins/scaffolder-backend/src/actions/index.ts +++ b/plugins/scaffolder-backend/src/actions/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { AuthService } from '@backstage/backend-plugin-api'; +import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; import { createDryRunTemplateAction } from './createDryRunTemplateAction'; import { createListScaffolderActionsAction } from './createListScaffolderActionsAction'; @@ -21,7 +23,13 @@ import { createListScaffolderActionsAction } from './createListScaffolderActions export const createScaffolderActions = (options: { actionsRegistry: ActionsRegistryService; scaffolderService: ScaffolderService; + auth: AuthService; }) => { + createListScaffolderTasksAction({ + actionsRegistry: options.actionsRegistry, + auth: options.auth, + scaffolderService: options.scaffolderService, + }); createDryRunTemplateAction(options); createListScaffolderActionsAction(options); }; diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts new file mode 100644 index 0000000000..075ba919f1 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2025 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 { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { mockServices, mockCredentials } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { ScaffolderTask } from '@backstage/plugin-scaffolder-common'; +import { scaffolderServiceMock } from '@backstage/plugin-scaffolder-node/testUtils'; +import { createListScaffolderTasksAction } from './listScaffolderTasksAction'; +import { ListTasksResponse } from '../schema/openapi/generated/models/ListTasksResponse.model'; + +describe('createListScaffolderTasksAction', () => { + it('should list tasks successfully', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const mockTasks = generateMockTasks(); + + mockScaffolderService.listTasks.mockResolvedValue({ + items: mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })) as ScaffolderTask[], + totalItems: mockTasks.totalTasks ?? 0, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: {}, + }); + + const expectedTasks: ScaffolderTask[] = mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })); + + expect(result.output).toEqual({ + tasks: expectedTasks, + totalTasks: mockTasks.totalTasks ?? 0, + }); + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { createdBy: undefined, limit: undefined, offset: undefined }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should pass limit and offset through to the API and return paginated results', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const paginatedTasks = [ + { + id: 'task-2', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + }, + { + id: 'task-3', + spec: {}, + status: 'processing', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + }, + ]; + + mockScaffolderService.listTasks.mockResolvedValue({ + items: paginatedTasks as ScaffolderTask[], + totalItems: 10, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { limit: 2, offset: 1 }, + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { createdBy: undefined, limit: 2, offset: 1 }, + expect.objectContaining({ credentials: expect.anything() }), + ); + + const expectedTasks = paginatedTasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })); + + expect(result.output).toEqual({ + tasks: expectedTasks, + totalTasks: 10, + }); + }); + + it('should throw an error if the service call fails', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockScaffolderService.listTasks.mockRejectedValue( + new Error('Internal Server Error'), + ); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: {}, + }), + ).rejects.toThrow('Internal Server Error'); + }); + + it('should use createdBy filter when owned is true with user identity', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const mockTasks = generateMockTasks(); + + mockAuth.isPrincipal.mockImplementation( + (creds, type) => + type === 'user' && + (creds?.principal as { type?: string })?.type === 'user' && + typeof (creds.principal as { userEntityRef?: string }).userEntityRef === + 'string', + ); + mockScaffolderService.listTasks.mockResolvedValue({ + items: mockTasks.tasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })) as ScaffolderTask[], + totalItems: mockTasks.totalTasks ?? 0, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { owned: true }, + credentials: mockCredentials.user('user:default/alice'), + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { + createdBy: 'user:default/alice', + limit: undefined, + offset: undefined, + }, + expect.objectContaining({ credentials: expect.anything() }), + ); + }); + + it('should throw NotAllowedError when owned is true without user identity', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + + mockAuth.isPrincipal.mockReturnValue(false); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { owned: true }, + credentials: mockCredentials.service(), + }), + ).rejects.toThrow(NotAllowedError); + + expect(mockScaffolderService.listTasks).not.toHaveBeenCalled(); + }); +}); + +// Return a mocked ListTasksResponse that contains a number of different mocked tasks +function generateMockTasks(): ListTasksResponse { + return { + tasks: [ + { + id: 'task-1', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + createdBy: 'user:default/guest', + }, + { + id: 'task-2', + spec: {}, + status: 'completed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:01:00Z', + createdBy: 'user:default/guest', + }, + { + id: 'task-3', + spec: {}, + status: 'processing', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + { + id: 'task-4', + spec: {}, + status: 'failed', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + { + id: 'task-5', + spec: {}, + status: 'cancelled', + createdAt: '2025-01-01T00:00:00Z', + lastHeartbeatAt: '2025-01-01T00:02:00Z', + createdBy: 'user:default/admin', + }, + ], + totalTasks: 5, + }; +} diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts new file mode 100644 index 0000000000..70977c9d12 --- /dev/null +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2025 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 { AuthService } from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { ScaffolderService } from '@backstage/plugin-scaffolder-node'; + +export const createListScaffolderTasksAction = ({ + actionsRegistry, + auth, + scaffolderService, +}: { + actionsRegistry: ActionsRegistryService; + auth: AuthService; + scaffolderService: ScaffolderService; +}) => { + actionsRegistry.register({ + name: 'list-scaffolder-tasks', + title: 'List Scaffolder Tasks', + attributes: { + destructive: false, + readOnly: true, + idempotent: true, + }, + description: ` +This allows you to list scaffolder tasks that have been created. +Each task has a unique id, specification, and status (one of open, processing, completed, failed, cancelled, skipped). +Each task includes a timestamp for when it was created, and an optional last heartbeat timestamp indicating the most recent activity. +Set owned to true to return only tasks created by the current user; omit or set to false for all tasks the credentials can see. +Pagination is supported via limit and offset. + `, + schema: { + input: z => + z.object({ + owned: z + .boolean() + .optional() + .default(false) + .describe( + 'If true, return only tasks created by the current user. Requires a user identity.', + ), + limit: z + .number() + .int() + .min(1) + .max(1000) + .describe('The maximum number of tasks to return for pagination') + .optional(), + offset: z + .number() + .int() + .min(0) + .describe('The offset to start from for pagination') + .optional(), + }), + output: z => + z + .object({ + tasks: z + .array( + z.object({ + id: z.string().describe('The task identifier'), + spec: z.unknown().describe('The task specification'), + status: z + .string() + .describe( + 'Task status: open, processing, completed, failed, cancelled, or skipped', + ), + createdAt: z + .string() + .describe('Timestamp when the task was created'), + lastHeartbeatAt: z + .string() + .optional() + .describe('Timestamp of the last heartbeat'), + }), + ) + .describe('The list of scaffolder tasks'), + totalTasks: z + .number() + .describe('Total number of tasks matching the filter'), + }) + .describe('Object containing a tasks array and totalTasks count'), + }, + action: async ({ input, credentials }) => { + if (input.owned && !auth.isPrincipal(credentials, 'user')) { + throw new NotAllowedError( + 'Filtering by owned tasks requires a user identity.', + ); + } + + const createdBy = + input.owned && auth.isPrincipal(credentials, 'user') + ? credentials.principal.userEntityRef + : undefined; + + const { items, totalItems } = await scaffolderService.listTasks( + { + createdBy, + limit: input.limit, + offset: input.offset, + }, + { credentials }, + ); + + return { + output: { + tasks: items.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })), + totalTasks: totalItems, + }, + }; + }, + }); +};