import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGet, useMutate } from 'restful-react' import { useParams } from 'react-router-dom' import { get, isEmpty, isUndefined, set } from 'lodash-es' import { parse, stringify } from 'yaml' import cx from 'classnames' import { Menu, PopoverPosition } from '@blueprintjs/core' import { Container, PageBody, Layout, ButtonVariation, Text, useToaster, SplitButton, Button } from '@harnessio/uicore' import { Color, FontVariation } from '@harnessio/design-system' import type { OpenapiCommitFilesRequest, RepoCommitFilesResponse, RepoFileContent, TypesPipeline } from 'services/code' import { useStrings } from 'framework/strings' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useGetResourceContent } from 'hooks/useGetResourceContent' import MonacoSourceCodeEditor from 'components/SourceCodeEditor/MonacoSourceCodeEditor' import { PluginForm, PluginsPanel } from 'components/PluginsPanel/PluginsPanel' import useRunPipelineModal from 'components/RunPipelineModal/RunPipelineModal' import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner' import { useAppContext } from 'AppContext' import type { CODEProps } from 'RouteDefinitions' import { getErrorMessage } from 'utils/Utils' import { decodeGitContent } from 'utils/GitUtils' import { RepositoryPageHeader } from 'components/RepositoryPageHeader/RepositoryPageHeader' import pipelineSchemaV1 from './schema/pipeline-schema-v1.json' import pipelineSchemaV0 from './schema/pipeline-schema-v0.json' import { DRONE_CONFIG_YAML_FILE_SUFFIXES, YamlVersion } from './Constants' import css from './AddUpdatePipeline.module.scss' const StarterPipelineV1: Record = { version: 1, kind: 'pipeline', spec: { stages: [ { name: 'build', type: 'ci', spec: { steps: [ { name: 'build', type: 'run', spec: { container: 'alpine', script: 'echo "hello world"' } } ] } } ] } } const StarterPipelineV0: Record = { kind: 'pipeline', type: 'docker', name: 'default', steps: [ { name: 'test', image: 'alpine', commands: ['echo hello world'] } ] } enum PipelineSaveAndRunAction { SAVE, RUN, SAVE_AND_RUN } interface PipelineSaveAndRunOption { title: string action: PipelineSaveAndRunAction } const AddUpdatePipeline = (): JSX.Element => { const { routes } = useAppContext() const { getString } = useStrings() const { pipeline } = useParams() const { repoMetadata } = useGetRepositoryMetadata() const { showError, showSuccess, clear: clearToaster } = useToaster() const [yamlVersion, setYAMLVersion] = useState() const [pipelineAsYAML, setPipelineAsYaml] = useState('') const { openModal: openRunPipelineModal } = useRunPipelineModal() const repoPath = useMemo(() => repoMetadata?.path || '', [repoMetadata]) const [isExistingPipeline, setIsExistingPipeline] = useState(false) const [isDirty, setIsDirty] = useState(false) const [generatingPipeline, setGeneratingPipeline] = useState(false) const pipelineAsYAMLRef = useRef('') const pipelineSaveOption: PipelineSaveAndRunOption = { title: getString('save'), action: PipelineSaveAndRunAction.SAVE } const pipelineRunOption: PipelineSaveAndRunOption = { title: getString('run'), action: PipelineSaveAndRunAction.RUN } const pipelineSaveAndRunOption: PipelineSaveAndRunOption = { title: getString('pipelines.saveAndRun'), action: PipelineSaveAndRunAction.SAVE_AND_RUN } const pipelineSaveAndRunOptions: PipelineSaveAndRunOption[] = [pipelineSaveAndRunOption, pipelineSaveOption] const [selectedOption, setSelectedOption] = useState() const { mutate, loading } = useMutate({ verb: 'POST', path: `/api/v1/repos/${repoPath}/+/commits` }) // Fetch pipeline metadata to fetch pipeline YAML file content const { data: pipelineData, loading: fetchingPipeline } = useGet({ path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`, lazy: !repoMetadata }) const { data: pipelineYAMLFileContent, loading: fetchingPipelineYAMLFileContent, refetch: fetchPipelineYAMLFileContent } = useGetResourceContent({ repoMetadata, gitRef: pipelineData?.default_branch || '', resourcePath: pipelineData?.config_path || '' }) const originalPipelineYAMLFileContent = useMemo( (): string => decodeGitContent((pipelineYAMLFileContent?.content as RepoFileContent)?.data), [pipelineYAMLFileContent?.content] ) // set YAML version for Pipeline setup useEffect(() => { setYAMLVersion( DRONE_CONFIG_YAML_FILE_SUFFIXES.find((suffix: string) => pipelineData?.config_path?.endsWith(suffix)) ? YamlVersion.V0 : YamlVersion.V1 ) }, [pipelineData]) // check if file already exists and has some content useEffect(() => { setIsExistingPipeline(!isEmpty(originalPipelineYAMLFileContent) && !isUndefined(originalPipelineYAMLFileContent)) }, [originalPipelineYAMLFileContent]) // load initial content on the editor useEffect(() => { if (isExistingPipeline) { setPipelineAsYaml(originalPipelineYAMLFileContent) } else { // load with starter pipeline try { setPipelineAsYaml(stringify(yamlVersion === YamlVersion.V1 ? StarterPipelineV1 : StarterPipelineV0)) } catch (ex) { // ignore exception } } }, [yamlVersion, isExistingPipeline, originalPipelineYAMLFileContent]) // find if editor content was modified useEffect(() => { setIsDirty(originalPipelineYAMLFileContent !== pipelineAsYAML) }, [originalPipelineYAMLFileContent, pipelineAsYAML]) // set initial CTA title useEffect(() => { setSelectedOption(isDirty ? pipelineSaveAndRunOption : pipelineRunOption) }, [isDirty]) const handleSaveAndRun = (option: PipelineSaveAndRunOption): void => { if ([PipelineSaveAndRunAction.SAVE_AND_RUN, PipelineSaveAndRunAction.SAVE].includes(option?.action)) { try { const data: OpenapiCommitFilesRequest = { actions: [ { action: isExistingPipeline ? 'UPDATE' : 'CREATE', path: pipelineData?.config_path, payload: pipelineAsYAML, sha: isExistingPipeline ? pipelineYAMLFileContent?.sha : '' } ], branch: pipelineData?.default_branch || '', title: `${isExistingPipeline ? getString('updated') : getString('created')} pipeline ${pipeline}`, message: '' } mutate(data) .then(() => { fetchPipelineYAMLFileContent() clearToaster() showSuccess(getString(isExistingPipeline ? 'pipelines.updated' : 'pipelines.created')) if (option?.action === PipelineSaveAndRunAction.SAVE_AND_RUN && repoMetadata && pipeline) { openRunPipelineModal({ repoMetadata, pipeline }) } setSelectedOption(pipelineRunOption) }) .catch(error => { showError(getErrorMessage(error), 0, 'pipelines.failedToSavePipeline') }) } catch (exception) { showError(getErrorMessage(exception), 0, 'pipelines.failedToSavePipeline') } } } const updatePipelineWithPluginData = ( existingPipeline: Record, payload: Record ): Record => { const pipelineAsObjClone = { ...existingPipeline } if (Object.keys(pipelineAsObjClone).length > 0) { const stepInsertPath = 'spec.stages.0.spec.steps' let existingSteps = get(pipelineAsObjClone, stepInsertPath, []) as unknown[] if (existingSteps.length > 0) { existingSteps.push(payload) } else { existingSteps = [payload] } set(pipelineAsObjClone, stepInsertPath, existingSteps) return pipelineAsObjClone } return existingPipeline } const handlePluginAddUpdateToPipeline = useCallback( ({ pluginFormData, existingYAML }: { isUpdate: boolean pluginFormData: PluginForm existingYAML: string }): void => { try { const pipelineAsObj = parse(existingYAML) const updatedPipelineAsObj = updatePipelineWithPluginData(pipelineAsObj, pluginFormData) if (Object.keys(updatedPipelineAsObj).length > 0) { // avoid setting to empty pipeline in case pipeline update with plugin data fails const updatedPipelineAsYAML = stringify(updatedPipelineAsObj) setPipelineAsYaml(updatedPipelineAsYAML) pipelineAsYAMLRef.current = updatedPipelineAsYAML } } catch (ex) { // ignore exception } }, [] ) const handleGeneratePipeline = useCallback(async (): Promise => { try { const response = await fetch(`/api/v1/repos/${repoPath}/+/pipelines/generate`) if (response.ok && response.status === 200) { const responsePipelineAsYAML = await response.text() if (responsePipelineAsYAML) { setPipelineAsYaml(responsePipelineAsYAML) } } setGeneratingPipeline(false) } catch (exception) { showError(getErrorMessage(exception), 0, getString('pipelines.failedToGenerate')) setGeneratingPipeline(false) } }, [repoPath]) const renderCTA = useCallback(() => { /* Do not render CTA till pipeline existence info is obtained */ if (fetchingPipeline || !pipelineData) { return <> } switch (selectedOption?.action) { case PipelineSaveAndRunAction.RUN: return (