diff --git a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx index 6c63f25ca..629d106d5 100644 --- a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx +++ b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx @@ -37,7 +37,13 @@ import { useHistory } from 'react-router-dom' import { useGet, useMutate } from 'restful-react' import { BranchTargetType, MergeStrategy, SettingTypeMode, SettingsTab, branchTargetOptions } from 'utils/GitUtils' import { useStrings } from 'framework/strings' -import { REGEX_VALID_REPO_NAME, getErrorMessage, permissionProps, rulesFormInitialPayload } from 'utils/Utils' +import { + REGEX_VALID_REPO_NAME, + RulesFormPayload, + getErrorMessage, + permissionProps, + rulesFormInitialPayload +} from 'utils/Utils' import type { RepoRepositoryOutput, OpenapiRule, @@ -147,7 +153,7 @@ const BranchProtectionForm = (props: { } const history = useHistory() - const initialValues = useMemo(() => { + const initialValues = useMemo((): RulesFormPayload => { if (editMode && rule) { const minReviewerCheck = ((rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count as number) > 0 ? true : false @@ -197,8 +203,16 @@ const BranchProtectionForm = (props: { rebaseMerge: isRebasePresent, autoDelete: (rule.definition as ProtectionBranch)?.pullreq?.merge?.delete_branch, blockBranchCreation: (rule.definition as ProtectionBranch)?.lifecycle?.create_forbidden, + blockBranchUpdate: + (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden && + (rule.definition as ProtectionBranch)?.pullreq?.merge?.block, blockBranchDeletion: (rule.definition as ProtectionBranch)?.lifecycle?.delete_forbidden, - requirePr: (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden, + blockForcePush: + (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden || + (rule.definition as ProtectionBranch)?.lifecycle?.update_force_forbidden, + requirePr: + (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden && + !(rule.definition as ProtectionBranch)?.pullreq?.merge?.block, targetSet: false, bypassSet: false } @@ -218,7 +232,7 @@ const BranchProtectionForm = (props: { [space] ) return ( - formName="branchProtectionRulesNewEditForm" initialValues={initialValues} enableReinitialize @@ -265,7 +279,8 @@ const BranchProtectionForm = (props: { }, merge: { strategies_allowed: stratArray, - delete_branch: formData.autoDelete + delete_branch: formData.autoDelete, + block: formData.blockBranchUpdate }, status_checks: { require_identifiers: formData.statusChecks @@ -274,7 +289,8 @@ const BranchProtectionForm = (props: { lifecycle: { create_forbidden: formData.blockBranchCreation, delete_forbidden: formData.blockBranchDeletion, - update_forbidden: formData.requirePr + update_forbidden: formData.requirePr || formData.blockBranchUpdate, + update_force_forbidden: formData.blockForcePush && !formData.requirePr && !formData.blockBranchUpdate } } } @@ -304,7 +320,7 @@ const BranchProtectionForm = (props: { const requireStatusChecks = formik.values.requireStatusChecks const filteredUserOptions = userOptions.filter( - (item: SelectOption) => !bypassList.includes(item.value as string) + (item: SelectOption) => !bypassList?.includes(item.value as string) ) return ( @@ -394,7 +410,7 @@ const BranchProtectionForm = (props: { if (formik.values.target !== '') { formik.setFieldValue('targetSet', true) - targetList.push([BranchTargetType.INCLUDE, formik.values.target]) + targetList.push([BranchTargetType.INCLUDE, formik.values.target ?? '']) formik.setFieldValue('targetList', targetList) formik.setFieldValue('target', '') } @@ -409,7 +425,7 @@ const BranchProtectionForm = (props: { if (formik.values.target !== '') { formik.setFieldValue('targetSet', true) - targetList.push([BranchTargetType.EXCLUDE, formik.values.target]) + targetList.push([BranchTargetType.EXCLUDE, formik.values.target ?? '']) formik.setFieldValue('targetList', targetList) formik.setFieldValue('target', '') } @@ -473,7 +489,7 @@ const BranchProtectionForm = (props: { void setSearchStatusTerm: React.Dispatch> + formik: FormikProps }) => { const { - setFieldValue, statusChecks, setSearchStatusTerm, minReviewers, requireStatusChecks, statusOptions, - limitMergeStrats + limitMergeStrats, + formik } = props const { getString } = useStrings() + const setFieldValue = formik.setFieldValue const filteredStatusOptions = statusOptions.filter( (item: SelectOption) => !statusChecks.includes(item.value as string) ) + const { values } = formik return ( @@ -68,16 +72,76 @@ const ProtectionRulesForm = (props: { {getString('branchProtection.blockBranchDeletionText')} +
{ + setFieldValue('blockForcePush', !(values.blockBranchUpdate && values.blockForcePush)) + setFieldValue('requirePr', false) + }} /> - {getString('branchProtection.requirePrText')} + {getString('branchProtection.blockBranchUpdateText')} +
+ + + {values.requirePr ? getString('pushBlockedMessage') : getString('ruleBlockedMessage')} + +
+ }> + <> + + + {getString('branchProtection.blockForcePushText')} + + + + +
+ + + {getString('ruleBlockedMessage')} + + + }> + <> + { + setFieldValue('blockForcePush', !values.requirePr) + }} + /> + + {getString('branchProtection.requirePrText')} + + + +
{ const { data: rules, refetch: refetchRules, + loading: loadingRules, response } = useGet({ path: `/api/v1/repos/${repoMetadata?.path}/+/rules`, @@ -87,6 +98,89 @@ const BranchProtectionListing = (props: { activeTab: string }) => { lazy: !repoMetadata || !!editRule }) + const branchProtectionRules = { + requireMinReviewersTitle: { + title: getString('branchProtection.requireMinReviewersTitle'), + requiredRule: { + [RuleFields.APPROVALS_REQUIRE_MINIMUM_COUNT]: true + } + }, + reqReviewFromCodeOwnerTitle: { + title: getString('branchProtection.reqReviewFromCodeOwnerTitle'), + requiredRule: { + [RuleFields.APPROVALS_REQUIRE_CODE_OWNERS]: true + } + }, + reqResOfChanges: { + title: getString('branchProtection.reqResOfChanges'), + requiredRule: { + [RuleFields.APPROVALS_REQUIRE_NO_CHANGE_REQUEST]: true + } + }, + reqNewChangesTitle: { + title: getString('branchProtection.reqNewChangesTitle'), + requiredRule: { + [RuleFields.APPROVALS_REQUIRE_LATEST_COMMIT]: true + } + }, + reqCommentResolutionTitle: { + title: getString('branchProtection.reqCommentResolutionTitle'), + requiredRule: { + [RuleFields.COMMENTS_REQUIRE_RESOLVE_ALL]: true + } + }, + reqStatusChecksTitle: { + title: getString('branchProtection.reqStatusChecksTitle'), + requiredRule: { + [RuleFields.STATUS_CHECKS_ALL_MUST_SUCCEED]: true + } + }, + limitMergeStrategies: { + title: getString('branchProtection.limitMergeStrategies'), + requiredRule: { + [RuleFields.MERGE_STRATEGIES_ALLOWED]: true + } + }, + autoDeleteTitle: { + title: getString('branchProtection.autoDeleteTitle'), + requiredRule: { + [RuleFields.MERGE_DELETE_BRANCH]: true + } + }, + blockBranchCreation: { + title: getString('branchProtection.blockBranchCreation'), + requiredRule: { + [RuleFields.LIFECYCLE_CREATE_FORBIDDEN]: true + } + }, + blockBranchDeletion: { + title: getString('branchProtection.blockBranchDeletion'), + requiredRule: { + [RuleFields.LIFECYCLE_DELETE_FORBIDDEN]: true + } + }, + blockBranchUpdate: { + title: getString('branchProtection.blockBranchUpdate'), + requiredRule: { + [RuleFields.MERGE_BLOCK]: true, + [RuleFields.LIFECYCLE_UPDATE_FORBIDDEN]: true + } + }, + requirePr: { + title: getString('branchProtection.requirePr'), + requiredRule: { + [RuleFields.LIFECYCLE_UPDATE_FORBIDDEN]: true, + [RuleFields.MERGE_BLOCK]: false + } + }, + blockForcePush: { + title: getString('branchProtection.blockForcePush'), + requiredRule: { + [RuleFields.LIFECYCLE_UPDATE_FORCE_FORBIDDEN]: true + } + } + } + const columns: Column[] = useMemo( () => [ { @@ -137,48 +231,35 @@ const BranchProtectionListing = (props: { activeTab: string }) => { ) - type Rule = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - } - - const fieldsToCheck = { - 'pullreq.approvals.require_minimum_count': getString('branchProtection.requireMinReviewersTitle'), - 'pullreq.approvals.require_code_owners': getString('branchProtection.reqReviewFromCodeOwnerTitle'), - 'pullreq.approvals.require_no_change_request': getString('branchProtection.reqResOfChanges'), - 'pullreq.approvals.require_latest_commit': getString('branchProtection.reqNewChangesTitle'), - 'pullreq.comments.require_resolve_all': getString('branchProtection.reqCommentResolutionTitle'), - 'pullreq.status_checks.all_must_succeed': getString('branchProtection.reqStatusChecksTitle'), - 'pullreq.status_checks.require_identifiers': getString('branchProtection.reqStatusChecksTitle'), - 'pullreq.merge.strategies_allowed': getString('branchProtection.limitMergeStrategies'), - 'pullreq.merge.delete_branch': getString('branchProtection.autoDeleteTitle'), - 'lifecycle.create_forbidden': getString('branchProtection.blockBranchCreation'), - 'lifecycle.delete_forbidden': getString('branchProtection.blockBranchDeletion'), - 'lifecycle.update_forbidden': getString('branchProtection.requirePr') - } - type NonEmptyRule = { field: string // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any } - const checkFieldsNotEmpty = (rulesArr: Rule, fields: { [key: string]: string }): NonEmptyRule[] => { + const checkAppliedRules = (rulesData: Rule, rulesList: BranchProtectionRulesMapType): NonEmptyRule[] => { const nonEmptyFields: NonEmptyRule[] = [] - for (const field in fields) { - const keys = field.split('.') - let value = rulesArr - for (const key of keys) { - value = value[key] - if (value == null) break - } - if (value !== undefined && (Array.isArray(value) ? value.length > 0 : true)) { - nonEmptyFields.push({ field, value: fields[field] }) // Use value from fieldsToCheck + const rulesDefinitionData: Record = createRuleFieldsMap(rulesData) + for (const [key, rule] of Object.entries(rulesList)) { + const { title, requiredRule } = rule + const isApplicable = Object.entries(requiredRule).every(([ruleField, requiredValue]) => { + const ruleFieldEnum = ruleField as RuleFields + const actualValue = rulesDefinitionData[ruleFieldEnum] + if (requiredValue) return actualValue + return !actualValue + }) + if (isApplicable) { + nonEmptyFields.push({ + field: key, + value: title + }) } } + return nonEmptyFields } - const nonEmptyRules = checkFieldsNotEmpty(row.original.definition as Rule, fieldsToCheck) + const nonEmptyRules = checkAppliedRules(row.original.definition as Rule, branchProtectionRules) + const { hooks, standalone } = useAppContext() const space = useGetSpaceParam() @@ -346,7 +427,7 @@ const BranchProtectionListing = (props: { activeTab: string }) => { {nonEmptyRules.map((rule: { value: string }) => { return ( {rule.value} @@ -386,6 +467,7 @@ const BranchProtectionListing = (props: { activeTab: string }) => { ) return ( + {repoMetadata && !newRule && !editRule && ( { const [minReqLatestApproval, setMinReqLatestApproval] = useState(0) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [resolvedCommentArr, setResolvedCommentArr] = useState() + const [mergeBlockedRule, setMergeBlockedRule] = useState(false) const [PRStateLoading, setPRStateLoading] = useState(isClosed ? false : true) const { pullRequestSection } = useGetRepositoryMetadata() const mergeable = useMemo(() => pullReqMetadata.merge_check_status === MergeCheckStatus.MERGEABLE, [pullReqMetadata]) @@ -199,9 +200,13 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { useEffect(() => { if (ruleViolationArr) { const requireResCommentRule = extractSpecificViolations(ruleViolationArr, 'pullreq.comments.require_resolve_all') + const mergeBlockedViaRule = extractSpecificViolations(ruleViolationArr, 'pullreq.merge.blocked') if (requireResCommentRule) { setResolvedCommentArr(requireResCommentRule[0]) } + setMergeBlockedRule(mergeBlockedViaRule.length > 0) + } else { + setMergeBlockedRule(false) } }, [ruleViolationArr, pullReqMetadata, repoMetadata, data, ruleViolation]) @@ -232,7 +237,7 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { }, [unchecked, pullReqMetadata?.source_sha, activities]) const rebasePossible = useMemo( - () => pullReqMetadata.merge_target_sha !== pullReqMetadata.merge_base_sha, + () => pullReqMetadata.merge_target_sha !== pullReqMetadata.merge_base_sha && !pullReqMetadata.merged, [pullReqMetadata] ) @@ -278,6 +283,7 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { minReqLatestApproval={minReqLatestApproval} reqCodeOwnerLatestApproval={reqCodeOwnerLatestApproval} refetchCodeOwners={refetchCodeOwners} + mergeBlockedRule={mergeBlockedRule} /> ), diff --git a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx index 335e8e429..ad8878b69 100644 --- a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx +++ b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx @@ -59,6 +59,7 @@ interface ChangesSectionProps { reviewers: TypesPullReqReviewer[] | null minReqLatestApproval: number reqCodeOwnerLatestApproval: boolean + mergeBlockedRule: boolean loadingReviewers: boolean refetchReviewers: () => void refetchCodeOwners: () => void @@ -76,6 +77,7 @@ const ChangesSection = (props: ChangesSectionProps) => { reqCodeOwnerLatestApproval, minReqLatestApproval, loadingReviewers, + mergeBlockedRule, refetchReviewers, refetchCodeOwners } = props @@ -91,7 +93,7 @@ const ChangesSection = (props: ChangesSectionProps) => { const reviewers = useMemo(() => { refetchCodeOwners() return currReviewers // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currReviewers, refetchReviewers]) + }, [currReviewers, refetchReviewers, mergeBlockedRule]) const codeOwners = useMemo(() => { return currCodeOwners // eslint-disable-next-line react-hooks/exhaustive-deps @@ -144,9 +146,15 @@ const ChangesSection = (props: ChangesSectionProps) => { reqCodeOwnerApproval || minApproval > 0 || reqCodeOwnerLatestApproval || - minReqLatestApproval > 0 + minReqLatestApproval > 0 || + mergeBlockedRule ) { - if (codeOwnerChangeReqEntries.length > 0 && (reqCodeOwnerApproval || reqCodeOwnerLatestApproval)) { + if (mergeBlockedRule) { + title = getString('changesSection.prMergeBlockedTitle') + // statusMessage = getString('changesSection.prMergeBlockedMessage') + statusColor = Color.RED_700 + statusIcon = 'warning-icon' + } else if (codeOwnerChangeReqEntries.length > 0 && (reqCodeOwnerApproval || reqCodeOwnerLatestApproval)) { title = getString('changesSection.reqChangeFromCodeOwners') statusMessage = getString('changesSection.codeOwnerReqChanges') statusColor = Color.RED_700 @@ -258,7 +266,9 @@ const ChangesSection = (props: ChangesSectionProps) => { reqCodeOwnerLatestApproval, minReqLatestApproval, refetchReviewers, - refetchCodeOwners + refetchCodeOwners, + mergeBlockedRule, + approvedEvaluations ]) function renderCodeOwnerStatus() { @@ -376,13 +386,14 @@ const ChangesSection = (props: ChangesSectionProps) => { ) } const viewBtn = - minApproval > minReqLatestApproval || - (!isEmpty(approvedEvaluations) && minReqLatestApproval === 0) || - (minApproval > 0 && minReqLatestApproval === undefined) || - minReqLatestApproval > 0 || - !isEmpty(changeReqEvaluations) || - !isEmpty(codeOwners) || - false + !mergeBlockedRule && + (minApproval > minReqLatestApproval || + (!isEmpty(approvedEvaluations) && minReqLatestApproval === 0) || + (minApproval > 0 && minReqLatestApproval === undefined) || + minReqLatestApproval > 0 || + !isEmpty(changeReqEvaluations) || + !isEmpty(codeOwners) || + false) return ( @@ -399,7 +410,7 @@ const ChangesSection = (props: ChangesSectionProps) => { )} + +export type Rule = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any +} + +export type BranchProtectionRule = { + title: string + requiredRule: { + [key in RuleFields]?: boolean + } +} + +export type BranchProtectionRulesMapType = Record + +export function createRuleFieldsMap(ruleDefinition: Rule): RuleFieldsMap { + const ruleFieldsMap: RuleFieldsMap = { + [RuleFields.APPROVALS_REQUIRE_MINIMUM_COUNT]: false, + [RuleFields.APPROVALS_REQUIRE_CODE_OWNERS]: false, + [RuleFields.APPROVALS_REQUIRE_NO_CHANGE_REQUEST]: false, + [RuleFields.APPROVALS_REQUIRE_LATEST_COMMIT]: false, + [RuleFields.COMMENTS_REQUIRE_RESOLVE_ALL]: false, + [RuleFields.STATUS_CHECKS_ALL_MUST_SUCCEED]: false, + [RuleFields.STATUS_CHECKS_REQUIRE_IDENTIFIERS]: false, + [RuleFields.MERGE_STRATEGIES_ALLOWED]: false, + [RuleFields.MERGE_DELETE_BRANCH]: false, + [RuleFields.LIFECYCLE_CREATE_FORBIDDEN]: false, + [RuleFields.LIFECYCLE_DELETE_FORBIDDEN]: false, + [RuleFields.MERGE_BLOCK]: false, + [RuleFields.LIFECYCLE_UPDATE_FORBIDDEN]: false, + [RuleFields.LIFECYCLE_UPDATE_FORCE_FORBIDDEN]: false + } + if (ruleDefinition.pullreq) { + if (ruleDefinition.pullreq.approvals) { + ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_CODE_OWNERS] = !!ruleDefinition.pullreq.approvals.require_code_owners + ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_LATEST_COMMIT] = + !!ruleDefinition.pullreq.approvals.require_latest_commit + ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_MINIMUM_COUNT] = + typeof ruleDefinition.pullreq.approvals.require_minimum_count === 'number' + ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_NO_CHANGE_REQUEST] = + !!ruleDefinition.pullreq.approvals.require_no_change_request + } + + if (ruleDefinition.pullreq.comments) { + ruleFieldsMap[RuleFields.COMMENTS_REQUIRE_RESOLVE_ALL] = !!ruleDefinition.pullreq.comments.require_resolve_all + } + + if (ruleDefinition.pullreq.merge) { + ruleFieldsMap[RuleFields.MERGE_BLOCK] = !!ruleDefinition.pullreq.merge.block + ruleFieldsMap[RuleFields.MERGE_DELETE_BRANCH] = !!ruleDefinition.pullreq.merge.delete_branch + ruleFieldsMap[RuleFields.MERGE_STRATEGIES_ALLOWED] = + Array.isArray(ruleDefinition.pullreq.merge.strategies_allowed) && + ruleDefinition.pullreq.merge.strategies_allowed.length > 0 + } + + if (ruleDefinition.pullreq.status_checks) { + ruleFieldsMap[RuleFields.STATUS_CHECKS_REQUIRE_IDENTIFIERS] = + Array.isArray(ruleDefinition.pullreq.status_checks.require_identifiers) && + ruleDefinition.pullreq.status_checks.require_identifiers.length > 0 + } + } + + if (ruleDefinition.lifecycle) { + ruleFieldsMap[RuleFields.LIFECYCLE_CREATE_FORBIDDEN] = !!ruleDefinition.lifecycle.create_forbidden + ruleFieldsMap[RuleFields.LIFECYCLE_DELETE_FORBIDDEN] = !!ruleDefinition.lifecycle.delete_forbidden + ruleFieldsMap[RuleFields.LIFECYCLE_UPDATE_FORBIDDEN] = !!ruleDefinition.lifecycle.update_forbidden + ruleFieldsMap[RuleFields.LIFECYCLE_UPDATE_FORCE_FORBIDDEN] = !!ruleDefinition.lifecycle.update_force_forbidden + } + + return ruleFieldsMap +}