feat(scaffolder): Create basic scaffolder task query action (#32989)

* feat(scaffolder): Create basic scaffolder task query action

Signed-off-by: John Collier <jcollier@redhat.com>

* Add changeset

Signed-off-by: John Collier <jcollier@redhat.com>

* Fix test

Signed-off-by: John Collier <jcollier@redhat.com>

* Address review feedback

Signed-off-by: John Collier <jcollier@redhat.com>

* Update plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: John Collier <jcollier@redhat.com>

* Update plugins/scaffolder-backend/src/actions/listScaffolderTaskAction.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: John Collier <jcollier@redhat.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: John Collier <jcollier@redhat.com>

* lint

Signed-off-by: John Collier <jcollier@redhat.com>

* fix conflict markers, remove unused discovery dep, add input validation bounds

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR feedback: fix status enum, add int validation, improve changeset

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: John Collier <jcollier@redhat.com>
Signed-off-by: benjdlambert <ben@blam.sh>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: benjdlambert <ben@blam.sh>
This commit is contained in:
John Collier
2026-03-03 03:38:09 -05:00
committed by GitHub
parent 7695dd23d0
commit c9b11eb0cf
5 changed files with 415 additions and 0 deletions
+5
View File
@@ -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
@@ -223,6 +223,7 @@ export const scaffolderPlugin = createBackendPlugin({
createScaffolderActions({
actionsRegistry: actionsRegistryService,
scaffolderService,
auth,
});
const router = await createRouter({
@@ -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);
};
@@ -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,
};
}
@@ -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,
},
};
},
});
};