/* * Copyright 2023 Harness, Inc. * * 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, { useMemo, useState } from 'react' import cx from 'classnames' import * as yup from 'yup' import { Button, ButtonVariation, Container, FlexExpander, FormInput, Formik, FormikForm, Layout, SelectOption, SplitButton, Text, useToaster } from '@harnessio/uicore' import { Color, FontVariation } from '@harnessio/design-system' import { Menu, PopoverPosition } from '@blueprintjs/core' import { Icon } from '@harnessio/icons' import { useHistory } from 'react-router-dom' import { useGet, useMutate } from 'restful-react' import { BranchTargetType, SettingTypeMode, SettingsTab, branchTargetOptions } from 'utils/GitUtils' import { useStrings } from 'framework/strings' import { REGEX_VALID_REPO_NAME, getErrorMessage, permissionProps, rulesFormInitialPayload } from 'utils/Utils' import type { TypesRepository, OpenapiRule, TypesPrincipalInfo, EnumMergeMethod, ProtectionPattern, ProtectionBranch } from 'services/code' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { useAppContext } from 'AppContext' import { useGetSpaceParam } from 'hooks/useGetSpaceParam' import ProtectionRulesForm from './ProtectionRulesForm/ProtectionRulesForm' import Include from '../../../icons/Include.svg?url' import Exclude from '../../../icons/Exclude.svg?url' import BypassList from './BypassList' import css from './BranchProtectionForm.module.scss' const BranchProtectionForm = (props: { ruleUid: string editMode: boolean repoMetadata?: TypesRepository | undefined refetchRules: () => void settingSectionMode: SettingTypeMode }) => { const { routes, routingId, standalone, hooks } = useAppContext() const { ruleId } = useGetRepositoryMetadata() const { showError, showSuccess } = useToaster() const { editMode = false, repoMetadata, ruleUid, refetchRules, settingSectionMode } = props const { getString } = useStrings() const { data: rule } = useGet({ path: `/api/v1/repos/${repoMetadata?.path}/+/rules/${ruleId}`, lazy: !repoMetadata && !ruleId }) const [searchTerm, setSearchTerm] = useState('') const [searchStatusTerm, setSearchStatusTerm] = useState('') const { mutate } = useMutate({ verb: 'POST', path: `/api/v1/repos/${repoMetadata?.path}/+/rules/` }) const { mutate: updateRule } = useMutate({ verb: 'PATCH', path: `/api/v1/repos/${repoMetadata?.path}/+/rules/${ruleId}` }) const { data: users } = useGet({ path: `/api/v1/principals`, queryParams: { query: searchTerm, type: 'user', accountIdentifier: routingId, debounce: 500 } }) // eslint-disable-next-line @typescript-eslint/no-explicit-any const transformDataToArray = (data: any) => { return Object.keys(data).map(key => { return { ...data[key] } }) } const transformUserArray = transformDataToArray(rule?.users || []) const usersArrayCurr = transformUserArray?.map(user => `${user.id} ${user.display_name}`) const [userArrayState, setUserArrayState] = useState(usersArrayCurr) const { data: statuses } = useGet({ path: `/api/v1/repos/${repoMetadata?.path}/+/checks/recent`, queryParams: { query: searchStatusTerm, debounce: 500 } }) const statusOptions: SelectOption[] = useMemo( () => statuses?.map(status => ({ value: status, label: status })) || [], [statuses] ) const userOptions: SelectOption[] = useMemo( () => users?.map(user => ({ value: `${user.id?.toString() as string} ${user.uid}`, label: (user.display_name || user.email) as string })) || [], [users] ) const handleSubmit = async (operation: Promise, successMessage: string, resetForm: () => void) => { try { await operation showSuccess(successMessage) resetForm() history.push( routes.toCODESettings({ repoPath: repoMetadata?.path as string, settingSection: SettingsTab.branchProtection }) ) refetchRules?.() } catch (exception) { showError(getErrorMessage(exception)) } } const history = useHistory() const initialValues = useMemo(() => { if (editMode && rule) { const minReviewerCheck = ((rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count as number) > 0 ? true : false const isMergePresent = (rule.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed?.includes( 'merge' ) const isSquashPresent = (rule.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed?.includes( 'squash' ) const isRebasePresent = (rule.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed?.includes( 'rebase' ) // List of strings to be included in the final array const includeList = (rule?.pattern as ProtectionPattern)?.include ?? [] const excludeList = (rule?.pattern as ProtectionPattern)?.exclude ?? [] // Create a new array based on the "include" key from the JSON object and the strings array const includeArr = includeList?.map((arr: string) => ['include', arr]) const excludeArr = excludeList?.map((arr: string) => ['exclude', arr]) const finalArray = [...includeArr, ...excludeArr] const usersArray = transformDataToArray(rule.users) const bypassList = userArrayState.length > 0 ? userArrayState : usersArray?.map(user => `${user.id} ${user.display_name}`) return { name: rule?.uid, desc: rule.description, enable: rule.state !== 'disabled', target: '', targetDefault: (rule?.pattern as ProtectionPattern)?.default, targetList: finalArray, allRepoOwners: (rule.definition as ProtectionBranch)?.bypass?.repo_owners, bypassList: bypassList, requireMinReviewers: minReviewerCheck, minReviewers: minReviewerCheck ? (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count : '', requireCodeOwner: (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_code_owners, requireNewChanges: (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_latest_commit, reqResOfChanges: (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_no_change_request, requireCommentResolution: (rule.definition as ProtectionBranch)?.pullreq?.comments?.require_resolve_all, // eslint-disable-next-line @typescript-eslint/no-explicit-any requireStatusChecks: (rule.definition as any)?.pullreq?.status_checks?.require_uids?.length > 0, statusChecks: (rule.definition as ProtectionBranch)?.pullreq?.status_checks?.require_uids || ([] as string[]), limitMergeStrategies: !!(rule.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed, mergeCommit: isMergePresent, squashMerge: isSquashPresent, rebaseMerge: isRebasePresent, autoDelete: (rule.definition as ProtectionBranch)?.pullreq?.merge?.delete_branch, blockBranchCreation: (rule.definition as ProtectionBranch)?.lifecycle?.create_forbidden, blockBranchDeletion: (rule.definition as ProtectionBranch)?.lifecycle?.delete_forbidden, requirePr: (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden, targetSet: false, bypassSet: false } } return rulesFormInitialPayload // eslint-disable-next-line react-hooks/exhaustive-deps }, [editMode, rule, ruleUid, users]) const space = useGetSpaceParam() const permPushResult = hooks?.usePermissionTranslate?.( { resource: { resourceType: 'CODE_REPOSITORY' }, permissions: ['code_repo_edit'] }, [space] ) return ( { const stratArray = [ formData.squashMerge && 'squash', formData.rebaseMerge && 'rebase', formData.mergeCommit && 'merge' ].filter(Boolean) as EnumMergeMethod[] const includeArray = formData?.targetList?.filter(([type]) => type === 'include').map(([, value]) => value) ?? [] const excludeArray = formData?.targetList?.filter(([type]) => type === 'exclude').map(([, value]) => value) ?? [] const bypassList = formData?.bypassList?.map(item => parseInt(item.split(' ')[0])) const payload: OpenapiRule = { uid: formData.name, type: 'branch', description: formData.desc, state: formData.enable === true ? 'active' : 'disabled', pattern: { default: formData.targetDefault, exclude: excludeArray, include: includeArray }, definition: { bypass: { user_ids: bypassList, repo_owners: formData.allRepoOwners }, pullreq: { approvals: { require_code_owners: formData.requireCodeOwner, require_minimum_count: parseInt(formData.minReviewers as string), require_latest_commit: formData.requireNewChanges, require_no_change_request: formData.reqResOfChanges }, comments: { require_resolve_all: formData.requireCommentResolution }, merge: { strategies_allowed: stratArray, delete_branch: formData.autoDelete }, status_checks: { require_uids: formData.statusChecks } }, lifecycle: { create_forbidden: formData.blockBranchCreation, delete_forbidden: formData.blockBranchDeletion, update_forbidden: formData.requirePr } } } if (!formData.limitMergeStrategies) { delete (payload?.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed } if (!formData.requireMinReviewers) { delete (payload?.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count } if (editMode) { handleSubmit(updateRule(payload), getString('branchProtection.ruleUpdated'), resetForm) } else { handleSubmit(mutate(payload), getString('branchProtection.ruleCreated'), resetForm) } }}> {formik => { const targetList = settingSectionMode === SettingTypeMode.EDIT || formik.values.targetSet ? formik.values.targetList : [] const bypassList = settingSectionMode === SettingTypeMode.EDIT || formik.values.bypassSet ? formik.values.bypassList : [] const minReviewers = formik.values.requireMinReviewers const statusChecks = formik.values.statusChecks const limitMergeStrats = formik.values.limitMergeStrategies const requireStatusChecks = formik.values.requireStatusChecks const filteredUserOptions = userOptions.filter( (item: SelectOption) => !bypassList.includes(item.value as string) ) return ( {editMode ? getString('branchProtection.edit') : getString('branchProtection.create')}
{getString('branchProtection.targetBranches')} } placeholder={getString('branchProtection.targetPlaceholder')} tooltipProps={{ dataTooltipId: 'branchProtectionTarget' }} className={cx(css.widthContainer, css.targetSpacingContainer, css.label)} /> {branchTargetOptions[0].title} } popoverProps={{ interactionKind: 'click', usePortal: true, popoverClassName: css.popover, position: PopoverPosition.BOTTOM_RIGHT }} onClick={() => { if (formik.values.target !== '') { formik.setFieldValue('targetSet', true) targetList.push([BranchTargetType.INCLUDE, formik.values.target]) formik.setFieldValue('targetList', targetList) formik.setFieldValue('target', '') } }}> {[branchTargetOptions[1]].map(option => { return ( {option.title}} onClick={() => { if (formik.values.target !== '') { formik.setFieldValue('targetSet', true) targetList.push([BranchTargetType.EXCLUDE, formik.values.target]) formik.setFieldValue('targetList', targetList) formik.setFieldValue('target', '') } }} /> ) })}
{getString('branchProtection.targetPatternHint')} {targetList.map((target, idx) => { return ( {target[0] === BranchTargetType.INCLUDE ? ( ) : ( )} {target[1]} { const filteredData = targetList.filter( item => !(item[0] === target[0] && item[1] === target[1]) ) formik.setFieldValue('targetList', filteredData) }} className={css.codeClose} /> ) })}
{getString('branchProtection.bypassList')} { const id = item.value?.toString().split(' ')[0] const displayName = item.label const bypassEntry = `${id} ${displayName}` bypassList?.push(bypassEntry) const uniqueArr = Array.from(new Set(bypassList)) formik.setFieldValue('bypassList', uniqueArr) formik.setFieldValue('bypassSet', true) setUserArrayState([...uniqueArr]) }} name={'bypassSelect'}>