diff --git a/app-config.yaml b/app-config.yaml index f68f5a30a2..be7391707e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -248,6 +248,9 @@ catalog: - type: file target: ../catalog-model/examples/acme-corp.yaml + - type: file + target: ../../plugins/scaffolder-backend/sample-templates/test-template-v2/template.yaml + scaffolder: github: token: diff --git a/plugins/scaffolder-backend/sample-templates/test-template-v2/template.yaml b/plugins/scaffolder-backend/sample-templates/test-template-v2/template.yaml index 0ac7493895..08432e6502 100644 --- a/plugins/scaffolder-backend/sample-templates/test-template-v2/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/test-template-v2/template.yaml @@ -1,63 +1,63 @@ -apiVersion: backstage.io/v1beta2 -kind: Template -metadata: - name: test-template - title: Test Template - description: Testing out the new template schema -spec: - owner: backstage/techdocs-core - type: service +# apiVersion: backstage.io/v1beta2 +# kind: Template +# metadata: +# name: test-template +# title: Test Template +# description: Testing out the new template schema +# spec: +# owner: backstage/techdocs-core +# type: service - parameters: - title: Fill in some BS params - required: - - name - properties: - name: - title: Name - type: string - description: Unique name of the component - ui:widget: textarea - ui:autofocus: true - ui:options: - rows: 5 - storePath: # provides {owner, repository, providerUrl} - # $ref: '#/definitions/StorePath' - title: Store Path - type: string - description: Copy https://github.com/aeothgiaetgh/aetkijhahte and hammer in some more letters +# parameters: +# title: Fill in some BS params +# required: +# - name +# properties: +# name: +# title: Name +# type: string +# description: Unique name of the component +# ui:widget: textarea +# ui:autofocus: true +# ui:options: +# rows: 5 +# storePath: # provides {owner, repository, providerUrl} +# # $ref: '#/definitions/StorePath' +# title: Store Path +# type: string +# description: Copy https://github.com/aeothgiaetgh/aetkijhahte and hammer in some more letters - steps: - - id: prepare - name: Prepare - action: legacy:prepare - parameters: - protocol: file - url: file:///Users/patriko/dev/backstage/plugins/scaffolder-backend/sample-templates/test-template-v2 - - id: template - name: Template - action: legacy:template - parameters: - templater: cookiecutter - values: - name: '{{ parameters.name }}' - - id: publish - name: Publish - action: legacy:publish - parameters: - values: - name: '{{ parameters.name }}' - storePath: '{{ parameters.storePath }}' - owner: kungen # not use - - id: register - name: Register - action: catalog:register - parameters: - catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}' +# steps: +# - id: prepare +# name: Prepare +# action: legacy:prepare +# parameters: +# protocol: file +# url: file:///Users/patriko/dev/backstage/plugins/scaffolder-backend/sample-templates/test-template-v2 +# - id: template +# name: Template +# action: legacy:template +# parameters: +# templater: cookiecutter +# values: +# name: '{{ parameters.name }}' +# - id: publish +# name: Publish +# action: legacy:publish +# parameters: +# values: +# name: '{{ parameters.name }}' +# storePath: '{{ parameters.storePath }}' +# owner: kungen # not use +# - id: register +# name: Register +# action: catalog:register +# parameters: +# catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}' - output: - catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}' - entityRef: '{{ steps.register.entityRef }}' +# output: +# catalogInfoUrl: '{{ steps.publish.output.catalogInfoUrl }}' +# entityRef: '{{ steps.register.entityRef }}' # TODO: @@ -90,15 +90,17 @@ spec: title: Name type: string description: Unique name of the component - ui:widget: textarea ui:autofocus: true ui:options: rows: 5 - storePath: # provides {owner, repository, providerUrl} - # $ref: '#/definitions/StorePath' - title: Store Path + repoUrl: + title: Repository Location type: string description: Copy https://github.com/aeothgiaetgh/aetkijhahte and hammer in some more letters + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - github.com steps: - id: fetch-base @@ -115,13 +117,16 @@ spec: action: fetch:plain parameters: url: https://github.com/aeothgiaetgh/aetkijhahte/tree/master/docs/template + - id: publish name: Publish - action: publish + action: publish:github parameters: + allowedHosts: ['ghe.mycompany.com'] name: '{{ parameters.name }}' # storePath: github.com?owner=spotify&repo=name storePath: '{{ parameters.storePath }}' + - id: register name: Register action: catalog:register diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index ac165e021a..2be4caa81f 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -20,8 +20,10 @@ import { createApiRef, DiscoveryApi, Observable, + ConfigApi, IdentityApi, } from '@backstage/core'; +import { ScmIntegrations } from '@backstage/integration'; import ObservableImpl from 'zen-observable'; import { ScaffolderTask, Status } from './types'; @@ -66,6 +68,10 @@ export interface ScaffolderApi { getTask(taskId: string): Promise; + getIntegrationsList(options: { + allowedHosts: string[]; + }): Promise<{ type: string; title: string; host: string }[]>; + streamLogs({ taskId, after, @@ -77,13 +83,31 @@ export interface ScaffolderApi { export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; + private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; + this.configApi = options.configApi; + } + + async getIntegrationsList(options: { allowedHosts: string[] }) { + const integrations = ScmIntegrations.fromConfig( + this.configApi.getConfig('integrations'), + ); + + return [ + ...integrations.azure.list(), + ...integrations.bitbucket.list(), + ...integrations.github.list(), + ...integrations.gitlab.list(), + ] + .map(c => ({ type: c.type, title: c.title, host: c.config.host })) + .filter(c => options.allowedHosts.includes(c.host)); } async getTemplateParameterSchema( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index eb51e86d59..b33bd82ffa 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -45,6 +45,8 @@ type Props = { onChange: (e: IChangeEvent) => void; onReset: () => void; onFinish: () => void; + widgets?: FormProps['widgets']; + fields?: FormProps['fields']; }; export const MultistepJsonForm = ({ @@ -53,6 +55,8 @@ export const MultistepJsonForm = ({ onChange, onReset, onFinish, + fields, + widgets, }: Props) => { const [activeStep, setActiveStep] = useState(0); @@ -77,6 +81,8 @@ export const MultistepJsonForm = ({ noHtml5Validate formData={formData} onChange={onChange} + fields={fields} + widgets={widgets} onSubmit={e => { if (e.errors.length === 0) handleNext(); }} diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 1c8a662acb..282154c1e2 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -33,6 +33,7 @@ import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; +import { RepoUrlPicker } from '../fields'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -53,7 +54,9 @@ const storePathValidator = ( errors: FormValidation, ) => { const { storePath } = formData; + if (!storePath) { + errors.storePath.addError('Store path is required and not present'); return errors; } @@ -131,6 +134,7 @@ export const TemplatePage = () => { { + const api = useApi(scaffolderApiRef); + const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; + + const { value: integrations, loading } = useAsync(async () => { + return await api.getIntegrationsList({ allowedHosts }); + }); + + const [hostname, setHostname] = useState(''); + const [owner, setOwner] = useState(''); + const [repo, setRepo] = useState(''); + + const updateHostname = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + setHostname(evt.target.value as string), + [setHostname], + ); + + const updateOwner = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + setOwner(evt.target.value as string), + [setOwner], + ); + + const updateRepo = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + setRepo(evt.target.value as string), + [setRepo], + ); + + useEffect(() => { + if (hostname === '' && integrations?.length) { + setHostname(integrations[0].host); + } + }, [integrations, hostname]); + + useEffect(() => { + const params = new URLSearchParams(); + params.set('owner', owner); + params.set('repo', repo); + + onChange(`${hostname}?${params.toString()}`); + }, [hostname, owner, repo, onChange]); + + if (loading) { + return ; + } + + return ( + <> + Repository Location + + {integrations! + .filter(i => allowedHosts?.includes(i.host)) + .map(({ host, title }) => ( + + {title} + + ))} + + + + + ); +}; diff --git a/plugins/scaffolder/src/components/fields/index.ts b/plugins/scaffolder/src/components/fields/index.ts new file mode 100644 index 0000000000..b0f3df61d9 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { RepoUrlPicker } from './RepoUrlPicker'; diff --git a/plugins/scaffolder/src/components/widgets/StorePathPicker/index.tsx b/plugins/scaffolder/src/components/widgets/StorePathPicker/index.tsx new file mode 100644 index 0000000000..87c00dbf98 --- /dev/null +++ b/plugins/scaffolder/src/components/widgets/StorePathPicker/index.tsx @@ -0,0 +1,89 @@ +/* + * Cop +yright 2021 Spotify AB + + * + * 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 React, { useState, useCallback, useEffect } from 'react'; +import { Field, Widget } from '@rjsf/core'; +import { useApi, Progress } from '@backstage/core'; +import { scaffolderApiRef } from '../../../api'; +import { useAsync } from 'react-use'; +import TextField from '@material-ui/core/TextField'; +import MenuItem from '@material-ui/core/MenuItem'; +import InputLabel from '@material-ui/core/InputLabel'; + +import { Typography } from '@material-ui/core'; +import { rest } from 'msw/lib/types'; + +export const StorePathPicker: Field = ({ + options, + onChange, + rawErrors, + formContext, + uiSchema +}) => { + const api = useApi(scaffolderApiRef); + console.log(uiSchema) + const allowedHosts = uiSchema['ui:Options'].allowedHosts as string[]; + + const { value: integrations, loading } = useAsync(async () => { + return await api.getIntegrationsList({ allowedHosts }); + }); + + const [hostname, setHostname] = useState(''); + const [organization, setOrganization] = useState(''); + const [repositoryName, setRepositoryName] = useState(''); + + const [organization, setOrganization] = useState(''); + const [repositoryName, setRepositoryName] = useState(''); + + const updateHostname = useCallback( + (evt: React.ChangeEvent<{ name?: string; value: unknown }>) => + setHostname(evt.target.value as string), + [setHostname], + ); + + useEffect(() => { + if (hostname === '' && integrations?.length) { + setHostname(integrations[0].host); + } + }, [integrations, hostname]); + + if (loading) { + return ; + } + + return ( + <> + Repository Location + + {integrations! + .filter(i => allowedHosts?.includes(i.host)) + .map(({ host, title }) => ( + + {title} + () =?> Setsets + ))} + + + + + ); +}; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index a6ba1a9899..8ba03f4682 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -19,6 +19,7 @@ import { createApiFactory, discoveryApiRef, identityApiRef, + configApiRef, createRoutableExtension, } from '@backstage/core'; import { rootRouteRef } from './routes'; @@ -29,9 +30,13 @@ export const scaffolderPlugin = createPlugin({ apis: [ createApiFactory({ api: scaffolderApiRef, - deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, - factory: ({ discoveryApi, identityApi }) => - new ScaffolderClient({ discoveryApi, identityApi }), + deps: { + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, identityApi, configApi }) => + new ScaffolderClient({ discoveryApi, identityApi, configApi }), }), ], routes: {