Merge pull request #11154 from angeliski/add-tasks-owner

Add tasks owner
This commit is contained in:
Ben Lambert
2022-06-01 11:16:37 +02:00
committed by GitHub
36 changed files with 1364 additions and 31 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
- Added an optional `list` method on the `TaskBroker` and `TaskStore` interface to list tasks by an optional `userEntityRef`
- Implemented a `list` method on the `DatabaseTaskStore` class to list tasks by an optional `userEntityRef`
- Added a route under `/v2/tasks` to list tasks by a `userEntityRef` using the `createdBy` query parameter
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder': minor
---
- Added a new page under `/create/tasks` to show tasks that have been run by the Scaffolder.
- Ability to filter these tasks by the signed in user, and all tasks.
- Added optional method to the `ScaffolderApi` interface called `listTasks` to get tasks with an required `filterByOwnership` parameter.
+12
View File
@@ -406,6 +406,10 @@ export class DatabaseTaskStore implements TaskStore {
// (undocumented)
heartbeatTask(taskId: string): Promise<void>;
// (undocumented)
list(options: { createdBy?: string }): Promise<{
tasks: SerializedTask[];
}>;
// (undocumented)
listEvents({ taskId, after }: TaskStoreListEventsOptions): Promise<{
events: SerializedTaskEvent[];
}>;
@@ -530,6 +534,10 @@ export interface TaskBroker {
// (undocumented)
get(taskId: string): Promise<SerializedTask>;
// (undocumented)
list?(options?: { createdBy?: string }): Promise<{
tasks: SerializedTask[];
}>;
// (undocumented)
vacuumTasks(options: { timeoutS: number }): Promise<void>;
}
@@ -629,6 +637,10 @@ export interface TaskStore {
// (undocumented)
heartbeatTask(taskId: string): Promise<void>;
// (undocumented)
list?(options: { createdBy?: string }): Promise<{
tasks: SerializedTask[];
}>;
// (undocumented)
listEvents({ taskId, after }: TaskStoreListEventsOptions): Promise<{
events: SerializedTaskEvent[];
}>;
@@ -64,6 +64,14 @@ export type DatabaseTaskStoreOptions = {
database: Knex;
};
const parseSqlDateToIsoString = <T>(input: T): T | string => {
if (typeof input === 'string') {
return DateTime.fromSQL(input, { zone: 'UTC' }).toISO();
}
return input;
};
/**
* DatabaseTaskStore
*
@@ -85,6 +93,31 @@ export class DatabaseTaskStore implements TaskStore {
this.db = options.database;
}
async list(options: {
createdBy?: string;
}): Promise<{ tasks: SerializedTask[] }> {
const queryBuilder = this.db<RawDbTaskRow>('tasks');
if (options.createdBy) {
queryBuilder.where({
created_by: options.createdBy,
});
}
const results = await queryBuilder.orderBy('created_at', 'desc').select();
const tasks = results.map(result => ({
id: result.id,
spec: JSON.parse(result.spec),
status: result.status,
createdBy: result.created_by ?? undefined,
lastHeartbeatAt: parseSqlDateToIsoString(result.last_heartbeat_at),
createdAt: parseSqlDateToIsoString(result.created_at),
}));
return { tasks };
}
async getTask(taskId: string): Promise<SerializedTask> {
const [result] = await this.db<RawDbTaskRow>('tasks')
.where({ id: taskId })
@@ -99,8 +132,8 @@ export class DatabaseTaskStore implements TaskStore {
id: result.id,
spec,
status: result.status,
lastHeartbeatAt: result.last_heartbeat_at,
createdAt: result.created_at,
lastHeartbeatAt: parseSqlDateToIsoString(result.last_heartbeat_at),
createdAt: parseSqlDateToIsoString(result.created_at),
createdBy: result.created_by ?? undefined,
secrets,
};
@@ -292,10 +325,7 @@ export class DatabaseTaskStore implements TaskStore {
taskId,
body,
type: event.event_type,
createdAt:
typeof event.created_at === 'string'
? DateTime.fromSQL(event.created_at, { zone: 'UTC' }).toISO()
: event.created_at,
createdAt: parseSqlDateToIsoString(event.created_at),
};
} catch (error) {
throw new Error(
@@ -202,4 +202,31 @@ describe('StorageTaskBroker', () => {
expect(task.done).toBe(true);
});
it('should list all tasks', async () => {
const broker = new StorageTaskBroker(storage, logger);
const { taskId } = await broker.dispatch({ spec: {} as TaskSpec });
const promise = broker.list();
await expect(promise).resolves.toEqual({
tasks: expect.arrayContaining([
expect.objectContaining({
id: taskId,
}),
]),
});
});
it('should list only tasks createdBy a specific user', async () => {
const broker = new StorageTaskBroker(storage, logger);
const { taskId } = await broker.dispatch({
spec: {} as TaskSpec,
createdBy: 'user:default/foo',
});
const task = await storage.getTask(taskId);
const promise = broker.list({ createdBy: 'user:default/foo' });
await expect(promise).resolves.toEqual({ tasks: [task] });
});
});
@@ -150,6 +150,18 @@ export class StorageTaskBroker implements TaskBroker {
private readonly storage: TaskStore,
private readonly logger: Logger,
) {}
async list(options?: {
createdBy?: string;
}): Promise<{ tasks: SerializedTask[] }> {
if (!this.storage.list) {
throw new Error(
'TaskStore does not implement the list method. Please implement the list method to be able to list tasks',
);
}
return await this.storage.list({ createdBy: options?.createdBy });
}
private deferredDispatch = defer();
/**
@@ -133,6 +133,7 @@ export interface TaskBroker {
after: number | undefined;
}): Observable<{ events: SerializedTaskEvent[] }>;
get(taskId: string): Promise<SerializedTask>;
list?(options?: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
}
/**
@@ -193,6 +194,7 @@ export interface TaskStore {
listStaleTasks(options: { timeoutS: number }): Promise<{
tasks: { taskId: string }[];
}>;
list?(options: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
emitLogEvent({ taskId, body }: TaskStoreEmitOptions): Promise<void>;
listEvents({
@@ -131,6 +131,7 @@ describe('createRouter', () => {
jest.spyOn(taskBroker, 'dispatch');
jest.spyOn(taskBroker, 'get');
jest.spyOn(taskBroker, 'list');
jest.spyOn(taskBroker, 'event$');
const router = await createRouter({
@@ -216,23 +217,18 @@ describe('createRouter', () => {
const mockToken =
'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob';
console.log(
JSON.stringify(
await request(app)
.post('/v2/tasks')
.set('Authorization', `Bearer ${mockToken}`)
.send({
templateRef: stringifyEntityRef({
kind: 'template',
name: 'create-react-app-template',
}),
values: {
required: 'required-value',
},
}),
),
);
await request(app)
.post('/v2/tasks')
.set('Authorization', `Bearer ${mockToken}`)
.send({
templateRef: stringifyEntityRef({
kind: 'template',
name: 'create-react-app-template',
}),
values: {
required: 'required-value',
},
});
expect(broker).toHaveBeenCalledWith(
expect.objectContaining({
createdBy: 'user:default/guest',
@@ -294,6 +290,77 @@ describe('createRouter', () => {
});
});
describe('GET /v2/tasks', () => {
it('return all tasks', async () => {
(
taskBroker.list as jest.Mocked<Required<TaskBroker>>['list']
).mockResolvedValue({
tasks: [
{
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
createdBy: '',
},
],
});
const response = await request(app).get(`/v2/tasks`);
expect(taskBroker.list).toBeCalledWith({
createdBy: undefined,
});
expect(response.status).toEqual(200);
expect(response.body).toStrictEqual({
tasks: [
{
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
createdBy: '',
},
],
});
});
it('return filtered tasks', async () => {
(
taskBroker.list as jest.Mocked<Required<TaskBroker>>['list']
).mockResolvedValue({
tasks: [
{
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
createdBy: 'user:default/foo',
},
],
});
const response = await request(app).get(
`/v2/tasks?createdBy=user:default/foo`,
);
expect(taskBroker.list).toBeCalledWith({
createdBy: 'user:default/foo',
});
expect(response.status).toEqual(200);
expect(response.body).toStrictEqual({
tasks: [
{
id: 'a-random-id',
spec: {} as any,
status: 'completed',
createdAt: '',
createdBy: 'user:default/foo',
},
],
});
});
});
describe('GET /v2/tasks/:taskId', () => {
it('does not divulge secrets', async () => {
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
@@ -302,6 +369,7 @@ describe('createRouter', () => {
status: 'completed',
createdAt: '',
secrets: { backstageToken: 'secret' },
createdBy: '',
});
const response = await request(app).get(`/v2/tasks/a-random-id`);
@@ -252,6 +252,28 @@ export async function createRouter(
res.status(201).json({ id: result.taskId });
})
.get('/v2/tasks', async (req, res) => {
const [userEntityRef] = [req.query.createdBy].flat();
if (
typeof userEntityRef !== 'string' &&
typeof userEntityRef !== 'undefined'
) {
throw new InputError('createdBy query parameter must be a string');
}
if (!taskBroker.list) {
throw new Error(
'TaskBroker does not support listing tasks, please implement the list method on the TaskBroker.',
);
}
const tasks = await taskBroker.list({
createdBy: userEntityRef,
});
res.status(200).json(tasks);
})
.get('/v2/tasks/:taskId', async (req, res) => {
const { taskId } = req.params;
const task = await taskBroker.get(taskId);
+14
View File
@@ -16,6 +16,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { FetchApi } from '@backstage/core-plugin-api';
import { FieldProps } from '@rjsf/core';
import { FieldValidation } from '@rjsf/core';
import { IdentityApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSONSchema7 } from 'json-schema';
import { JsonValue } from '@backstage/types';
@@ -240,6 +241,14 @@ export interface ScaffolderApi {
templateRef: string,
): Promise<TemplateParameterSchema>;
listActions(): Promise<ListActionsResponse>;
// (undocumented)
listTasks?({
filterByOwnership,
}: {
filterByOwnership: 'owned' | 'all';
}): Promise<{
tasks: ScaffolderTask[];
}>;
scaffold(
options: ScaffolderScaffoldOptions,
): Promise<ScaffolderScaffoldResponse>;
@@ -255,6 +264,7 @@ export class ScaffolderClient implements ScaffolderApi {
constructor(options: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
identityApi?: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
useLongPollingLogs?: boolean;
});
@@ -272,6 +282,10 @@ export class ScaffolderClient implements ScaffolderApi {
): Promise<TemplateParameterSchema>;
// (undocumented)
listActions(): Promise<ListActionsResponse>;
// (undocumented)
listTasks(options: { filterByOwnership: 'owned' | 'all' }): Promise<{
tasks: ScaffolderTask[];
}>;
scaffold(
options: ScaffolderScaffoldOptions,
): Promise<ScaffolderScaffoldResponse>;
+13 -3
View File
@@ -25,7 +25,11 @@ import {
import React from 'react';
import { scaffolderApiRef, ScaffolderClient } from '../src';
import { ScaffolderPage } from '../src/plugin';
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
import {
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { CatalogEntityPage } from '@backstage/plugin-catalog';
createDevApp()
@@ -49,9 +53,15 @@ createDevApp()
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
scmIntegrationsApi: scmIntegrationsApiRef,
identityApi: identityApiRef,
},
factory: ({ discoveryApi, fetchApi, scmIntegrationsApi }) =>
new ScaffolderClient({ discoveryApi, fetchApi, scmIntegrationsApi }),
factory: ({ discoveryApi, fetchApi, scmIntegrationsApi, identityApi }) =>
new ScaffolderClient({
discoveryApi,
fetchApi,
scmIntegrationsApi,
identityApi,
}),
})
.addPage({
path: '/create',
+1
View File
@@ -57,6 +57,7 @@
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^13.0.0",
"@rjsf/core": "^3.2.1",
"@material-table/core": "^3.1.0",
"@rjsf/material-ui": "^3.2.1",
"@types/json-schema": "^7.0.9",
"@uiw/react-codemirror": "^4.7.0",
+86
View File
@@ -33,6 +33,13 @@ describe('api', () => {
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
const fetchApi = new MockFetchApi();
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
const scmIntegrationsApi = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
@@ -51,7 +58,11 @@ describe('api', () => {
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
});
jest.restoreAllMocks();
identityApi.getBackstageIdentity.mockReturnValue({});
});
it('should return default and custom integrations', async () => {
@@ -129,6 +140,7 @@ describe('api', () => {
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
useLongPollingLogs: true,
});
});
@@ -305,4 +317,78 @@ describe('api', () => {
});
});
});
describe('listTasks', () => {
it('should list all tasks', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json([
{
createdBy,
},
]),
);
}
return res(
ctx.json([
{
createdBy: null,
},
{
createdBy: null,
},
]),
);
}),
);
const result = await apiClient.listTasks({ filterByOwnership: 'all' });
expect(result).toHaveLength(2);
});
it('should list task using the current user as owner', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json({
tasks: [
{
createdBy,
},
],
}),
);
}
return res(
ctx.json({
tasks: [
{
createdBy: null,
},
{
createdBy: null,
},
],
}),
);
}),
);
identityApi.getBackstageIdentity.mockResolvedValueOnce({
userEntityRef: 'user:default/foo',
});
const result = await apiClient.listTasks({ filterByOwnership: 'owned' });
expect(identityApi.getBackstageIdentity).toBeCalled();
expect(result.tasks).toHaveLength(1);
});
});
});
+28
View File
@@ -19,6 +19,7 @@ import {
createApiRef,
DiscoveryApi,
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -39,6 +40,7 @@ import {
ScaffolderDryRunOptions,
ScaffolderDryRunResponse,
} from './types';
import queryString from 'qs';
/**
* Utility API reference for the {@link ScaffolderApi}.
@@ -58,11 +60,13 @@ export class ScaffolderClient implements ScaffolderApi {
private readonly discoveryApi: DiscoveryApi;
private readonly scmIntegrationsApi: ScmIntegrationRegistry;
private readonly fetchApi: FetchApi;
private readonly identityApi?: IdentityApi;
private readonly useLongPollingLogs: boolean;
constructor(options: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
identityApi?: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
useLongPollingLogs?: boolean;
}) {
@@ -70,6 +74,30 @@ export class ScaffolderClient implements ScaffolderApi {
this.fetchApi = options.fetchApi ?? { fetch };
this.scmIntegrationsApi = options.scmIntegrationsApi;
this.useLongPollingLogs = options.useLongPollingLogs ?? false;
this.identityApi = options.identityApi;
}
async listTasks(options: {
filterByOwnership: 'owned' | 'all';
}): Promise<{ tasks: ScaffolderTask[] }> {
if (!this.identityApi) {
throw new Error(
'IdentityApi is not available in the ScaffolderClient, please pass through the IdentityApi to the ScaffolderClient constructor in order to use the listTasks method',
);
}
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
const query = queryString.stringify(
options.filterByOwnership === 'owned' ? { createdBy: userEntityRef } : {},
);
const response = await this.fetchApi.fetch(`${baseUrl}/v2/tasks?${query}`);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return await response.json();
}
async getIntegrationsList(
@@ -28,6 +28,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]);
@@ -0,0 +1,249 @@
/*
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { identityApiRef } from '@backstage/core-plugin-api';
import { ListTasksPage } from './ListTasksPage';
import { ScaffolderApi } from '../../types';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { act, fireEvent } from '@testing-library/react';
describe('<ListTasksPage />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
const scaffolderApiMock: jest.Mocked<Required<ScaffolderApi>> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
listTasks: jest.fn(),
} as any;
it('should render the page', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'service',
metadata: {
name: 'test',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
scaffolderApiMock.listTasks.mockResolvedValue({ tasks: [] });
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
expect(getByText('List template tasks')).toBeInTheDocument();
expect(getByText('All tasks that have been started')).toBeInTheDocument();
expect(getByText('Tasks')).toBeInTheDocument();
});
it('should render the task I am owner', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'foo',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
scaffolderApiMock.listTasks.mockResolvedValue({
tasks: [
{
id: 'a-random-id',
spec: {
user: { ref: 'user:default/foo' },
templateInfo: {
entityRef: 'template:default/test',
},
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
],
});
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText, findByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
expect(scaffolderApiMock.listTasks).toBeCalledWith({
filterByOwnership: 'owned',
});
expect(getByText('List template tasks')).toBeInTheDocument();
expect(getByText('All tasks that have been started')).toBeInTheDocument();
expect(getByText('Tasks')).toBeInTheDocument();
expect(await findByText('One Template')).toBeInTheDocument();
expect(await findByText('BackUser')).toBeInTheDocument();
});
it('should render all tasks', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'foo',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef
.mockResolvedValue(entity)
.mockResolvedValue(entity)
.mockResolvedValue({
...entity,
spec: {
profile: {
displayName: 'OtherUser',
},
},
});
scaffolderApiMock.listTasks
.mockResolvedValue({
tasks: [
{
id: 'a-random-id',
spec: {
user: { ref: 'user:default/foo' },
templateInfo: {
entityRef: 'template:default/mock',
},
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
],
})
.mockResolvedValue({
tasks: [
{
id: 'b-random-id',
spec: {
templateInfo: {
entityRef: 'template:default/mock',
},
user: {
ref: 'user:default/boo',
},
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
],
});
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText, findByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
await act(async () => {
const allButton = await getByText('All');
fireEvent.click(allButton);
});
expect(scaffolderApiMock.listTasks).toBeCalledWith({
filterByOwnership: 'all',
});
expect(await findByText('One Template')).toBeInTheDocument();
expect(await findByText('OtherUser')).toBeInTheDocument();
});
});
@@ -0,0 +1,151 @@
/*
* Copyright 2022 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 {
Content,
EmptyState,
ErrorPanel,
Header,
Lifecycle,
Link,
Page,
Progress,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { CatalogFilterLayout } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
import MaterialTable from '@material-table/core';
import React, { useState } from 'react';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { OwnerListPicker } from './OwnerListPicker';
import {
CreatedAtColumn,
OwnerEntityColumn,
TaskStatusColumn,
TemplateTitleColumn,
} from './columns';
export interface MyTaskPageProps {
initiallySelectedFilter?: 'owned' | 'all';
}
const ListTaskPageContent = (props: MyTaskPageProps) => {
const { initiallySelectedFilter = 'owned' } = props;
const scaffolderApi = useApi(scaffolderApiRef);
const rootLink = useRouteRef(rootRouteRef);
const [ownerFilter, setOwnerFilter] = useState(initiallySelectedFilter);
const { value, loading, error } = useAsync(() => {
if (scaffolderApi.listTasks) {
return scaffolderApi.listTasks?.({ filterByOwnership: ownerFilter });
}
// eslint-disable-next-line no-console
console.warn(
'listTasks is not implemented in the scaffolderApi, please make sure to implement this method.',
);
return Promise.resolve({ tasks: [] });
}, [scaffolderApi, ownerFilter]);
if (loading) {
return <Progress />;
}
if (error) {
return (
<>
<ErrorPanel error={error} />
<EmptyState
missing="info"
title="No information to display"
description="There is no Tasks or there was an issue communicating with backend."
/>
</>
);
}
return (
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<OwnerListPicker
filter={ownerFilter}
onSelectOwner={id => setOwnerFilter(id)}
/>
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<MaterialTable
data={value?.tasks ?? []}
title="Tasks"
columns={[
{
title: 'Task ID',
field: 'id',
render: row => (
<Link to={`${rootLink()}/tasks/${row.id}`}>{row.id}</Link>
),
},
{
title: 'Template',
render: row => (
<TemplateTitleColumn
entityRef={row.spec.templateInfo?.entityRef}
/>
),
},
{
title: 'Created',
field: 'createdAt',
render: row => <CreatedAtColumn createdAt={row.createdAt} />,
},
{
title: 'Owner',
field: 'createdBy',
render: row => (
<OwnerEntityColumn entityRef={row.spec?.user?.ref} />
),
},
{
title: 'Status',
field: 'status',
render: row => <TaskStatusColumn status={row.status} />,
},
]}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
);
};
export const ListTasksPage = (props: MyTaskPageProps) => {
return (
<Page themeId="home">
<Header
pageTitleOverride="Templates Tasks"
title={
<>
List template tasks <Lifecycle shorthand alpha />
</>
}
subtitle="All tasks that have been started"
/>
<Content>
<ListTaskPageContent {...props} />
</Content>
</Page>
);
};
@@ -0,0 +1,47 @@
/*
* Copyright 2022 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { OwnerListPicker } from './OwnerListPicker';
import { fireEvent } from '@testing-library/react';
describe('<OwnerListPicker />', () => {
it('should render the tasks owner filter', async () => {
const props = {
filter: 'owned',
onSelectOwner: jest.fn(),
};
const { getByText } = await renderInTestApp(<OwnerListPicker {...props} />);
expect(await getByText('Owned')).toBeDefined();
expect(await getByText('All')).toBeDefined();
});
it('should call the function on select other item', async () => {
const props = {
filter: 'owned',
onSelectOwner: jest.fn(),
};
const { getByText } = await renderInTestApp(<OwnerListPicker {...props} />);
fireEvent.click(await getByText('All'));
expect(props.onSelectOwner).toBeCalledWith('all');
});
});
@@ -0,0 +1,133 @@
/*
* Copyright 2022 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 { IconComponent } from '@backstage/core-plugin-api';
import {
Card,
List,
ListItemIcon,
ListItemText,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import React, { Fragment } from 'react';
import AllIcon from '@material-ui/icons/FontDownload';
const useStyles = makeStyles<Theme>(
theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
margin: theme.spacing(1, 0, 1, 0),
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
listIcon: {
minWidth: 30,
color: theme.palette.text.primary,
},
menuItem: {
minHeight: theme.spacing(6),
},
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
}),
{
name: 'ScaffolderReactOwnerListPicker',
},
);
export type ButtonGroup = {
name: string;
items: {
id: 'owned' | 'starred' | 'all';
label: string;
icon?: IconComponent;
}[];
};
function getFilterGroups(): ButtonGroup[] {
return [
{
name: 'Task Owner',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
},
{
id: 'all',
label: 'All',
icon: AllIcon,
},
],
},
];
}
export const OwnerListPicker = (props: {
filter: string;
onSelectOwner: (id: 'owned' | 'all') => void;
}) => {
const { filter, onSelectOwner } = props;
const classes = useStyles();
const filterGroups = getFilterGroups();
return (
<Card className={classes.root}>
{filterGroups.map(group => (
<Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => onSelectOwner(item.id as 'owned' | 'all')}
selected={item.id === filter}
className={classes.menuItem}
data-testid={`owner-picker-${item.id}`}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1">{item.label}</Typography>
</ListItemText>
</MenuItem>
))}
</List>
</Card>
</Fragment>
))}
</Card>
);
};
@@ -0,0 +1,34 @@
/*
* Copyright 2022 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { CreatedAtColumn } from './CreatedAtColumn';
import { DateTime } from 'luxon';
describe('<CreatedAtColumn />', () => {
it('should render the column with the time', async () => {
const props = {
createdAt: DateTime.now().toISO(),
};
const { getByText } = await renderInTestApp(<CreatedAtColumn {...props} />);
const text = getByText('0 seconds ago');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,27 @@
/*
* Copyright 2022 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 { DateTime, Interval } from 'luxon';
import humanizeDuration from 'humanize-duration';
import React from 'react';
export const CreatedAtColumn = ({ createdAt }: { createdAt: string }) => {
const createdAtTime = DateTime.fromISO(createdAt);
const formatted = Interval.fromDateTimes(createdAtTime, DateTime.local())
.toDuration()
.valueOf();
return <p>{humanizeDuration(formatted, { round: true })} ago</p>;
};
@@ -0,0 +1,82 @@
/*
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { OwnerEntityColumn } from './OwnerEntityColumn';
import { identityApiRef } from '@backstage/core-plugin-api';
describe('<OwnerEntityColumn />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
it('should render the column with the user', async () => {
const props = {
entityRef: 'user:default/foo',
};
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'test',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
const { getByText, getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
]}
>
<OwnerEntityColumn {...props} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/foo',
);
const text = getByText('BackUser');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,49 @@
/*
* Copyright 2022 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 { useApi } from '@backstage/core-plugin-api';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef, EntityRefLink } from '@backstage/plugin-catalog-react';
import { parseEntityRef, UserEntity } from '@backstage/catalog-model';
export const OwnerEntityColumn = ({ entityRef }: { entityRef?: string }) => {
const catalogApi = useApi(catalogApiRef);
const { value, loading, error } = useAsync(
() => catalogApi.getEntityByRef(entityRef || ''),
[catalogApi, entityRef],
);
if (!entityRef) {
return <p>Unknown</p>;
}
if (loading || error) {
return null;
}
return (
<EntityRefLink
entityRef={parseEntityRef(entityRef)}
title={
(value as UserEntity)?.spec?.profile?.displayName ??
value?.metadata.name
}
/>
);
};
@@ -0,0 +1,38 @@
/*
* Copyright 2022 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { TaskStatusColumn } from './TaskStatusColumn';
describe('<TaskStatusColumn />', () => {
it.each(['processing', 'error', 'completed'])(
'should render the column with the status %s',
async status => {
const props = {
status: status,
};
const { getByText } = await renderInTestApp(
<TaskStatusColumn {...props} />,
);
const text = getByText(status);
expect(text).toBeDefined();
},
);
});
@@ -0,0 +1,33 @@
/*
* Copyright 2022 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 {
StatusError,
StatusOK,
StatusPending,
} from '@backstage/core-components';
import React from 'react';
export const TaskStatusColumn = ({ status }: { status: string }) => {
switch (status) {
case 'processing':
return <StatusPending>{status}</StatusPending>;
case 'completed':
return <StatusOK>{status}</StatusOK>;
case 'error':
default:
return <StatusError>{status}</StatusError>;
}
};
@@ -0,0 +1,50 @@
/*
* Copyright 2022 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { TemplateTitleColumn } from './TemplateTitleColumn';
import { scaffolderApiRef } from '../../../api';
import { ScaffolderApi } from '../../../types';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
describe('<TemplateTitleColumn />', () => {
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
} as any;
it('should render the column with the template name', async () => {
const props = {
entityRef: 'template:default/one-template',
};
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<TemplateTitleColumn {...props} />
</TestApiProvider>,
{ mountedRoutes: { '/test': entityRouteRef } },
);
const text = getByText('One Template');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,37 @@
/*
* Copyright 2022 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 { useApi } from '@backstage/core-plugin-api';
import React from 'react';
import { scaffolderApiRef } from '../../../api';
import useAsync from 'react-use/lib/useAsync';
import { parseEntityRef } from '@backstage/catalog-model';
import { EntityRefLink } from '@backstage/plugin-catalog-react';
export const TemplateTitleColumn = ({ entityRef }: { entityRef?: string }) => {
const scaffolder = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() => scaffolder.getTemplateParameterSchema(entityRef || ''),
[scaffolder, entityRef],
);
if (loading || error || !entityRef) {
return null;
}
return (
<EntityRefLink entityRef={parseEntityRef(entityRef)} title={value?.title} />
);
};
@@ -0,0 +1,19 @@
/*
* Copyright 2022 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.
*/
export { CreatedAtColumn } from './CreatedAtColumn';
export { OwnerEntityColumn } from './OwnerEntityColumn';
export { TaskStatusColumn } from './TaskStatusColumn';
export { TemplateTitleColumn } from './TemplateTitleColumn';
@@ -0,0 +1,16 @@
/*
* Copyright 2022 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.
*/
export { ListTasksPage } from './ListTasksPage';
@@ -35,9 +35,11 @@ import { useElementFilter } from '@backstage/core-plugin-api';
import {
actionsRouteRef,
editRouteRef,
scaffolderListTaskRouteRef,
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
} from '../routes';
import { ListTasksPage } from './ListTasksPage';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
@@ -118,6 +120,10 @@ export const Router = (props: RouterProps) => {
</SecretsContextProvider>
}
/>
<Route
path={scaffolderListTaskRouteRef.path}
element={<ListTasksPage />}
/>
<Route path={scaffolderTaskRouteRef.path} element={<TaskPageElement />} />
<Route path={actionsRouteRef.path} element={<ActionsPage />} />
<Route
@@ -52,6 +52,7 @@ export type ScaffolderPageProps = {
contextMenu?: {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
};
};
@@ -24,10 +24,15 @@ import Popover from '@material-ui/core/Popover';
import { makeStyles } from '@material-ui/core/styles';
import Description from '@material-ui/icons/Description';
import Edit from '@material-ui/icons/Edit';
import List from '@material-ui/icons/List';
import MoreVert from '@material-ui/icons/MoreVert';
import React, { useState } from 'react';
import { useNavigate } from 'react-router';
import { rootRouteRef } from '../../routes';
import {
actionsRouteRef,
editRouteRef,
scaffolderListTaskRouteRef,
} from '../../routes';
const useStyles = makeStyles({
button: {
@@ -38,6 +43,7 @@ const useStyles = makeStyles({
export type ScaffolderPageContextMenuProps = {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
};
export function ScaffolderPageContextMenu(
@@ -45,11 +51,15 @@ export function ScaffolderPageContextMenu(
) {
const classes = useStyles();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const pageLink = useRouteRef(rootRouteRef);
const editLink = useRouteRef(editRouteRef);
const actionsLink = useRouteRef(actionsRouteRef);
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const navigate = useNavigate();
const showEditor = props.editor !== false;
const showActions = props.actions !== false;
const showTasks = props.tasks !== false;
if (!showEditor && !showActions) {
return null;
@@ -85,7 +95,7 @@ export function ScaffolderPageContextMenu(
>
<MenuList>
{showEditor && (
<MenuItem onClick={() => navigate(`${pageLink()}/edit`)}>
<MenuItem onClick={() => navigate(editLink())}>
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
@@ -93,13 +103,21 @@ export function ScaffolderPageContextMenu(
</MenuItem>
)}
{showActions && (
<MenuItem onClick={() => navigate(`${pageLink()}/actions`)}>
<MenuItem onClick={() => navigate(actionsLink())}>
<ListItemIcon>
<Description fontSize="small" />
</ListItemIcon>
<ListItemText primary="Installed Actions" />
</MenuItem>
)}
{showTasks && (
<MenuItem onClick={() => navigate(tasksLink())}>
<ListItemIcon>
<List fontSize="small" />
</ListItemIcon>
<ListItemText primary="Task List" />
</MenuItem>
)}
</MenuList>
</Popover>
</>
@@ -51,6 +51,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const featureFlagsApiMock: jest.Mocked<FeatureFlagsApi> = {
+4 -1
View File
@@ -30,6 +30,7 @@ import {
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { OwnedEntityPicker } from './components/fields/OwnedEntityPicker/OwnedEntityPicker';
import { EntityTagsPicker } from './components/fields/EntityTagsPicker/EntityTagsPicker';
@@ -47,12 +48,14 @@ export const scaffolderPlugin = createPlugin({
discoveryApi: discoveryApiRef,
scmIntegrationsApi: scmIntegrationsApiRef,
fetchApi: fetchApiRef,
identityApi: identityApiRef,
},
factory: ({ discoveryApi, scmIntegrationsApi, fetchApi }) =>
factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) =>
new ScaffolderClient({
discoveryApi,
scmIntegrationsApi,
fetchApi,
identityApi,
}),
}),
],
+6
View File
@@ -40,6 +40,12 @@ export const scaffolderTaskRouteRef = createSubRouteRef({
path: '/tasks/:taskId',
});
export const scaffolderListTaskRouteRef = createSubRouteRef({
id: 'scaffolder/list-tasks',
parent: rootRouteRef,
path: '/tasks',
});
export const actionsRouteRef = createSubRouteRef({
id: 'scaffolder/actions',
parent: rootRouteRef,
+6
View File
@@ -192,6 +192,12 @@ export interface ScaffolderApi {
getTask(taskId: string): Promise<ScaffolderTask>;
listTasks?({
filterByOwnership,
}: {
filterByOwnership: 'owned' | 'all';
}): Promise<{ tasks: ScaffolderTask[] }>;
getIntegrationsList(
options: ScaffolderGetIntegrationsListOptions,
): Promise<ScaffolderGetIntegrationsListResponse>;