diff --git a/.changeset/fair-carrots-provide.md b/.changeset/fair-carrots-provide.md new file mode 100644 index 0000000000..01bc9e18bf --- /dev/null +++ b/.changeset/fair-carrots-provide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +- **BREAKING** - the `/v2/tasks` endpoint now takes `templateRef` instead of `templateName` in the POST body. This should be a valid stringified `entityRef`. diff --git a/.changeset/seven-rings-smoke.md b/.changeset/seven-rings-smoke.md new file mode 100644 index 0000000000..5183ccb3ac --- /dev/null +++ b/.changeset/seven-rings-smoke.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +- Moved the `JSONSchema` type from `@backstage/catalog-model` to `JSONSchema7`. +- Renamed and prefixed some types ready for exporting. diff --git a/.changeset/slimy-drinks-tell.md b/.changeset/slimy-drinks-tell.md new file mode 100644 index 0000000000..87b9644d29 --- /dev/null +++ b/.changeset/slimy-drinks-tell.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +- **BREAKING** - `scaffolderApi.scaffold()` now takes one `options` argument instead of 3, the existing arguments should just be wrapped up in one object instead. +- **BREAKING** - `scaffolderApi.scaffold()` now returns an object instead of a single string for the job ID. It's now `{ taskId: string }` +- **BREAKING** - `scaffolderApi.scaffold()` now takes a `templateRef` instead of `templateName` as an argument in the options. This should be a valid stringified `entityRef`. +- **BREAKING** - `scaffolderApi.getIntegrationsList` now returns an object `{ integrations: { type: string, title: string, host: string }[] }` instead of just an array. diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 6a66d6df47..fa1b649231 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -49,6 +49,7 @@ import request from 'supertest'; */ import { createRouter, DatabaseTaskStore, TaskBroker } from '../index'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; +import { stringifyEntityRef } from '@backstage/catalog-model'; const createCatalogClient = (template: any) => ({ @@ -146,7 +147,10 @@ describe('createRouter', () => { const response = await request(app) .post('/v2/tasks') .send({ - templateName: 'create-react-app-template', + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), values: { storePath: 'https://github.com/backstage/backstage', }, @@ -165,7 +169,10 @@ describe('createRouter', () => { const response = await request(app) .post('/v2/tasks') .send({ - templateName: 'create-react-app-template', + templateRef: stringifyEntityRef({ + kind: 'template', + name: 'create-react-app-template', + }), values: { required: 'required-value', }, diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index e1cd75dcc2..84febe4225 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -20,10 +20,7 @@ import { UrlReader, } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; -import { - DEFAULT_NAMESPACE, - stringifyEntityRef, -} from '@backstage/catalog-model'; +import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, NotFoundError } from '@backstage/errors'; @@ -178,14 +175,15 @@ export async function createRouter( res.json(actionsList); }) .post('/v2/tasks', async (req, res) => { - const templateName: string = req.body.templateName; - const kind = 'template'; - const namespace = DEFAULT_NAMESPACE; + const templateRef: string = req.body.templateRef; + const { kind, namespace, name } = parseEntityRef(templateRef, { + defaultKind: 'template', + }); const values = req.body.values; const token = getBearerToken(req.headers.authorization); const template = await findTemplate({ catalogApi: catalogClient, - entityRef: { kind, namespace, name: templateName }, + entityRef: { kind, namespace, name }, token: getBearerToken(req.headers.authorization), }); diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index fb3fd830f7..df8f1ca9e2 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -12,7 +12,6 @@ import { ComponentProps } from 'react'; import { ComponentType } from 'react'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityName } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FetchApi } from '@backstage/core-plugin-api'; @@ -20,7 +19,7 @@ import { FieldProps } from '@rjsf/core'; import { FieldValidation } from '@rjsf/core'; import { IconButton } from '@material-ui/core'; import { JsonObject } from '@backstage/types'; -import { JSONSchema } from '@backstage/catalog-model'; +import { JSONSchema7 } from 'json-schema'; import { Observable } from '@backstage/types'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; @@ -122,6 +121,38 @@ export type FieldExtensionOptions< validation?: CustomFieldValidator; }; +// Warning: (ae-missing-release-tag) "JobStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + +// Warning: (ae-missing-release-tag) "ListActionsResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ListActionsResponse = Array<{ + id: string; + description?: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}>; + +// Warning: (ae-missing-release-tag) "LogEvent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type LogEvent = { + type: 'log' | 'completion'; + body: { + message: string; + stepId?: string; + status?: ScaffolderTaskStatus; + }; + createdAt: string; + id: string; + taskId: string; +}; + // Warning: (ae-missing-release-tag) "OwnedEntityPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -210,34 +241,21 @@ export interface RepoUrlPickerUiOptions { // @public export interface ScaffolderApi { // (undocumented) - getIntegrationsList(options: { allowedHosts: string[] }): Promise< - { - type: string; - title: string; - host: string; - }[] - >; - // Warning: (ae-forgotten-export) The symbol "ScaffolderTask" needs to be exported by the entry point index.d.ts - // + getIntegrationsList( + options: ScaffolderGetIntegrationsListOptions, + ): Promise; // (undocumented) getTask(taskId: string): Promise; - // Warning: (ae-forgotten-export) The symbol "TemplateParameterSchema" needs to be exported by the entry point index.d.ts - // // (undocumented) getTemplateParameterSchema( - templateName: EntityName, + templateRef: string, ): Promise; - // Warning: (ae-forgotten-export) The symbol "ListActionsResponse" needs to be exported by the entry point index.d.ts listActions(): Promise; scaffold( - templateName: string, - values: Record, - secrets?: Record, - ): Promise; - // Warning: (ae-forgotten-export) The symbol "LogEvent" needs to be exported by the entry point index.d.ts - // + options: ScaffolderScaffoldOptions, + ): Promise; // (undocumented) - streamLogs(options: { taskId: string; after?: number }): Observable; + streamLogs(options: ScaffolderStreamLogsOptions): Observable; } // @public @@ -252,28 +270,22 @@ export class ScaffolderClient implements ScaffolderApi { useLongPollingLogs?: boolean; }); // (undocumented) - getIntegrationsList(options: { allowedHosts: string[] }): Promise< - { - type: string; - title: string; - host: string; - }[] - >; + getIntegrationsList( + options: ScaffolderGetIntegrationsListOptions, + ): Promise; // (undocumented) - getTask(taskId: string): Promise; + getTask(taskId: string): Promise; // (undocumented) getTemplateParameterSchema( - templateName: EntityName, + templateRef: string, ): Promise; // (undocumented) listActions(): Promise; scaffold( - templateName: string, - values: Record, - secrets?: Record, - ): Promise; + options: ScaffolderScaffoldOptions, + ): Promise; // (undocumented) - streamLogs(options: { taskId: string; after?: number }): Observable; + streamLogs(options: ScaffolderStreamLogsOptions): Observable; } // Warning: (ae-missing-release-tag) "ScaffolderFieldExtensions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -281,6 +293,26 @@ export class ScaffolderClient implements ScaffolderApi { // @public (undocumented) export const ScaffolderFieldExtensions: React_2.ComponentType; +// Warning: (ae-missing-release-tag) "ScaffolderGetIntegrationsListOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ScaffolderGetIntegrationsListOptions { + // (undocumented) + allowedHosts: string[]; +} + +// Warning: (ae-missing-release-tag) "ScaffolderGetIntegrationsListResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ScaffolderGetIntegrationsListResponse { + // (undocumented) + integrations: { + type: string; + title: string; + host: string; + }[]; +} + // Warning: (ae-missing-release-tag) "ScaffolderPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -316,6 +348,68 @@ export const scaffolderPlugin: BackstagePlugin< } >; +// Warning: (ae-missing-release-tag) "ScaffolderScaffoldOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ScaffolderScaffoldOptions { + // (undocumented) + secrets?: Record; + // (undocumented) + templateRef: string; + // (undocumented) + values: Record; +} + +// Warning: (ae-missing-release-tag) "ScaffolderScaffoldResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ScaffolderScaffoldResponse { + // (undocumented) + taskId: string; +} + +// Warning: (ae-missing-release-tag) "ScaffolderStreamLogsOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ScaffolderStreamLogsOptions { + // (undocumented) + after?: number; + // (undocumented) + taskId: string; +} + +// Warning: (ae-missing-release-tag) "ScaffolderTask" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ScaffolderTask = { + id: string; + spec: TaskSpec; + status: 'failed' | 'completed' | 'processing' | 'open' | 'cancelled'; + lastHeartbeatAt: string; + createdAt: string; +}; + +// Warning: (ae-missing-release-tag) "ScaffolderTaskOutput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ScaffolderTaskOutput = { + entityRef?: string; + remoteUrl?: string; + links?: ScaffolderOutputLink[]; +} & { + [key: string]: unknown; +}; + +// Warning: (ae-missing-release-tag) "ScaffolderTaskStatus" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ScaffolderTaskStatus = + | 'open' + | 'processing' + | 'failed' + | 'completed' + | 'skipped'; + // @public export const TaskPage: ({ loadingText }: TaskPageProps) => JSX.Element; @@ -348,6 +442,17 @@ export type TemplateListProps = { }; }; +// Warning: (ae-missing-release-tag) "TemplateParameterSchema" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type TemplateParameterSchema = { + title: string; + steps: Array<{ + title: string; + schema: JsonObject; + }>; +}; + // Warning: (ae-missing-release-tag) "TemplateTypePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -357,4 +462,8 @@ export const TemplateTypePicker: () => JSX.Element | null; export const useTemplateSecrets: () => { setSecret: (input: Record) => void; }; + +// Warnings were encountered during analysis: +// +// src/types.d.ts:32:5 - (ae-forgotten-export) The symbol "ScaffolderOutputLink" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 827e8126f7..60b32881c9 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -34,6 +34,7 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@types/json-schema": "^7.0.9", "@backstage/catalog-client": "^0.7.0", "@backstage/catalog-model": "^0.10.0", "@backstage/config": "^0.1.14", diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index 369722ec8f..4e47c7f239 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -62,7 +62,9 @@ describe('api', () => { 'dev.azure.com', 'bitbucket.org', ]; - const integrations = await apiClient.getIntegrationsList({ allowedHosts }); + const { integrations } = await apiClient.getIntegrationsList({ + allowedHosts, + }); integrations.forEach(integration => expect(allowedHosts).toContain(integration.host), ); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 62fc06a6db..818ee7971b 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityName } from '@backstage/catalog-model'; +import { parseEntityRef } from '@backstage/catalog-model'; import { createApiRef, DiscoveryApi, @@ -22,11 +22,21 @@ import { } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; -import { JsonObject, JsonValue, Observable } from '@backstage/types'; -import { Field, FieldValidation } from '@rjsf/core'; +import { Observable } from '@backstage/types'; import qs from 'qs'; import ObservableImpl from 'zen-observable'; -import { ListActionsResponse, ScaffolderTask, Status } from './types'; +import { + ListActionsResponse, + LogEvent, + ScaffolderApi, + TemplateParameterSchema, + ScaffolderScaffoldOptions, + ScaffolderScaffoldResponse, + ScaffolderStreamLogsOptions, + ScaffolderGetIntegrationsListOptions, + ScaffolderGetIntegrationsListResponse, + ScaffolderTask, +} from './types'; /** * Utility API reference for the {@link ScaffolderApi}. @@ -37,70 +47,6 @@ export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', }); -type TemplateParameterSchema = { - title: string; - steps: Array<{ - title: string; - schema: JsonObject; - }>; -}; - -export type LogEvent = { - type: 'log' | 'completion'; - body: { - message: string; - stepId?: string; - status?: Status; - }; - createdAt: string; - id: string; - taskId: string; -}; - -export type CustomField = { - name: string; - component: Field; - validation: (data: JsonValue, field: FieldValidation) => void; -}; - -/** - * An API to interact with the scaffolder backend. - * - * @public - */ -export interface ScaffolderApi { - getTemplateParameterSchema( - templateName: EntityName, - ): Promise; - - /** - * Executes the scaffolding of a component, given a template and its - * parameter values. - * - * @param templateName - Name of the Template entity for the scaffolder to use. New project is going to be created out of this template. - * @param values - Parameters for the template, e.g. name, description - * @param secrets - Optional secrets to pass to as the secrets parameter to the template. - */ - scaffold( - templateName: string, - values: Record, - secrets?: Record, - ): Promise; - - getTask(taskId: string): Promise; - - getIntegrationsList(options: { - allowedHosts: string[]; - }): Promise<{ type: string; title: string; host: string }[]>; - - /** - * Returns a list of all installed actions. - */ - listActions(): Promise; - - streamLogs(options: { taskId: string; after?: number }): Observable; -} - /** * An API to interact with the scaffolder backend. * @@ -124,8 +70,10 @@ export class ScaffolderClient implements ScaffolderApi { this.useLongPollingLogs = options.useLongPollingLogs ?? false; } - async getIntegrationsList(options: { allowedHosts: string[] }) { - return [ + async getIntegrationsList( + options: ScaffolderGetIntegrationsListOptions, + ): Promise { + const integrations = [ ...this.scmIntegrationsApi.azure.list(), ...this.scmIntegrationsApi.bitbucket.list(), ...this.scmIntegrationsApi.github.list(), @@ -133,17 +81,24 @@ export class ScaffolderClient implements ScaffolderApi { ] .map(c => ({ type: c.type, title: c.title, host: c.config.host })) .filter(c => options.allowedHosts.includes(c.host)); + + return { + integrations, + }; } async getTemplateParameterSchema( - templateName: EntityName, + templateRef: string, ): Promise { - const { namespace, kind, name } = templateName; + const { namespace, kind, name } = parseEntityRef(templateRef, { + defaultKind: 'template', + }); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const templatePath = [namespace, kind, name] .map(s => encodeURIComponent(s)) .join('/'); + const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`; const response = await this.fetchApi.fetch(url); @@ -159,15 +114,12 @@ export class ScaffolderClient implements ScaffolderApi { * Executes the scaffolding of a component, given a template and its * parameter values. * - * @param templateName - Template name for the scaffolder to use. New project is going to be created out of this template. - * @param values - Parameters for the template, e.g. name, description - * @param secrets - Optional secrets to pass to as the secrets parameter to the template. + * @param options - The {@link ScaffolderScaffoldOptions} the scaffolding. */ async scaffold( - templateName: string, - values: Record, - secrets: Record = {}, - ): Promise { + options: ScaffolderScaffoldOptions, + ): Promise { + const { templateRef, values, secrets = {} } = options; const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`; const response = await this.fetchApi.fetch(url, { method: 'POST', @@ -175,7 +127,7 @@ export class ScaffolderClient implements ScaffolderApi { 'Content-Type': 'application/json', }, body: JSON.stringify({ - templateName, + templateRef, values: { ...values }, secrets, }), @@ -188,10 +140,10 @@ export class ScaffolderClient implements ScaffolderApi { } const { id } = (await response.json()) as { id: string }; - return id; + return { taskId: id }; } - async getTask(taskId: string) { + async getTask(taskId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; @@ -203,10 +155,7 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } - streamLogs(options: { - taskId: string; - after?: number; - }): Observable { + streamLogs(options: ScaffolderStreamLogsOptions): Observable { if (this.useLongPollingLogs) { return this.streamLogsPolling(options); } diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index ce0354b817..aff6681037 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ import React from 'react'; -import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { scaffolderApiRef } from '../../api'; import { ActionsPage } from './ActionsPage'; import { rootRouteRef } from '../../routes'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; +import { ScaffolderApi } from '../../types'; const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index 4542979e73..78efaa43ff 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -28,8 +28,7 @@ import { TableRow, makeStyles, } from '@material-ui/core'; -import { JSONSchema } from '@backstage/catalog-model'; -import { JSONSchema7Definition } from 'json-schema'; +import { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import classNames from 'classnames'; import { useApi } from '@backstage/core-plugin-api'; @@ -87,7 +86,7 @@ export const ActionsPage = () => { ); } - const formatRows = (input: JSONSchema) => { + const formatRows = (input: JSONSchema7) => { const properties = input.properties; if (!properties) { return undefined; @@ -95,7 +94,7 @@ export const ActionsPage = () => { return Object.entries(properties).map(entry => { const [key] = entry; - const props = entry[1] as unknown as JSONSchema; + const props = entry[1] as unknown as JSONSchema7; const codeClassname = classNames(classes.code, { [classes.codeRequired]: input.required?.includes(key), }); @@ -115,7 +114,7 @@ export const ActionsPage = () => { }); }; - const renderTable = (input: JSONSchema) => { + const renderTable = (input: JSONSchema7) => { if (!input.properties) { return undefined; } @@ -145,7 +144,7 @@ export const ActionsPage = () => { <> {name} {input.map((i, index) => ( -
{renderTable(i as unknown as JSONSchema)}
+
{renderTable(i as unknown as JSONSchema7)}
))} ); diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx index a642660bac..0882d49ff4 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPage.tsx @@ -49,7 +49,7 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import { generatePath, useNavigate, useParams } from 'react-router'; import useInterval from 'react-use/lib/useInterval'; import { rootRouteRef } from '../../routes'; -import { Status, TaskOutput } from '../../types'; +import { ScaffolderTaskStatus, ScaffolderTaskOutput } from '../../types'; import { useTaskEventStream } from '../hooks/useEventStream'; import { TaskPageLinks } from './TaskPageLinks'; @@ -86,7 +86,7 @@ const useStyles = makeStyles((theme: Theme) => type TaskStep = { id: string; name: string; - status: Status; + status: ScaffolderTaskStatus; startedAt?: string; endedAt?: string; }; @@ -217,7 +217,11 @@ export const TaskStatusStepper = memo( }, ); -const hasLinks = ({ entityRef, remoteUrl, links = [] }: TaskOutput): boolean => +const hasLinks = ({ + entityRef, + remoteUrl, + links = [], +}: ScaffolderTaskOutput): boolean => !!(entityRef || remoteUrl || links.length > 0); /** diff --git a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx index d53b5d705c..5451b4e725 100644 --- a/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx +++ b/plugins/scaffolder/src/components/TaskPage/TaskPageLinks.tsx @@ -19,12 +19,12 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; import LanguageIcon from '@material-ui/icons/Language'; import React from 'react'; -import { TaskOutput } from '../../types'; +import { ScaffolderTaskOutput } from '../../types'; import { IconLink } from './IconLink'; import { IconComponent, useApp, useRouteRef } from '@backstage/core-plugin-api'; type TaskPageLinksProps = { - output: TaskOutput; + output: ScaffolderTaskOutput; }; export const TaskPageLinks = ({ output }: TaskPageLinksProps) => { diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 48710b7086..d0eca58b9d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -23,7 +23,8 @@ import { ThemeProvider } from '@material-ui/core'; import { act, fireEvent, within } from '@testing-library/react'; import React from 'react'; import { MemoryRouter, Route } from 'react-router'; -import { ScaffolderApi, scaffolderApiRef } from '../../api'; +import { scaffolderApiRef } from '../../api'; +import { ScaffolderApi } from '../../types'; import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; import { diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index d30c292053..798ce31539 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -41,17 +41,13 @@ import { useApiHolder, useRouteRef, } from '@backstage/core-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; -const useTemplateParameterSchema = (templateName: string) => { +const useTemplateParameterSchema = (templateRef: string) => { const scaffolderApi = useApi(scaffolderApiRef); const { value, loading, error } = useAsync( - () => - scaffolderApi.getTemplateParameterSchema({ - name: templateName, - kind: 'template', - namespace: 'default', - }), - [scaffolderApi, templateName], + () => scaffolderApi.getTemplateParameterSchema(templateRef), + [scaffolderApi, templateRef], ); return { schema: value, loading, error }; }; @@ -141,11 +137,15 @@ export const TemplatePage = ({ ); const handleCreate = async () => { - const id = await scaffolderApi.scaffold( - templateName, - formState, - secretsContext?.secrets, - ); + const { taskId } = await scaffolderApi.scaffold({ + templateRef: stringifyEntityRef({ + name: templateName, + kind: 'template', + namespace: 'default', + }), + values: formState, + secrets: secretsContext?.secrets, + }); const formParams = qs.stringify( { formData: formState }, @@ -158,7 +158,7 @@ export const TemplatePage = ({ // extra back/forward slots. window.history?.replaceState(null, document.title, newUrl); - navigate(generatePath(`${rootLink()}/tasks/:taskId`, { taskId: id })); + navigate(generatePath(`${rootLink()}/tasks/:taskId`, { taskId })); }; if (error) { diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx index 829495cdda..22043d5761 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.test.tsx @@ -23,7 +23,9 @@ import { scmAuthApiRef, ScmAuthApi, } from '@backstage/integration-react'; -import { scaffolderApiRef, ScaffolderApi } from '../../../api'; +import { scaffolderApiRef } from '../../../api'; +import { ScaffolderApi } from '../../../types'; + import { SecretsContextProvider, SecretsContext, @@ -32,10 +34,12 @@ import { act, fireEvent } from '@testing-library/react'; describe('RepoUrlPicker', () => { const mockScaffolderApi: Partial = { - getIntegrationsList: async () => [ - { host: 'github.com', type: 'github', title: 'github.com' }, - { host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' }, - ], + getIntegrationsList: async () => ({ + integrations: [ + { host: 'github.com', type: 'github', title: 'github.com' }, + { host: 'dev.azure.com', type: 'azure', title: 'dev.azure.com' }, + ], + }), }; const mockIntegrationsApi: Partial = { diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx index e151ddbbc7..bd71fb160f 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.test.tsx @@ -23,11 +23,11 @@ describe('RepoUrlPickerHostField', () => { it('renders the default host properly', async () => { const mockOnChange = jest.fn(); const mockScaffolderApi = { - getIntegrationsList: jest - .fn() - .mockResolvedValue([ + getIntegrationsList: jest.fn().mockResolvedValue({ + integrations: [ { host: 'github.com', title: 'github.com', type: 'github' }, - ]), + ], + }), }; const { getByText } = await renderInTestApp( @@ -46,10 +46,12 @@ describe('RepoUrlPickerHostField', () => { it('should provide a dropdown when multiple hosts are returned that can be selected', async () => { const mockOnChange = jest.fn(); const mockScaffolderApi = { - getIntegrationsList: jest.fn().mockResolvedValue([ - { host: 'github.com', title: 'github.com', type: 'github' }, - { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, - ]), + getIntegrationsList: jest.fn().mockResolvedValue({ + integrations: [ + { host: 'github.com', title: 'github.com', type: 'github' }, + { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, + ], + }), }; const { getByRole, getByText, getByTestId } = await renderInTestApp( @@ -73,10 +75,12 @@ describe('RepoUrlPickerHostField', () => { it('should not display hosts that dont have integration config set correctly', async () => { const mockOnChange = jest.fn(); const mockScaffolderApi = { - getIntegrationsList: jest.fn().mockResolvedValue([ - { host: 'github.com', title: 'github.com', type: 'github' }, - { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, - ]), + getIntegrationsList: jest.fn().mockResolvedValue({ + integrations: [ + { host: 'github.com', title: 'github.com', type: 'github' }, + { host: 'gitlab.com', title: 'gitlab.com', type: 'gitlab' }, + ], + }), }; const { getByRole, getByText, getByTestId } = await renderInTestApp( diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx index ec09494a1b..b9762cd69d 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPickerHost.tsx @@ -30,11 +30,13 @@ export const RepoUrlPickerHost = (props: { const { host, hosts, onChange, rawErrors } = props; const scaffolderApi = useApi(scaffolderApiRef); - const { value: integrations, loading } = useAsync(async () => { - return await scaffolderApi.getIntegrationsList({ - allowedHosts: hosts ?? [], - }); - }); + const { value: { integrations } = { integrations: [] }, loading } = useAsync( + async () => { + return await scaffolderApi.getIntegrationsList({ + allowedHosts: hosts ?? [], + }); + }, + ); useEffect(() => { // If there is no host chosen currently diff --git a/plugins/scaffolder/src/components/hooks/useEventStream.ts b/plugins/scaffolder/src/components/hooks/useEventStream.ts index 7414a35189..8ff9a30d0b 100644 --- a/plugins/scaffolder/src/components/hooks/useEventStream.ts +++ b/plugins/scaffolder/src/components/hooks/useEventStream.ts @@ -15,14 +15,19 @@ */ import { useImmerReducer } from 'use-immer'; import { useEffect } from 'react'; -import { scaffolderApiRef, LogEvent } from '../../api'; -import { ScaffolderTask, Status, TaskOutput } from '../../types'; +import { scaffolderApiRef } from '../../api'; +import { + ScaffolderTask, + ScaffolderTaskStatus, + ScaffolderTaskOutput, + LogEvent, +} from '../../types'; import { useApi } from '@backstage/core-plugin-api'; import { Subscription } from '@backstage/types'; type Step = { id: string; - status: Status; + status: ScaffolderTaskStatus; endedAt?: string; startedAt?: string; }; @@ -34,16 +39,16 @@ export type TaskStream = { completed: boolean; task?: ScaffolderTask; steps: { [stepId in string]: Step }; - output?: TaskOutput; + output?: ScaffolderTaskOutput; }; type ReducerLogEntry = { createdAt: string; body: { stepId?: string; - status?: Status; + status?: ScaffolderTaskStatus; message: string; - output?: TaskOutput; + output?: ScaffolderTaskOutput; }; }; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 34091b3fea..90d5445c8c 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -21,7 +21,21 @@ */ export { scaffolderApiRef, ScaffolderClient } from './api'; -export type { ScaffolderApi } from './api'; +export type { + JobStatus, + ListActionsResponse, + LogEvent, + ScaffolderApi, + ScaffolderGetIntegrationsListOptions, + ScaffolderGetIntegrationsListResponse, + ScaffolderScaffoldOptions, + ScaffolderScaffoldResponse, + ScaffolderStreamLogsOptions, + ScaffolderTask, + ScaffolderTaskOutput, + ScaffolderTaskStatus, + TemplateParameterSchema, +} from './types'; export { createScaffolderFieldExtension, ScaffolderFieldExtensions, diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 291f680710..5d89f10291 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -13,31 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JSONSchema } from '@backstage/catalog-model'; import { TaskSpec } from '@backstage/plugin-scaffolder-common'; +import { JsonObject, Observable } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; + +export type ScaffolderTaskStatus = + | 'open' + | 'processing' + | 'failed' + | 'completed' + | 'skipped'; -export type Status = 'open' | 'processing' | 'failed' | 'completed' | 'skipped'; export type JobStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; -export type Job = { - id: string; - metadata: { - entity: any; - values: any; - remoteUrl?: string; - catalogInfoUrl?: string; - }; - status: JobStatus; - stages: Stage[]; - error?: Error; -}; - -export type Stage = { - name: string; - log: string[]; - status: JobStatus; - startedAt: string; - endedAt?: string; -}; export type ScaffolderTask = { id: string; @@ -51,24 +38,100 @@ export type ListActionsResponse = Array<{ id: string; description?: string; schema?: { - input?: JSONSchema; - output?: JSONSchema; + input?: JSONSchema7; + output?: JSONSchema7; }; }>; -type OutputLink = { +type ScaffolderOutputLink = { title?: string; icon?: string; url?: string; entityRef?: string; }; -export type TaskOutput = { +export type ScaffolderTaskOutput = { /** @deprecated use the `links` property to link out to relevant resources */ entityRef?: string; /** @deprecated use the `links` property to link out to relevant resources */ remoteUrl?: string; - links?: OutputLink[]; + links?: ScaffolderOutputLink[]; } & { [key: string]: unknown; }; + +export type TemplateParameterSchema = { + title: string; + steps: Array<{ + title: string; + schema: JsonObject; + }>; +}; + +export type LogEvent = { + type: 'log' | 'completion'; + body: { + message: string; + stepId?: string; + status?: ScaffolderTaskStatus; + }; + createdAt: string; + id: string; + taskId: string; +}; + +export interface ScaffolderScaffoldOptions { + templateRef: string; + values: Record; + secrets?: Record; +} + +export interface ScaffolderScaffoldResponse { + taskId: string; +} + +export interface ScaffolderGetIntegrationsListOptions { + allowedHosts: string[]; +} + +export interface ScaffolderGetIntegrationsListResponse { + integrations: { type: string; title: string; host: string }[]; +} + +export interface ScaffolderStreamLogsOptions { + taskId: string; + after?: number; +} +/** + * An API to interact with the scaffolder backend. + * + * @public + */ +export interface ScaffolderApi { + getTemplateParameterSchema( + templateRef: string, + ): Promise; + + /** + * Executes the scaffolding of a component, given a template and its + * parameter values. + * + * @param options - The {@link ScaffolderScaffoldOptions} the scaffolding. + */ + scaffold( + options: ScaffolderScaffoldOptions, + ): Promise; + + getTask(taskId: string): Promise; + + getIntegrationsList( + options: ScaffolderGetIntegrationsListOptions, + ): Promise; + + /** + * Returns a list of all installed actions. + */ + listActions(): Promise; + + streamLogs(options: ScaffolderStreamLogsOptions): Observable; +}