diff --git a/.changeset/fifty-clubs-play.md b/.changeset/fifty-clubs-play.md new file mode 100644 index 0000000000..33d4378e69 --- /dev/null +++ b/.changeset/fifty-clubs-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Updated the `list-scaffolder-tasks` action to support the new "status" filter parameter, allowing the action to return tasks matching a specific status. diff --git a/.changeset/gold-friends-end.md b/.changeset/gold-friends-end.md new file mode 100644 index 0000000000..ddbe4cee8a --- /dev/null +++ b/.changeset/gold-friends-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': patch +--- + +Added optional `status` filter to `ScaffolderService.listTasks`, allowing callers to retrieve tasks matching a specific status. diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts index 075ba919f1..20eac54cf8 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -63,7 +63,12 @@ describe('createListScaffolderTasksAction', () => { totalTasks: mockTasks.totalTasks ?? 0, }); expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( - { createdBy: undefined, limit: undefined, offset: undefined }, + { + createdBy: undefined, + limit: undefined, + offset: undefined, + status: undefined, + }, expect.objectContaining({ credentials: expect.anything() }), ); }); @@ -106,7 +111,7 @@ describe('createListScaffolderTasksAction', () => { }); expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( - { createdBy: undefined, limit: 2, offset: 1 }, + { createdBy: undefined, limit: 2, offset: 1, status: undefined }, expect.objectContaining({ credentials: expect.anything() }), ); @@ -188,11 +193,102 @@ describe('createListScaffolderTasksAction', () => { createdBy: 'user:default/alice', limit: undefined, offset: undefined, + status: undefined, }, expect.objectContaining({ credentials: expect.anything() }), ); }); + it('should filter tasks by a single status when status is provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const completedTasks = generateMockTasks().tasks.filter( + t => t.status === 'completed', + ); + + mockScaffolderService.listTasks.mockResolvedValue({ + items: completedTasks as ScaffolderTask[], + totalItems: completedTasks.length, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { status: 'completed' }, + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { + createdBy: undefined, + limit: undefined, + offset: undefined, + status: 'completed', + }, + expect.objectContaining({ credentials: expect.anything() }), + ); + expect(result.output).toEqual({ + tasks: completedTasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })), + totalTasks: completedTasks.length, + }); + }); + + it('should filter tasks by multiple statuses when an array is provided', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockAuth = mockServices.auth.mock(); + const mockScaffolderService = scaffolderServiceMock.mock(); + const matchingTasks = generateMockTasks().tasks.filter( + t => t.status === 'completed' || t.status === 'failed', + ); + + mockScaffolderService.listTasks.mockResolvedValue({ + items: matchingTasks as ScaffolderTask[], + totalItems: matchingTasks.length, + }); + + createListScaffolderTasksAction({ + actionsRegistry: mockActionsRegistry, + auth: mockAuth, + scaffolderService: mockScaffolderService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-scaffolder-tasks', + input: { status: ['completed', 'failed'] }, + }); + + expect(mockScaffolderService.listTasks).toHaveBeenCalledWith( + { + createdBy: undefined, + limit: undefined, + offset: undefined, + status: ['completed', 'failed'], + }, + expect.objectContaining({ credentials: expect.anything() }), + ); + expect(result.output).toEqual({ + tasks: matchingTasks.map(task => ({ + id: task.id, + spec: task.spec, + status: task.status, + createdAt: task.createdAt, + lastHeartbeatAt: task.lastHeartbeatAt, + })), + totalTasks: matchingTasks.length, + }); + }); + it('should throw NotAllowedError when owned is true without user identity', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockAuth = mockServices.auth.mock(); diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts index 70977c9d12..b3374a0946 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -40,7 +40,7 @@ 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. +Filtering by one or multiple statuses is supported. Pagination is supported via limit and offset. `, schema: { input: z => @@ -65,6 +65,20 @@ Pagination is supported via limit and offset. .min(0) .describe('The offset to start from for pagination') .optional(), + status: (() => { + const statusEnum = z.enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]); + return z + .union([statusEnum, z.array(statusEnum).nonempty()]) + .optional() + .describe('Filter tasks by status, or an array of statuses'); + })(), }), output: z => z @@ -112,6 +126,7 @@ Pagination is supported via limit and offset. createdBy, limit: input.limit, offset: input.offset, + status: input.status, }, { credentials }, ); diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md index e4f9c55ee2..0dcbd460a2 100644 --- a/plugins/scaffolder-node/report-testUtils.api.md +++ b/plugins/scaffolder-node/report-testUtils.api.md @@ -80,6 +80,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index 538f19cd1e..f2e3d7119b 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -406,6 +406,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/src/scaffolderService.test.ts b/plugins/scaffolder-node/src/scaffolderService.test.ts index de0870751f..f9b0d85f32 100644 --- a/plugins/scaffolder-node/src/scaffolderService.test.ts +++ b/plugins/scaffolder-node/src/scaffolderService.test.ts @@ -202,4 +202,61 @@ describe('scaffolderServiceRef', () => { expect(result).toEqual({ items: [], totalItems: 0 }); }); + + it('should serialize a single status as a repeated query param for listTasks', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => { + expect(req.url.searchParams.getAll('status')).toEqual(['completed']); + return res(ctx.json({ tasks: [], totalTasks: 0 })); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.listTasks( + { status: 'completed' }, + { credentials: mockCredentials.user() }, + ); + }); + + it('should serialize multiple statuses as repeated query params for listTasks', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => { + expect(req.url.searchParams.getAll('status')).toEqual([ + 'completed', + 'failed', + ]); + return res(ctx.json({ tasks: [], totalTasks: 0 })); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.listTasks( + { status: ['completed', 'failed'] }, + { credentials: mockCredentials.user() }, + ); + }); }); diff --git a/plugins/scaffolder-node/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts index b978164c7f..cb3e8dd675 100644 --- a/plugins/scaffolder-node/src/scaffolderService.ts +++ b/plugins/scaffolder-node/src/scaffolderService.ts @@ -83,6 +83,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; @@ -185,6 +186,7 @@ class DefaultScaffolderService implements ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { @@ -201,6 +203,11 @@ class DefaultScaffolderService implements ScaffolderService { if (request.offset !== undefined) { params.set('offset', String(request.offset)); } + if (request.status !== undefined) { + for (const s of [request.status].flat()) { + params.append('status', s); + } + } const query = params.toString(); const url = `${baseUrl}/v2/tasks${query ? `?${query}` : ''}`;