diff --git a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss index 0a139b77d..7090aac78 100644 --- a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss +++ b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss @@ -81,6 +81,10 @@ } } +.codeCloseBtn { + cursor: pointer !important; +} + .targetContainer { :global(.bp3-form-group) { margin-bottom: unset !important; @@ -187,3 +191,14 @@ flex-wrap: wrap; max-width: calc(100% - 100px) !important; } + +.reviewerBlock { + background-color: var(--primary-1) !important; + padding: 3px 10px !important; + gap: 5px !important; +} + +.defaultReviewerContainer { + gap: 10px; + flex-wrap: wrap; +} diff --git a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss.d.ts b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss.d.ts index bc5f21614..a1dd16994 100644 --- a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss.d.ts +++ b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.module.scss.d.ts @@ -22,6 +22,8 @@ export declare const checkboxLabel: string export declare const checkboxText: string export declare const checkContainer: string export declare const codeClose: string +export declare const codeCloseBtn: string +export declare const defaultReviewerContainer: string export declare const dividerContainer: string export declare const generalContainer: string export declare const greyButton: string @@ -35,6 +37,7 @@ export declare const minText: string export declare const noData: string export declare const paddingTop: string export declare const popover: string +export declare const reviewerBlock: string export declare const row: string export declare const statusWidthContainer: string export declare const table: string diff --git a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx index 54ad2866f..b6adb0fc9 100644 --- a/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx +++ b/web/src/components/BranchProtection/BranchProtectionForm/BranchProtectionForm.tsx @@ -35,7 +35,14 @@ 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, MergeStrategy, SettingTypeMode, SettingsTab, branchTargetOptions } from 'utils/GitUtils' +import { + BranchTargetType, + MergeStrategy, + PrincipalUserType, + SettingTypeMode, + SettingsTab, + branchTargetOptions +} from 'utils/GitUtils' import { useStrings } from 'framework/strings' import { LabelsPageScope, @@ -136,10 +143,19 @@ const BranchProtectionForm = (props: { } }) } - const transformUserArray = transformDataToArray(rule?.users || []) - const usersArrayCurr = transformUserArray?.map(user => `${user.id} ${user.display_name}`) + + const usersMap = rule?.users + + const bypassListUsers = rule?.definition?.bypass?.user_ids?.map(id => usersMap?.[id]) + const transformBypassListArray = transformDataToArray(bypassListUsers || []) + const usersArrayCurr = transformBypassListArray?.map(user => `${user.id} ${user.display_name}`) const [userArrayState, setUserArrayState] = useState(usersArrayCurr) + const defaultReviewersUsers = rule?.definition?.pullreq?.reviewers?.default_reviewer_ids?.map(id => usersMap?.[id]) + const transformDefaultReviewersArray = transformDataToArray(defaultReviewersUsers || []) + const reviewerArrayCurr = transformDefaultReviewersArray?.map(user => `${user.id} ${user.display_name}`) + const [defaultReviewersState, setDefaultReviewersState] = useState(reviewerArrayCurr) + const getUpdateChecksPath = () => currentRule?.scope === 0 && repoMetadata ? `/repos/${repoMetadata?.path}/+/checks/recent` @@ -173,6 +189,20 @@ const BranchProtectionForm = (props: { [principals] ) + const userPrincipalOptions: SelectOption[] = useMemo( + () => + principals?.reduce((acc, principal) => { + if (principal?.type === PrincipalUserType.USER) { + acc.push({ + value: `${principal.id?.toString() as string} ${principal.uid}`, + label: `${principal?.display_name} (${principal.email})` + }) + } + return acc + }, []) || [], + [principals] + ) + const handleSubmit = async (operation: Promise, successMessage: string, resetForm: () => void) => { try { await operation @@ -204,7 +234,10 @@ const BranchProtectionForm = (props: { const initialValues = useMemo((): RulesFormPayload => { if (editMode && rule) { const minReviewerCheck = - ((rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count as number) > 0 ? true : false + ((rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count as number) > 0 + const minDefaultReviewerCheck = + ((rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_default_reviewer_count as number) > + 0 const isMergePresent = (rule.definition as ProtectionBranch)?.pullreq?.merge?.strategies_allowed?.includes( MergeStrategy.MERGE ) @@ -224,12 +257,19 @@ const BranchProtectionForm = (props: { 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 usersArray = transformDataToArray(bypassListUsers || []) + const bypassList = userArrayState.length > 0 ? userArrayState : usersArray?.map(user => `${user.id} ${user.display_name} (${user.email})`) + const reviewersArray = transformDataToArray(defaultReviewersUsers || []) + const defaultReviewersList = + defaultReviewersState.length > 0 + ? defaultReviewersState + : reviewersArray?.map(user => `${user.id} ${user.display_name} (${user.email})`) + return { name: rule?.identifier, desc: rule.description, @@ -239,10 +279,16 @@ const BranchProtectionForm = (props: { targetList: finalArray, allRepoOwners: (rule.definition as ProtectionBranch)?.bypass?.repo_owners, bypassList: bypassList, + defaultReviewersEnabled: (rule.definition as any)?.pullreq?.reviewers?.default_reviewer_ids?.length > 0, + defaultReviewersList: defaultReviewersList, requireMinReviewers: minReviewerCheck, + requireMinDefaultReviewers: minDefaultReviewerCheck, minReviewers: minReviewerCheck ? (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count : '', + minDefaultReviewers: minDefaultReviewerCheck + ? (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_default_reviewer_count + : '', autoAddCodeOwner: (rule.definition as ProtectionBranch)?.pullreq?.reviewers?.request_code_owners, requireCodeOwner: (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_code_owners, requireNewChanges: (rule.definition as ProtectionBranch)?.pullreq?.approvals?.require_latest_commit, @@ -269,7 +315,8 @@ const BranchProtectionForm = (props: { (rule.definition as ProtectionBranch)?.lifecycle?.update_forbidden && !(rule.definition as ProtectionBranch)?.pullreq?.merge?.block, targetSet: false, - bypassSet: false + bypassSet: false, + defaultReviewersSet: false } } @@ -280,6 +327,14 @@ const BranchProtectionForm = (props: { getEditPermissionRequestFromScope(space, currentRule?.scope ?? 0, repoMetadata), [space, currentRule?.scope, repoMetadata] ) + + const defaultReviewerProps = { + setSearchTerm, + userPrincipalOptions, + settingSectionMode, + setDefaultReviewersState + } + return ( formName="branchProtectionRulesNewEditForm" @@ -287,7 +342,34 @@ const BranchProtectionForm = (props: { enableReinitialize validationSchema={yup.object().shape({ name: yup.string().trim().required().matches(REGEX_VALID_REPO_NAME, getString('validation.nameLogic')), - minReviewers: yup.number().typeError(getString('enterANumber')) + minReviewers: yup.number().typeError(getString('enterANumber')), + minDefaultReviewers: yup.number().typeError(getString('enterANumber')), + defaultReviewersList: yup + .array() + .of(yup.string()) + .test( + 'min-reviewers', // Name of the test + getString('branchProtection.atLeastMinReviewer', { count: 1 }), + function (defaultReviewersList) { + const { minDefaultReviewers, requireMinDefaultReviewers, defaultReviewersEnabled } = this.parent + const minReviewers = Number(minDefaultReviewers) || 0 + if (defaultReviewersEnabled && requireMinDefaultReviewers) { + const isValid = defaultReviewersList && defaultReviewersList.length >= minReviewers + + return ( + isValid || + this.createError({ + message: + minReviewers > 1 + ? getString('branchProtection.atLeastMinReviewers', { count: minReviewers }) + : getString('branchProtection.atLeastMinReviewer', { count: minReviewers }) + }) + ) + } + + return true + } + ) })} onSubmit={async (formData, { resetForm }) => { const stratArray = [ @@ -302,6 +384,7 @@ const BranchProtectionForm = (props: { formData?.targetList?.filter(([type]) => type === 'exclude').map(([, value]) => value) ?? [] const bypassList = formData?.bypassList?.map(item => parseInt(item.split(' ')[0])) + const defaultReviewersList = formData?.defaultReviewersList?.map(item => parseInt(item.split(' ')[0])) const payload: OpenapiRule = { identifier: formData.name, type: 'branch', @@ -321,11 +404,13 @@ const BranchProtectionForm = (props: { approvals: { require_code_owners: formData.requireCodeOwner, require_minimum_count: parseInt(formData.minReviewers as string), + require_minimum_default_reviewer_count: parseInt(formData.minDefaultReviewers as string), require_latest_commit: formData.requireNewChanges, require_no_change_request: formData.reqResOfChanges }, reviewers: { - request_code_owners: formData.autoAddCodeOwner + request_code_owners: formData.autoAddCodeOwner, + default_reviewer_ids: defaultReviewersList }, comments: { require_resolve_all: formData.requireCommentResolution @@ -356,6 +441,9 @@ const BranchProtectionForm = (props: { if (!formData.requireMinReviewers) { delete (payload?.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_count } + if (!formData.requireMinDefaultReviewers) { + delete (payload?.definition as ProtectionBranch)?.pullreq?.approvals?.require_minimum_default_reviewer_count + } if (editMode) { handleSubmit(updateRule(payload), getString('branchProtection.ruleUpdated'), resetForm) } else { @@ -549,6 +637,7 @@ const BranchProtectionForm = (props: { statusChecks={statusChecks} limitMergeStrats={limitMergeStrats} setSearchStatusTerm={setSearchStatusTerm} + defaultReviewerProps={defaultReviewerProps} /> diff --git a/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersList.tsx b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersList.tsx new file mode 100644 index 000000000..35a20873e --- /dev/null +++ b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersList.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react' +import cx from 'classnames' +import { Icon } from '@harnessio/icons' +import { Container, FlexExpander, Layout, Text } from '@harnessio/uicore' +import { Classes, Popover, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core' +import { Color, FontVariation } from '@harnessio/design-system' +import css from '../BranchProtectionForm.module.scss' + +const DefaultReviewersList = (props: { + defaultReviewersList?: string[] // eslint-disable-next-line @typescript-eslint/no-explicit-any + setFieldValue: (field: string, value: any, shouldValidate?: boolean | undefined) => void +}) => { + const { defaultReviewersList, setFieldValue } = props + const defaultReviewerContent = useMemo(() => { + return ( + + {defaultReviewersList?.map((owner: string, idx: number) => { + const str = owner.slice(owner.indexOf(' ') + 1) + const name = str.split(' (')[0] + const email = str.split(' (')[1].replace(')', '') + return ( + + + {email} + + + }> + + + {name} + + + { + const filteredData = defaultReviewersList.filter(item => !(item === owner)) + setFieldValue('defaultReviewersList', filteredData) + }} + className={css.codeCloseBtn} + /> + + + ) + })} + + ) + }, [defaultReviewersList, setFieldValue]) + return <>{defaultReviewerContent} +} + +export default DefaultReviewersList diff --git a/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersSection.tsx b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersSection.tsx new file mode 100644 index 000000000..11157f2ad --- /dev/null +++ b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/DefaultReviewersSection.tsx @@ -0,0 +1,126 @@ +/* + * 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 from 'react' +import cx from 'classnames' +import { Container, FormInput, SelectOption, Text } from '@harnessio/uicore' +import { Color } from '@harnessio/design-system' +import type { FormikProps } from 'formik' +import { Render } from 'react-jsx-match' +import { useStrings } from 'framework/strings' +import type { RulesFormPayload } from 'utils/Utils' +import { SettingTypeMode } from 'utils/GitUtils' +import DefaultReviewersList from './DefaultReviewersList' +import css from '../BranchProtectionForm.module.scss' + +const DefaultReviewersSection = (props: { + formik: FormikProps + defaultReviewerProps: { + setSearchTerm: React.Dispatch> + userPrincipalOptions: SelectOption[] + settingSectionMode: SettingTypeMode + setDefaultReviewersState: React.Dispatch> + } +}) => { + const { formik, defaultReviewerProps } = props + const { settingSectionMode, userPrincipalOptions, setSearchTerm, setDefaultReviewersState } = defaultReviewerProps + const { getString } = useStrings() + const setFieldValue = formik.setFieldValue + + const defaultReviewersList = + settingSectionMode === SettingTypeMode.EDIT || formik.values.defaultReviewersSet + ? formik.values.defaultReviewersList + : [] + const minDefaultReviewers = formik.values.requireMinDefaultReviewers + const defaultReviewersEnabled = formik.values.defaultReviewersEnabled + const filteredPrincipalOptions = userPrincipalOptions.filter( + (item: SelectOption) => !defaultReviewersList?.includes(item.value as string) + ) + + return ( + <> + { + if (!(e.target as HTMLInputElement).checked) { + setFieldValue('requireMinDefaultReviewers', false) + formik.setFieldValue('defaultReviewersList', []) + } + }} + /> + + {getString('branchProtection.enableDefaultReviewersText')} + + + + + { + const id = item.value?.toString().split(' ')[0] + const displayName = item.label + const defaultReviewerEntry = `${id} ${displayName}` + defaultReviewersList?.push(defaultReviewerEntry) + const uniqueArr = Array.from(new Set(defaultReviewersList)) + formik.setFieldValue('defaultReviewersList', uniqueArr) + formik.setFieldValue('defaultReviewersSet', true) + setDefaultReviewersState([...uniqueArr]) + }} + name={'defaultReviewerSelect'}> + {formik.errors.defaultReviewersList && ( + + {formik.errors.defaultReviewersList} + + )} + + + { + if ((e.target as HTMLInputElement).checked) { + setFieldValue('minDefaultReviewers', 1) + setFieldValue('defaultReviewersEnabled', true) + } + }} + /> + + {getString('branchProtection.requireMinDefaultReviewersContent')} + + {minDefaultReviewers && ( + + + + )} + + +
+ + ) +} + +export default DefaultReviewersSection diff --git a/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/ProtectionRulesForm.tsx b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/ProtectionRulesForm.tsx index e68bbea69..d011e3ef5 100644 --- a/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/ProtectionRulesForm.tsx +++ b/web/src/components/BranchProtection/BranchProtectionForm/ProtectionRulesForm/ProtectionRulesForm.tsx @@ -22,6 +22,8 @@ import type { FormikProps } from 'formik' import { Classes, Popover, PopoverInteractionKind, PopoverPosition } from '@blueprintjs/core' import { useStrings } from 'framework/strings' import type { RulesFormPayload } from 'utils/Utils' +import type { SettingTypeMode } from 'utils/GitUtils' +import DefaultReviewersSection from './DefaultReviewersSection' import css from '../BranchProtectionForm.module.scss' const ProtectionRulesForm = (props: { @@ -29,9 +31,15 @@ const ProtectionRulesForm = (props: { minReviewers: boolean statusOptions: SelectOption[] statusChecks: string[] - limitMergeStrats: boolean // eslint-disable-next-line @typescript-eslint/no-explicit-any + limitMergeStrats: boolean setSearchStatusTerm: React.Dispatch> formik: FormikProps + defaultReviewerProps: { + setSearchTerm: React.Dispatch> + userPrincipalOptions: SelectOption[] + settingSectionMode: SettingTypeMode + setDefaultReviewersState: React.Dispatch> + } }) => { const { statusChecks, @@ -40,7 +48,8 @@ const ProtectionRulesForm = (props: { requireStatusChecks, statusOptions, limitMergeStrats, - formik + formik, + defaultReviewerProps } = props const { getString } = useStrings() const setFieldValue = formik.setFieldValue @@ -143,6 +152,7 @@ const ProtectionRulesForm = (props: {
+ @@ -165,7 +165,7 @@ export const CodeOwnerSection: React.FC = ({ [ { id: 'CODE', - width: '45%', + width: '40%', sort: true, Header: getString('code'), accessor: 'CODE', @@ -183,7 +183,7 @@ export const CodeOwnerSection: React.FC = ({ }, { id: 'Owners', - width: '13%', + width: '18%', sort: true, Header: getString('ownersHeading'), accessor: 'OWNERS', diff --git a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/PullRequestOverviewPanel.tsx b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/PullRequestOverviewPanel.tsx index a7eca7c4c..31e269be8 100644 --- a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/PullRequestOverviewPanel.tsx +++ b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/PullRequestOverviewPanel.tsx @@ -27,7 +27,8 @@ import type { TypesPullReqReviewer, RepoRepositoryOutput, TypesRuleViolations, - TypesBranchExtended + TypesBranchExtended, + TypesDefaultReviewerApprovalsResponse } from 'services/code' import { PRMergeOption, @@ -103,6 +104,7 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { const [reqCodeOwnerApproval, setReqCodeOwnerApproval] = useState(false) const [minApproval, setMinApproval] = useState(0) const [reqCodeOwnerLatestApproval, setReqCodeOwnerLatestApproval] = useState(false) + const [defaultReviewersInfoSet, setDefaultReviewersInfoSet] = useState([]) const [minReqLatestApproval, setMinReqLatestApproval] = useState(0) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [resolvedCommentArr, setResolvedCommentArr] = useState() @@ -234,7 +236,8 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { setMinApproval, setReqCodeOwnerLatestApproval, setMinReqLatestApproval, - setPRStateLoading + setPRStateLoading, + setDefaultReviewersInfoSet ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [unchecked, pullReqMetadata?.source_sha, activities]) @@ -295,6 +298,7 @@ const PullRequestOverviewPanel = (props: PullRequestOverviewPanelProps) => { reqCodeOwnerLatestApproval={reqCodeOwnerLatestApproval} refetchCodeOwners={refetchCodeOwners} mergeBlockedRule={mergeBlockedRule} + defaultReviewersInfoSet={defaultReviewersInfoSet} /> ), diff --git a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx index 7be27d23c..7e618d284 100644 --- a/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx +++ b/web/src/pages/PullRequest/Conversation/PullRequestOverviewPanel/sections/ChangesSection.tsx @@ -30,7 +30,7 @@ import { Render } from 'react-jsx-match' import { isEmpty } from 'lodash-es' import type { IconName } from '@blueprintjs/core' import { Icon } from '@harnessio/icons' -import { CodeOwnerReqDecision, findChangeReqDecisions } from 'utils/Utils' +import { CodeOwnerReqDecision, findChangeReqDecisions, getUnifiedDefaultReviewersState } from 'utils/Utils' import { CodeOwnerSection } from 'pages/PullRequest/CodeOwners/CodeOwnersOverview' import { useStrings } from 'framework/strings' import type { @@ -38,10 +38,12 @@ import type { TypesCodeOwnerEvaluationEntry, TypesPullReq, TypesPullReqReviewer, - RepoRepositoryOutput + RepoRepositoryOutput, + TypesDefaultReviewerApprovalsResponse } from 'services/code' import { capitalizeFirstLetter } from 'pages/PullRequest/Checks/ChecksUtils' -import { findWaitingDecisions } from 'pages/PullRequest/PullRequestUtils' +import { defaultReviewerResponseWithDecision, findWaitingDecisions } from 'pages/PullRequest/PullRequestUtils' +import { DefaultReviewersPanel } from 'pages/PullRequest/DefaultReviewers/DefaultReviewersPanel' import greyCircle from '../../../../../icons/greyCircle.svg?url' import emptyStatus from '../../../../../icons/emptyStatus.svg?url' import Success from '../../../../../icons/code-success.svg?url' @@ -58,6 +60,7 @@ interface ChangesSectionProps { reqCodeOwnerApproval: boolean minApproval: number reviewers: TypesPullReqReviewer[] | null + defaultReviewersInfoSet: TypesDefaultReviewerApprovalsResponse[] minReqLatestApproval: number reqCodeOwnerLatestApproval: boolean mergeBlockedRule: boolean @@ -69,6 +72,7 @@ interface ChangesSectionProps { const ChangesSection = (props: ChangesSectionProps) => { const { reviewers: currReviewers, + defaultReviewersInfoSet, minApproval, reqCodeOwnerApproval, repoMetadata, @@ -139,6 +143,18 @@ const ChangesSection = (props: ChangesSectionProps) => { changeReqEvaluations[0].reviewer?.display_name || changeReqEvaluations[0].reviewer?.uid || '' ) : 'Reviewer' + const updatedDefaultApprovalRes = reviewers + ? defaultReviewerResponseWithDecision(defaultReviewersInfoSet, reviewers) + : defaultReviewersInfoSet + + const { + defReviewerApprovalRequiredByRule, + defReviewerLatestApprovalRequiredByRule, + defReviewerApprovedLatestChanges, + defReviewerApprovedChanges, + changesRequestedByDefReviewersArr + } = getUnifiedDefaultReviewersState(updatedDefaultApprovalRes) + const extractInfoForCodeOwnerContent = () => { let statusMessage = '' let statusColor = 'grey' // Default color for no rules required @@ -151,6 +167,8 @@ const ChangesSection = (props: ChangesSectionProps) => { minApproval > 0 || reqCodeOwnerLatestApproval || minReqLatestApproval > 0 || + defReviewerApprovalRequiredByRule || + defReviewerLatestApprovalRequiredByRule || mergeBlockedRule ) { if (mergeBlockedRule) { @@ -183,6 +201,14 @@ const ChangesSection = (props: ChangesSectionProps) => { statusMessage = getString('changesSection.latestChangesPendingReqRev') statusColor = Color.ORANGE_500 statusIcon = 'execution-waiting' + } else if (defReviewerLatestApprovalRequiredByRule && !defReviewerApprovedLatestChanges) { + title = getString('changesSection.approvalPending') + statusMessage = stringSubstitute(getString('changesSection.pendingLatestApprovalDefaultReviewers'), { + count: approvedEvaluations?.length || '0', + total: minApproval + }) as string + statusColor = Color.ORANGE_500 + statusIcon = 'execution-waiting' } else if (approvedEvaluations && approvedEvaluations?.length < minApproval && minApproval > 0) { title = getString('changesSection.approvalPending') statusMessage = stringSubstitute(getString('changesSection.waitingOnReviewers'), { @@ -190,6 +216,15 @@ const ChangesSection = (props: ChangesSectionProps) => { total: minApproval }) as string + statusColor = Color.ORANGE_500 + statusIcon = 'execution-waiting' + } else if (defReviewerApprovalRequiredByRule && !defReviewerApprovedChanges) { + title = getString('changesSection.approvalPending') + statusMessage = stringSubstitute(getString('changesSection.waitingOnDefaultReviewers'), { + count: approvedEvaluations?.length || '0', + total: minApproval + }) as string + statusColor = Color.ORANGE_500 statusIcon = 'execution-waiting' } else if (reqCodeOwnerLatestApproval && latestCodeOwnerApprovalArr?.length > 0) { @@ -384,6 +419,84 @@ const ChangesSection = (props: ChangesSectionProps) => { ) } + + const renderDefaultReviewersStatus = () => { + if (defReviewerLatestApprovalRequiredByRule && !defReviewerApprovedLatestChanges) { + return ( + // Waiting on default reviewers reviews of latest changes + + + emptyStatus + + + + {getString('changesSection.waitingOnLatestDefaultReviewers')} + + + ) + } + if (defReviewerApprovalRequiredByRule && !defReviewerApprovedChanges) { + //Changes are pending approval from default reviewers + return ( + + + emptyStatus + + + {getString('changesSection.waitingOnDefaultReviewers')} + + + ) + } + + if (defReviewerLatestApprovalRequiredByRule && defReviewerApprovedLatestChanges) { + // Latest changes were approved by default reviewers + return ( + + {getString('changesSection.latestChangesWereAppByDefaultReviewers')} + + ) + } + + if (defReviewerApprovalRequiredByRule && defReviewerApprovedChanges) { + //Changes were approved by default reviewers + return ( + + {getString('changesSection.changesWereAppByDefaultReviewers')} + + ) + } + + return ( + + {getString('changesSection.defaultReviewersStatus')} + + ) + } const viewBtn = !mergeBlockedRule && (minApproval > minReqLatestApproval || @@ -573,6 +686,56 @@ const ChangesSection = (props: ChangesSectionProps) => { )} + {!isEmpty(defaultReviewersInfoSet) && + (defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule) && ( + + + {changesRequestedByDefReviewersArr && changesRequestedByDefReviewersArr?.length > 0 ? ( + + {getString('changesSection.defaultReviewersChangesToPr')} + + ) : ( + renderDefaultReviewersStatus() + )} + {(defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule) && ( + + + {getString('required')} + + + )} + + + )} + {!isEmpty(defaultReviewersInfoSet) && ( + + res.minimum_required_count || res.minimum_required_count_latest + )} //to only consider response with min default reviewers required (>0) + pullReqMetadata={pullReqMetadata} + repoMetadata={repoMetadata} + /> + + )} {!isEmpty(codeOwners) && !isEmpty(codeOwners.evaluation_entries) && ( @@ -604,19 +767,19 @@ const ChangesSection = (props: ChangesSectionProps) => { )} + {codeOwners && !isEmpty(codeOwners?.evaluation_entries) && ( + + + + )} - {codeOwners && !isEmpty(codeOwners?.evaluation_entries) && ( - - - - )} ) diff --git a/web/src/pages/PullRequest/Conversation/SystemComment.tsx b/web/src/pages/PullRequest/Conversation/SystemComment.tsx index 6683bc9ea..2ae814746 100644 --- a/web/src/pages/PullRequest/Conversation/SystemComment.tsx +++ b/web/src/pages/PullRequest/Conversation/SystemComment.tsx @@ -50,29 +50,27 @@ interface ReviewerAddActivityPayload { } const formatListWithAndFragment = (names: string[]): React.ReactNode => { - const uniqueNames = [...new Set(names)] // Ensure uniqueness - - switch (uniqueNames.length) { + switch (names.length) { case 0: return null case 1: - return {uniqueNames[0]} + return {names[0]} case 2: return ( <> - {uniqueNames[0]} and {uniqueNames[1]} + {names[0]} and {names[1]} ) default: return ( <> - {uniqueNames.slice(0, -1).map((name, index) => ( + {names.slice(0, -1).map((name, index) => ( {name} - {index < uniqueNames.length - 2 ? ', ' : ''} + {index < names.length - 2 ? ', ' : ''} ))}{' '} - and {uniqueNames[uniqueNames.length - 1]} + and {names[names.length - 1]} ) } @@ -86,10 +84,18 @@ export const SystemComment: React.FC = ({ pullReqMetadata, c const { routes } = useAppContext() const displayNameList = useMemo(() => { const checkList = payload?.metadata?.mentions?.ids ?? [] + const uniqueList = [...new Set(checkList)] const mentionsMap = payload?.mentions ?? {} - return [...new Set(checkList.map(id => mentionsMap[id]?.display_name ?? ''))] + return uniqueList.map(id => mentionsMap[id]?.display_name ?? '') }, [payload?.metadata?.mentions?.ids, payload?.mentions]) + const principalNameList = useMemo(() => { + const checkList = (payload?.payload as any)?.principal_ids ?? [] + const uniqueList = [...new Set(checkList)] + const mentionsMap = payload?.mentions ?? {} + return uniqueList.map(id => mentionsMap[id as number]?.display_name ?? '') + }, [(payload?.payload as any)?.principal_ids, payload?.mentions]) + switch (type) { case CommentType.MERGE: { return ( @@ -461,6 +467,7 @@ export const SystemComment: React.FC = ({ pullReqMetadata, c case CommentType.REVIEWER_ADD: { const activityMentions = formatListWithAndFragment(displayNameList) + const principalMentions = formatListWithAndFragment(principalNameList) return ( @@ -499,7 +506,7 @@ export const SystemComment: React.FC = ({ pullReqMetadata, c str={getString('prReview.codeowners')} vars={{ author: {payload?.author?.display_name}, - codeowners: activityMentions + codeowners: principalMentions }} /> @@ -508,7 +515,7 @@ export const SystemComment: React.FC = ({ pullReqMetadata, c str={getString('prReview.defaultReviewers')} vars={{ author: {payload?.author?.display_name}, - reviewers: activityMentions + reviewers: principalMentions }} /> diff --git a/web/src/pages/PullRequest/DefaultReviewers/DefaultReviewersPanel.tsx b/web/src/pages/PullRequest/DefaultReviewers/DefaultReviewersPanel.tsx new file mode 100644 index 000000000..27713ab0a --- /dev/null +++ b/web/src/pages/PullRequest/DefaultReviewers/DefaultReviewersPanel.tsx @@ -0,0 +1,257 @@ +/* + * 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 } from 'react' +import { Render } from 'react-jsx-match' +import { Container, Text, TableV2, Layout, Avatar } from '@harnessio/uicore' +import { Color } from '@harnessio/design-system' +import type { CellProps, Column } from 'react-table' +import type { GitInfoProps } from 'utils/GitUtils' +import { useStrings } from 'framework/strings' +import type { TypesDefaultReviewerApprovalsResponseWithRevDecision } from 'utils/Utils' +import { PullReqReviewDecision } from '../PullRequestUtils' +import css from '../CodeOwners/CodeOwnersOverview.module.scss' + +interface DefaultReviewersPanelProps extends Pick { + defaultRevApprovalResponse: TypesDefaultReviewerApprovalsResponseWithRevDecision[] +} + +export const DefaultReviewersPanel: React.FC = ({ + defaultRevApprovalResponse, + pullReqMetadata +}) => { + const { getString } = useStrings() + + const columns = useMemo( + () => + [ + { + id: 'REQUIRED', + width: '40%', + sort: true, + Header: getString('required'), + accessor: 'REQUIRED', + Cell: ({ row }: CellProps) => { + if (row.original?.minimum_required_count && row.original?.minimum_required_count > 0) + return ( + + {row.original.current_count} / {row.original.minimum_required_count} + + ) + else if (row.original?.minimum_required_count_latest && row.original?.minimum_required_count_latest > 0) + return ( + + + {row.original.current_count} / {row.original.minimum_required_count_latest} + + ({getString('onLatestChanges')}) + + ) + else {row.original.current_count} + } + }, + { + id: 'DefaultReviewers', + width: '18%', + sort: true, + Header: getString('defaultReviewers'), + accessor: 'DefaultReviewers', + Cell: ({ row }: CellProps) => { + return ( + + {row.original.principals?.map((principal, idx) => { + if (idx < 2) { + return ( + + ) + } + if (idx === 2 && row.original.principals?.length && row.original.principals?.length > 2) { + return ( + + + {row.original.principals?.map((entry, entryidx) => ( + + {row.original.principals?.length === entryidx + 1 + ? `${entry?.display_name}` + : `${entry?.display_name}, `} + + ))} + + + } + flex={{ alignItems: 'center' }}>{`+${row.original.principals?.length - 2}`} + ) + } + return null + })} + + ) + } + }, + { + id: 'changesRequested', + Header: getString('changesRequestedBy'), + width: '24%', + sort: true, + accessor: 'ChangesRequested', + Cell: ({ row }: CellProps) => { + const changeReqEvaluations = row?.original?.principals?.filter( + principal => principal?.review_decision === PullReqReviewDecision.CHANGEREQ + ) + return ( + + {changeReqEvaluations?.map((principal, idx: number) => { + if (idx < 2) { + return ( + + ) + } + if (idx === 2 && changeReqEvaluations.length && changeReqEvaluations.length > 2) { + return ( + + + {changeReqEvaluations?.map(evalPrincipal => ( + {`${evalPrincipal?.display_name}, `} + ))} + + + } + flex={{ alignItems: 'center' }}>{`+${changeReqEvaluations.length - 2}`} + ) + } + return null + })} + + ) + } + }, + { + id: 'approvedBy', + Header: getString('approvedBy'), + sort: true, + width: '15%', + accessor: 'APPROVED BY', + Cell: ({ row }: CellProps) => { + const approvedEvaluations = row?.original?.principals?.filter( + principal => + principal.review_decision === PullReqReviewDecision.APPROVED && + (row.original.minimum_required_count_latest + ? principal.review_sha === pullReqMetadata?.source_sha + : true) + ) + + return ( + + {approvedEvaluations?.map((principal, idx: number) => { + if (idx < 2) { + return ( + + ) + } + if (idx === 2 && approvedEvaluations.length && approvedEvaluations.length > 2) { + return ( + + + {approvedEvaluations?.map(appPrincipalObj => ( + {`${appPrincipalObj?.display_name}, `} + ))} + + + } + flex={{ alignItems: 'center' }}>{`+${approvedEvaluations.length - 2}`} + ) + } + return null + })} + + ) + } + } + ] as unknown as Column[], // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + return ( + + + + css.row} + /> + + + + ) +} diff --git a/web/src/pages/PullRequest/PullRequestUtils.tsx b/web/src/pages/PullRequest/PullRequestUtils.tsx index 47b350843..345db826e 100644 --- a/web/src/pages/PullRequest/PullRequestUtils.tsx +++ b/web/src/pages/PullRequest/PullRequestUtils.tsx @@ -23,9 +23,12 @@ import type { EnumMergeMethod, EnumPullReqReviewDecision, TypesCodeOwnerEvaluationEntry, + TypesDefaultReviewerApprovalsResponse, TypesOwnerEvaluation, + TypesPrincipalInfo, TypesPullReq, - TypesPullReqActivity + TypesPullReqActivity, + TypesPullReqReviewer } from 'services/code' export interface PRMergeOption extends SelectOption { @@ -95,7 +98,7 @@ export const findWaitingDecisions = ( const hasApprovedDecision = entry?.owner_evaluations?.some( evaluation => evaluation.review_decision === PullReqReviewDecision.APPROVED && - (reqCodeOwnerLatestApproval ? evaluation.review_sha === pullReqMetadata.source_sha : true) + (reqCodeOwnerLatestApproval ? evaluation.review_sha === pullReqMetadata?.source_sha : true) ) return !hasApprovedDecision }) @@ -176,3 +179,41 @@ export const getMergeOptions = (getString: UseStringsReturn['getString'], mergea value: 'close' } ] + +export const updateReviewDecisionPrincipal = (reviewers: TypesPullReqReviewer[], principals: TypesPrincipalInfo[]) => { + const reviewDecisionMap: { + [x: number]: { sha: string; review_decision: EnumPullReqReviewDecision } | null + } = reviewers?.reduce((acc, rev) => { + if (rev.reviewer?.id) { + acc[rev.reviewer.id] = { + sha: rev.sha ?? '', + review_decision: rev.review_decision ?? 'pending' + } + } + return acc + }, {} as { [x: number]: { sha: string; review_decision: EnumPullReqReviewDecision } | null }) + + return principals?.map(principal => { + if (principal?.id) { + return { + ...principal, + review_decision: reviewDecisionMap[principal.id] ? reviewDecisionMap[principal.id]?.review_decision : 'pending', + review_sha: reviewDecisionMap[principal.id]?.sha + } + } + return principal + }) +} + +export const defaultReviewerResponseWithDecision = ( + defaultRevApprovalResponse: TypesDefaultReviewerApprovalsResponse[], + reviewers: TypesPullReqReviewer[] +) => { + return defaultRevApprovalResponse?.map(res => { + return { + ...res, + principals: + reviewers && res.principals ? updateReviewDecisionPrincipal(reviewers, res.principals) : res.principals + } + }) +} diff --git a/web/src/services/code/index.tsx b/web/src/services/code/index.tsx index 29372e124..56276c4bc 100644 --- a/web/src/services/code/index.tsx +++ b/web/src/services/code/index.tsx @@ -145,7 +145,7 @@ export type EnumPullReqCommentStatus = 'active' | 'resolved' export type EnumPullReqReviewDecision = 'approved' | 'changereq' | 'pending' | 'reviewed' -export type EnumPullReqReviewerType = 'assigned' | 'requested' | 'self_assigned' +export type EnumPullReqReviewerType = 'assigned' | 'code_owners' | 'default' | 'requested' | 'self_assigned' export type EnumPullReqState = 'closed' | 'merged' | 'open' @@ -183,11 +183,11 @@ export type EnumWebhookTrigger = | 'pullreq_comment_created' | 'pullreq_comment_status_updated' | 'pullreq_comment_updated' - | 'pullreq_review_submitted' | 'pullreq_created' | 'pullreq_label_assigned' | 'pullreq_merged' | 'pullreq_reopened' + | 'pullreq_review_submitted' | 'pullreq_updated' | 'tag_created' | 'tag_deleted' @@ -344,6 +344,7 @@ export interface OpenapiCommentUpdatePullReqRequest { export interface OpenapiCommitFilesRequest { actions?: RepoCommitFileAction[] | null + author?: GitIdentity branch?: string bypass_rules?: boolean dry_run_rules?: boolean @@ -738,6 +739,7 @@ export interface OpenapiWebhookType { latest_execution_result?: EnumWebhookExecutionResult parent_id?: number parent_type?: EnumWebhookParent + scope?: number triggers?: EnumWebhookTrigger[] | null updated?: number url?: string @@ -754,6 +756,7 @@ export interface ProtectionDefApprovals { require_code_owners?: boolean require_latest_commit?: boolean require_minimum_count?: number + require_minimum_default_reviewer_count?: number require_no_change_request?: boolean } @@ -1119,6 +1122,14 @@ export interface TypesCreateBranchOutput { sha?: ShaSHA } +export interface TypesDefaultReviewerApprovalsResponse { + current_count?: number + minimum_required_count?: number + minimum_required_count_latest?: number + principals?: TypesPrincipalInfo[] | null + rule_info?: TypesRuleInfo +} + export interface TypesDeleteBranchOutput { dry_run_rules?: boolean rule_violations?: TypesRuleViolations[] @@ -1358,6 +1369,7 @@ export interface TypesMergeResponse { allowed_methods?: EnumMergeMethod[] branch_deleted?: boolean conflict_files?: string[] + default_reviewer_aprovals?: TypesDefaultReviewerApprovalsResponse[] dry_run?: boolean dry_run_rules?: boolean mergeable?: boolean @@ -1676,6 +1688,7 @@ export interface TypesServiceAccount { created?: number display_name?: string email?: string + id?: number parent_id?: number parent_type?: EnumParentResourceType uid?: string @@ -1801,8 +1814,10 @@ export interface TypesUser { export interface TypesUserGroupInfo { description?: string + id?: number identifier?: string name?: string + scope?: number } export interface TypesUserGroupOwnerEvaluation { @@ -3761,7 +3776,7 @@ export interface ListRepoLabelsQueryParams { */ limit?: number /** - * The result should inherit labels from parent parent spaces. + * The result should inherit entities from parent spaces. */ inherited?: boolean /** @@ -6636,11 +6651,11 @@ export const useRuleList = ({ repo_ref, ...props }: UseRuleListProps) => { base: getConfig('code/api/v1'), pathParams: { repo_ref }, ...props } ) -export interface RuleAddPathParams { +export interface RepoRuleAddPathParams { repo_ref: string } -export interface RuleAddRequestBody { +export interface RepoRuleAddRequestBody { definition?: OpenapiRuleDefinition description?: string identifier?: string @@ -6650,14 +6665,14 @@ export interface RuleAddRequestBody { uid?: string } -export type RuleAddProps = Omit< - MutateProps, +export type RepoRuleAddProps = Omit< + MutateProps, 'path' | 'verb' > & - RuleAddPathParams + RepoRuleAddPathParams -export const RuleAdd = ({ repo_ref, ...props }: RuleAddProps) => ( - +export const RepoRuleAdd = ({ repo_ref, ...props }: RepoRuleAddProps) => ( + verb="POST" path={`/repos/${repo_ref}/rules`} base={getConfig('code/api/v1')} @@ -6665,31 +6680,31 @@ export const RuleAdd = ({ repo_ref, ...props }: RuleAddProps) => ( /> ) -export type UseRuleAddProps = Omit< - UseMutateProps, +export type UseRepoRuleAddProps = Omit< + UseMutateProps, 'path' | 'verb' > & - RuleAddPathParams + RepoRuleAddPathParams -export const useRuleAdd = ({ repo_ref, ...props }: UseRuleAddProps) => - useMutate( +export const useRepoRuleAdd = ({ repo_ref, ...props }: UseRepoRuleAddProps) => + useMutate( 'POST', - (paramsInPath: RuleAddPathParams) => `/repos/${paramsInPath.repo_ref}/rules`, + (paramsInPath: RepoRuleAddPathParams) => `/repos/${paramsInPath.repo_ref}/rules`, { base: getConfig('code/api/v1'), pathParams: { repo_ref }, ...props } ) -export interface RuleDeletePathParams { +export interface RepoRuleDeletePathParams { repo_ref: string } -export type RuleDeleteProps = Omit< - MutateProps, +export type RepoRuleDeleteProps = Omit< + MutateProps, 'path' | 'verb' > & - RuleDeletePathParams + RepoRuleDeletePathParams -export const RuleDelete = ({ repo_ref, ...props }: RuleDeleteProps) => ( - +export const RepoRuleDelete = ({ repo_ref, ...props }: RepoRuleDeleteProps) => ( + verb="DELETE" path={`/repos/${repo_ref}/rules`} base={getConfig('code/api/v1')} @@ -6697,50 +6712,50 @@ export const RuleDelete = ({ repo_ref, ...props }: RuleDeleteProps) => ( /> ) -export type UseRuleDeleteProps = Omit< - UseMutateProps, +export type UseRepoRuleDeleteProps = Omit< + UseMutateProps, 'path' | 'verb' > & - RuleDeletePathParams + RepoRuleDeletePathParams -export const useRuleDelete = ({ repo_ref, ...props }: UseRuleDeleteProps) => - useMutate( +export const useRepoRuleDelete = ({ repo_ref, ...props }: UseRepoRuleDeleteProps) => + useMutate( 'DELETE', - (paramsInPath: RuleDeletePathParams) => `/repos/${paramsInPath.repo_ref}/rules`, + (paramsInPath: RepoRuleDeletePathParams) => `/repos/${paramsInPath.repo_ref}/rules`, { base: getConfig('code/api/v1'), pathParams: { repo_ref }, ...props } ) -export interface RuleGetPathParams { +export interface RepoRuleGetPathParams { repo_ref: string rule_identifier: string } -export type RuleGetProps = Omit, 'path'> & - RuleGetPathParams +export type RepoRuleGetProps = Omit, 'path'> & + RepoRuleGetPathParams -export const RuleGet = ({ repo_ref, rule_identifier, ...props }: RuleGetProps) => ( - +export const RepoRuleGet = ({ repo_ref, rule_identifier, ...props }: RepoRuleGetProps) => ( + path={`/repos/${repo_ref}/rules/${rule_identifier}`} base={getConfig('code/api/v1')} {...props} /> ) -export type UseRuleGetProps = Omit, 'path'> & - RuleGetPathParams +export type UseRepoRuleGetProps = Omit, 'path'> & + RepoRuleGetPathParams -export const useRuleGet = ({ repo_ref, rule_identifier, ...props }: UseRuleGetProps) => - useGet( - (paramsInPath: RuleGetPathParams) => `/repos/${paramsInPath.repo_ref}/rules/${paramsInPath.rule_identifier}`, +export const useRepoRuleGet = ({ repo_ref, rule_identifier, ...props }: UseRepoRuleGetProps) => + useGet( + (paramsInPath: RepoRuleGetPathParams) => `/repos/${paramsInPath.repo_ref}/rules/${paramsInPath.rule_identifier}`, { base: getConfig('code/api/v1'), pathParams: { repo_ref, rule_identifier }, ...props } ) -export interface RuleUpdatePathParams { +export interface RepoRuleUpdatePathParams { repo_ref: string rule_identifier: string } -export interface RuleUpdateRequestBody { +export interface RepoRuleUpdateRequestBody { definition?: OpenapiRuleDefinition description?: string | null identifier?: string | null @@ -6750,14 +6765,14 @@ export interface RuleUpdateRequestBody { uid?: string | null } -export type RuleUpdateProps = Omit< - MutateProps, +export type RepoRuleUpdateProps = Omit< + MutateProps, 'path' | 'verb' > & - RuleUpdatePathParams + RepoRuleUpdatePathParams -export const RuleUpdate = ({ repo_ref, rule_identifier, ...props }: RuleUpdateProps) => ( - +export const RepoRuleUpdate = ({ repo_ref, rule_identifier, ...props }: RepoRuleUpdateProps) => ( + verb="PATCH" path={`/repos/${repo_ref}/rules/${rule_identifier}`} base={getConfig('code/api/v1')} @@ -6765,16 +6780,16 @@ export const RuleUpdate = ({ repo_ref, rule_identifier, ...props }: RuleUpdatePr /> ) -export type UseRuleUpdateProps = Omit< - UseMutateProps, +export type UseRepoRuleUpdateProps = Omit< + UseMutateProps, 'path' | 'verb' > & - RuleUpdatePathParams + RepoRuleUpdatePathParams -export const useRuleUpdate = ({ repo_ref, rule_identifier, ...props }: UseRuleUpdateProps) => - useMutate( +export const useRepoRuleUpdate = ({ repo_ref, rule_identifier, ...props }: UseRepoRuleUpdateProps) => + useMutate( 'PATCH', - (paramsInPath: RuleUpdatePathParams) => `/repos/${paramsInPath.repo_ref}/rules/${paramsInPath.rule_identifier}`, + (paramsInPath: RepoRuleUpdatePathParams) => `/repos/${paramsInPath.repo_ref}/rules/${paramsInPath.rule_identifier}`, { base: getConfig('code/api/v1'), pathParams: { repo_ref, rule_identifier }, ...props } ) diff --git a/web/src/services/code/swagger.yaml b/web/src/services/code/swagger.yaml index 04b4c5469..193b45bf7 100644 --- a/web/src/services/code/swagger.yaml +++ b/web/src/services/code/swagger.yaml @@ -12287,6 +12287,8 @@ components: - deleted - starting - stopping + - cleaning + - cleaned type: string EnumGitspaceOwner: enum: @@ -12416,6 +12418,8 @@ components: EnumPullReqReviewerType: enum: - assigned + - code_owners + - default - requested - self_assigned type: string @@ -12468,11 +12472,15 @@ components: type: string EnumWebhookParent: enum: + - registry - repo - space type: string EnumWebhookTrigger: enum: + - artifact_created + - artifact_deleted + - artifact_updated - branch_created - branch_deleted - branch_updated @@ -14439,8 +14447,13 @@ components: properties: created: type: integer + deleted: + nullable: true + type: integer identifier: type: string + is_deleted: + type: boolean metadata: additionalProperties: {} nullable: true @@ -15229,6 +15242,8 @@ components: type: string email: type: string + id: + type: integer parent_id: type: integer parent_type: @@ -15469,6 +15484,8 @@ components: type: string email: type: string + id: + type: integer uid: type: string updated: diff --git a/web/src/utils/GitUtils.ts b/web/src/utils/GitUtils.ts index 8b100b262..c4ba704c3 100644 --- a/web/src/utils/GitUtils.ts +++ b/web/src/utils/GitUtils.ts @@ -28,7 +28,8 @@ import type { TypesCommit, TypesPullReq, RepoRepositoryOutput, - TypesRuleViolations + TypesRuleViolations, + TypesDefaultReviewerApprovalsResponse } from 'services/code' import { getConfig } from 'services/config' import { PullRequestSection, getErrorMessage } from './Utils' @@ -173,7 +174,8 @@ export enum GitRefType { export enum PrincipalUserType { USER = 'user', - SERVICE = 'service' + SERVICE = 'service', + SERVICE_ACCOUNT = 'serviceaccount' } export enum SettingTypeMode { @@ -540,9 +542,10 @@ export const dryMerge = ( setMinApproval?: (value: React.SetStateAction) => void, setReqCodeOwnerLatestApproval?: (value: React.SetStateAction) => void, setMinReqLatestApproval?: (value: React.SetStateAction) => void, - setPRStateLoading?: (value: React.SetStateAction) => void + setPRStateLoading?: (value: React.SetStateAction) => void, + setDefaultReviewersInfoSet?: React.Dispatch> ) => { - if (isMounted.current && !isClosed && pullReqMetadata.state !== PullRequestState.MERGED) { + if (isMounted.current && !isClosed && pullReqMetadata?.state !== PullRequestState.MERGED) { // Use an internal flag to prevent flickering during the loading state of buttons internalFlags.current.dryRun = true mergePR({ bypass_rules: true, dry_run: true, source_sha: pullReqMetadata?.source_sha }) @@ -558,6 +561,7 @@ export const dryMerge = ( setReqCodeOwnerLatestApproval?.(res.requires_code_owners_approval_latest) setMinReqLatestApproval?.(res.minimum_required_approvals_count_latest) setConflictingFiles?.(res.conflict_files) + setDefaultReviewersInfoSet?.(res.default_reviewer_aprovals) } else { setRuleViolation(false) setAllowedStrats(res.allowed_methods) @@ -568,6 +572,7 @@ export const dryMerge = ( setReqCodeOwnerLatestApproval?.(res.requires_code_owners_approval_latest) setMinReqLatestApproval?.(res.minimum_required_approvals_count_latest) setConflictingFiles?.(res.conflict_files) + setDefaultReviewersInfoSet?.(res.default_reviewer_aprovals) } }) .catch(err => { @@ -582,6 +587,7 @@ export const dryMerge = ( setReqCodeOwnerLatestApproval?.(err.requires_code_owners_approval_latest) setMinReqLatestApproval?.(err.minimum_required_approvals_count_latest) setConflictingFiles?.(err.conflict_files) + setDefaultReviewersInfoSet?.(err.default_reviewer_aprovals) } else if ( err.status === 400 && [oldCommitRefetchRequired, prMergedRefetchRequired].includes(getErrorMessage(err) || '') diff --git a/web/src/utils/Utils.ts b/web/src/utils/Utils.ts index eb8f0f658..1bf1c2cda 100644 --- a/web/src/utils/Utils.ts +++ b/web/src/utils/Utils.ts @@ -30,9 +30,11 @@ import type { TypesLabel, TypesLabelValue, TypesPrincipalInfo, - EnumMembershipRole + EnumMembershipRole, + TypesDefaultReviewerApprovalsResponse } from 'services/code' import type { StringKeys } from 'framework/strings' +import { PullReqReviewDecision } from 'pages/PullRequest/PullRequestUtils' export enum ACCESS_MODES { VIEW, @@ -361,8 +363,11 @@ export const rulesFormInitialPayload: RulesFormPayload = { targetList: [] as string[][], allRepoOwners: false, bypassList: [] as string[], + defaultReviewersList: [] as string[], requireMinReviewers: false, + requireMinDefaultReviewers: false, minReviewers: '', + minDefaultReviewers: '', requireCodeOwner: false, requireNewChanges: false, reqResOfChanges: false, @@ -380,7 +385,9 @@ export const rulesFormInitialPayload: RulesFormPayload = { blockForcePush: false, requirePr: false, bypassSet: false, - targetSet: false + targetSet: false, + defaultReviewersSet: false, + defaultReviewersEnabled: false } export type RulesFormPayload = { @@ -392,8 +399,11 @@ export type RulesFormPayload = { targetList: string[][] allRepoOwners?: boolean bypassList?: string[] + defaultReviewersList?: string[] requireMinReviewers: boolean + requireMinDefaultReviewers: boolean minReviewers?: string | number + minDefaultReviewers?: string | number autoAddCodeOwner?: boolean requireCodeOwner?: boolean requireNewChanges?: boolean @@ -414,6 +424,8 @@ export type RulesFormPayload = { requirePr?: boolean bypassSet: boolean targetSet: boolean + defaultReviewersSet: boolean + defaultReviewersEnabled: boolean } /** @@ -1002,8 +1014,10 @@ export enum RuleFields { APPROVALS_REQUIRE_MINIMUM_COUNT = 'pullreq.approvals.require_minimum_count', APPROVALS_REQUIRE_CODE_OWNERS = 'pullreq.approvals.require_code_owners', APPROVALS_REQUIRE_NO_CHANGE_REQUEST = 'pullreq.approvals.require_no_change_request', + APPROVALS_REQUIRE_MINIMUM_DEFAULT_REVIEWERS = 'pullreq.approvals.require_minimum_default_reviewer_count', APPROVALS_REQUIRE_LATEST_COMMIT = 'pullreq.approvals.require_latest_commit', AUTO_ADD_CODE_OWNERS = 'pullreq.reviewers.request_code_owners', + DEFAULT_REVIEWERS_ADDED = 'pullreq.reviewers.default_reviewer_ids', COMMENTS_REQUIRE_RESOLVE_ALL = 'pullreq.comments.require_resolve_all', STATUS_CHECKS_ALL_MUST_SUCCEED = 'pullreq.status_checks.all_must_succeed', STATUS_CHECKS_REQUIRE_IDENTIFIERS = 'pullreq.status_checks.require_identifiers', @@ -1034,10 +1048,12 @@ export type BranchProtectionRulesMapType = Record export function createRuleFieldsMap(ruleDefinition: Rule): RuleFieldsMap { const ruleFieldsMap: RuleFieldsMap = { [RuleFields.APPROVALS_REQUIRE_MINIMUM_COUNT]: false, - [RuleFields.AUTO_ADD_CODE_OWNERS]: false, [RuleFields.APPROVALS_REQUIRE_CODE_OWNERS]: false, [RuleFields.APPROVALS_REQUIRE_NO_CHANGE_REQUEST]: false, [RuleFields.APPROVALS_REQUIRE_LATEST_COMMIT]: false, + [RuleFields.APPROVALS_REQUIRE_MINIMUM_DEFAULT_REVIEWERS]: false, + [RuleFields.AUTO_ADD_CODE_OWNERS]: false, + [RuleFields.DEFAULT_REVIEWERS_ADDED]: false, [RuleFields.COMMENTS_REQUIRE_RESOLVE_ALL]: false, [RuleFields.STATUS_CHECKS_ALL_MUST_SUCCEED]: false, [RuleFields.STATUS_CHECKS_REQUIRE_IDENTIFIERS]: false, @@ -1058,6 +1074,8 @@ export function createRuleFieldsMap(ruleDefinition: Rule): RuleFieldsMap { typeof ruleDefinition.pullreq.approvals.require_minimum_count === 'number' ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_NO_CHANGE_REQUEST] = !!ruleDefinition.pullreq.approvals.require_no_change_request + ruleFieldsMap[RuleFields.APPROVALS_REQUIRE_MINIMUM_DEFAULT_REVIEWERS] = + !!ruleDefinition.pullreq.approvals.require_minimum_default_reviewer_count } if (ruleDefinition.pullreq.comments) { @@ -1080,6 +1098,9 @@ export function createRuleFieldsMap(ruleDefinition: Rule): RuleFieldsMap { if (ruleDefinition.pullreq.reviewers) { ruleFieldsMap[RuleFields.AUTO_ADD_CODE_OWNERS] = !!ruleDefinition.pullreq.reviewers.request_code_owners + ruleFieldsMap[RuleFields.DEFAULT_REVIEWERS_ADDED] = + Array.isArray(ruleDefinition.pullreq.reviewers.default_reviewer_ids) && + ruleDefinition.pullreq.reviewers.default_reviewer_ids.length > 0 } } @@ -1122,3 +1143,43 @@ export const formatListWithAnd = (list: string[]): string => { return `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}` } else return '' } + +export interface TypesPrincipalInfoWithReviewDecision extends TypesPrincipalInfo { + review_decision?: PullReqReviewDecision + review_sha?: string +} + +export interface TypesDefaultReviewerApprovalsResponseWithRevDecision extends TypesDefaultReviewerApprovalsResponse { + principals?: TypesPrincipalInfoWithReviewDecision[] | null // Override the 'principals' field +} +export const getUnifiedDefaultReviewersState = (info: TypesDefaultReviewerApprovalsResponseWithRevDecision[]) => { + const defaultReviewState = { + defReviewerApprovalRequiredByRule: false, + defReviewerLatestApprovalRequiredByRule: false, + defReviewerApprovedLatestChanges: true, + defReviewerApprovedChanges: true, + changesRequestedByDefReviewersArr: [] as TypesPrincipalInfoWithReviewDecision[] + } + + info?.forEach(item => { + if (item?.minimum_required_count !== undefined && item.minimum_required_count > 0) { + defaultReviewState.defReviewerApprovalRequiredByRule = true + if (item.current_count !== undefined && item.current_count < item.minimum_required_count) { + defaultReviewState.defReviewerApprovedChanges = false + } + } + if (item?.minimum_required_count_latest !== undefined && item.minimum_required_count_latest > 0) { + defaultReviewState.defReviewerLatestApprovalRequiredByRule = true + if (item.current_count !== undefined && item.current_count < item.minimum_required_count_latest) { + defaultReviewState.defReviewerApprovedLatestChanges = false + } + } + + item?.principals?.forEach(principal => { + if (principal?.review_decision === PullReqReviewDecision.CHANGEREQ) + defaultReviewState.changesRequestedByDefReviewersArr.push(principal) + }) + }) + + return defaultReviewState +}