From 77bee9f0134b9107019f38ce9d52af4d59b362b0 Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 16:25:29 -0500 Subject: [PATCH 1/5] feat(scaffolder): Allow sorting by status in scaffolderService.listTasks. Added optional `status` filter to `ScaffolderService.listTasks`, by exposing the `status` query parameter, allowing callers to retrieve tasks of a specific status. Also updated the `list-scaffolder-tasks` action to support this parameter. Signed-off-by: John Collier --- .changeset/fifty-clubs-play.md | 5 ++ .changeset/gold-friends-end.md | 5 ++ .../actions/listScaffolderTasksAction.test.ts | 55 ++++++++++++++++++- .../src/actions/listScaffolderTasksAction.ts | 12 ++++ .../scaffolder-node/src/scaffolderService.ts | 5 ++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/fifty-clubs-play.md create mode 100644 .changeset/gold-friends-end.md diff --git a/.changeset/fifty-clubs-play.md b/.changeset/fifty-clubs-play.md new file mode 100644 index 0000000000..561e24a130 --- /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 paramter, 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..eddacadf19 --- /dev/null +++ b/.changeset/gold-friends-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +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..bad0b366b6 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,57 @@ describe('createListScaffolderTasksAction', () => { createdBy: 'user:default/alice', limit: undefined, offset: undefined, + status: undefined, }, expect.objectContaining({ credentials: expect.anything() }), ); }); + it('should filter tasks by 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 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..2b05b8aee1 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -65,6 +65,17 @@ Pagination is supported via limit and offset. .min(0) .describe('The offset to start from for pagination') .optional(), + status: z + .enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]) + .optional() + .describe('Filter tasks by status'), }), output: z => z @@ -112,6 +123,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/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts index b978164c7f..74d301b718 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; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; @@ -185,6 +186,7 @@ class DefaultScaffolderService implements ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { @@ -201,6 +203,9 @@ class DefaultScaffolderService implements ScaffolderService { if (request.offset !== undefined) { params.set('offset', String(request.offset)); } + if (request.status !== undefined) { + params.set('status', request.status); + } const query = params.toString(); const url = `${baseUrl}/v2/tasks${query ? `?${query}` : ''}`; From 2375ad9a832617ea3aad9009f0797e57b9fe233b Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 16:42:46 -0500 Subject: [PATCH 2/5] Generate api reports Signed-off-by: John Collier --- plugins/scaffolder-node/report-testUtils.api.md | 1 + plugins/scaffolder-node/report.api.md | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md index e4f9c55ee2..51bc0d0d5e 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; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index 54282f370d..b27c8c8cfe 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -391,6 +391,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ From f5fb2829f7f6c4fabf8f2a63e86a5d7cbc1425a5 Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 23:00:02 -0500 Subject: [PATCH 3/5] address review feedback Signed-off-by: John Collier --- .../actions/listScaffolderTasksAction.test.ts | 47 ++++++++++++++++++- .../src/actions/listScaffolderTasksAction.ts | 30 ++++++++---- .../scaffolder-node/report-testUtils.api.md | 2 +- plugins/scaffolder-node/report.api.md | 2 +- .../scaffolder-node/src/scaffolderService.ts | 8 ++-- 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts index bad0b366b6..20eac54cf8 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -199,7 +199,7 @@ describe('createListScaffolderTasksAction', () => { ); }); - it('should filter tasks by status when status is provided', async () => { + 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(); @@ -244,6 +244,51 @@ describe('createListScaffolderTasksAction', () => { }); }); + 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 2b05b8aee1..3f3a0eed04 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 => @@ -66,16 +66,28 @@ Pagination is supported via limit and offset. .describe('The offset to start from for pagination') .optional(), status: z - .enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', + .union([ + z.enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]), + z.array( + z.enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]), + ), ]) .optional() - .describe('Filter tasks by status'), + .describe('Filter tasks by status, or an array of statuses'), }), output: z => z diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md index 51bc0d0d5e..0dcbd460a2 100644 --- a/plugins/scaffolder-node/report-testUtils.api.md +++ b/plugins/scaffolder-node/report-testUtils.api.md @@ -80,7 +80,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index b27c8c8cfe..95d821a218 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -391,7 +391,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts index 74d301b718..cb3e8dd675 100644 --- a/plugins/scaffolder-node/src/scaffolderService.ts +++ b/plugins/scaffolder-node/src/scaffolderService.ts @@ -83,7 +83,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; @@ -186,7 +186,7 @@ class DefaultScaffolderService implements ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { @@ -204,7 +204,9 @@ class DefaultScaffolderService implements ScaffolderService { params.set('offset', String(request.offset)); } if (request.status !== undefined) { - params.set('status', request.status); + for (const s of [request.status].flat()) { + params.append('status', s); + } } const query = params.toString(); From 4445d88cff3ce374110c693553e2ca3aaef4ab29 Mon Sep 17 00:00:00 2001 From: John Collier Date: Thu, 5 Mar 2026 10:54:33 -0500 Subject: [PATCH 4/5] address pr review feedback Signed-off-by: John Collier --- .changeset/fifty-clubs-play.md | 2 +- .../src/actions/listScaffolderTasksAction.ts | 37 +++++------- .../src/scaffolderService.test.ts | 57 +++++++++++++++++++ 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/.changeset/fifty-clubs-play.md b/.changeset/fifty-clubs-play.md index 561e24a130..33d4378e69 100644 --- a/.changeset/fifty-clubs-play.md +++ b/.changeset/fifty-clubs-play.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': minor --- -Updated the `list-scaffolder-tasks` action to support the new "status" filter paramter, allowing the action to return tasks matching a specific status. +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/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts index 3f3a0eed04..b3374a0946 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -65,29 +65,20 @@ Filtering by one or multiple statuses is supported. Pagination is supported via .min(0) .describe('The offset to start from for pagination') .optional(), - status: z - .union([ - z.enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', - ]), - z.array( - z.enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', - ]), - ), - ]) - .optional() - .describe('Filter tasks by status, or an array of statuses'), + 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 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() }, + ); + }); }); From 504ebe49a0f3745fdbfdd3fd593199c7aff25e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 22 Apr 2026 10:27:27 +0200 Subject: [PATCH 5/5] Update .changeset/gold-friends-end.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/gold-friends-end.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/gold-friends-end.md b/.changeset/gold-friends-end.md index eddacadf19..ddbe4cee8a 100644 --- a/.changeset/gold-friends-end.md +++ b/.changeset/gold-friends-end.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-node': minor +'@backstage/plugin-scaffolder-node': patch --- Added optional `status` filter to `ScaffolderService.listTasks`, allowing callers to retrieve tasks matching a specific status.