Merge pull request #33122 from johnmcollier/filterstatustasks

feat(scaffolder): Allow filtering by status in scaffolderService.listTasks
This commit is contained in:
Fredrik Adelöw
2026-04-22 10:27:36 +02:00
committed by GitHub
8 changed files with 190 additions and 3 deletions
+5
View File
@@ -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.
+5
View File
@@ -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.
@@ -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();
@@ -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 },
);
@@ -80,6 +80,7 @@ export interface ScaffolderService {
createdBy?: string;
limit?: number;
offset?: number;
status?: ScaffolderTaskStatus | ScaffolderTaskStatus[];
},
options: ScaffolderServiceRequestOptions,
): Promise<{
+1
View File
@@ -406,6 +406,7 @@ export interface ScaffolderService {
createdBy?: string;
limit?: number;
offset?: number;
status?: ScaffolderTaskStatus | ScaffolderTaskStatus[];
},
options: ScaffolderServiceRequestOptions,
): Promise<{
@@ -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<void>({ 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<void>({ id: 'unused-dummy' }),
deps: {},
factory() {},
}),
{ dependencies: [mockServices.discovery.factory()] },
);
const scaffolder = await tester.getService(scaffolderServiceRef);
await scaffolder.listTasks(
{ status: ['completed', 'failed'] },
{ credentials: mockCredentials.user() },
);
});
});
@@ -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}` : ''}`;