feat: [CODE-3324] add default reviewers support (#3572)

* fix: [CODE-3324] resolve comments
* fix: [CODE-3324] resolve comments
* fix: [CODE-3324] min reviewer req condition
* fix: [CODE-3324] approval check
* fix: [CODE-3324] latest rule check
* fix: [CODE-3324] null check again
* fix: [CODE-3324] null check
* fix: [CODE-3324] update on latest changes tag
* fix: [CODE-3324] pr page fix - only default rule present
* fix: [CODE-3324] inital state of default reviewers
* fix: [CODE-3324] swagger indentation
* feat: [CODE-3324] add default reviewers support
main
Ritik Kapoor 2025-03-18 21:55:55 +00:00 committed by Harness
parent 1cfdf10e08
commit 8e925410fb
19 changed files with 1016 additions and 101 deletions

View File

@ -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;
}

View File

@ -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

View File

@ -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<string[]>(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<string[]>(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<SelectOption[]>((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<OpenapiRule>, 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 (
<Formik<RulesFormPayload>
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}
/>
<Container padding={{ top: 'large' }}>
<Layout.Horizontal spacing="small">

View File

@ -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 (
<Layout.Horizontal className={cx(css.widthContainer, css.defaultReviewerContainer)} padding={{ bottom: 'large' }}>
{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 (
<Popover
key={`${name}-${idx}`}
interactionKind={PopoverInteractionKind.HOVER}
position={PopoverPosition.TOP_LEFT}
popoverClassName={Classes.DARK}
content={
<Container padding="medium">
<Text font={{ variation: FontVariation.FORM_HELP }} color={Color.WHITE}>
{email}
</Text>
</Container>
}>
<Layout.Horizontal key={`${name}-${idx}`} flex={{ align: 'center-center' }} className={css.reviewerBlock}>
<Text padding={{ top: 'tiny' }} lineClamp={1}>
{name}
</Text>
<FlexExpander />
<Icon
name="code-close"
onClick={() => {
const filteredData = defaultReviewersList.filter(item => !(item === owner))
setFieldValue('defaultReviewersList', filteredData)
}}
className={css.codeCloseBtn}
/>
</Layout.Horizontal>
</Popover>
)
})}
</Layout.Horizontal>
)
}, [defaultReviewersList, setFieldValue])
return <>{defaultReviewerContent}</>
}
export default DefaultReviewersList

View File

@ -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<RulesFormPayload>
defaultReviewerProps: {
setSearchTerm: React.Dispatch<React.SetStateAction<string>>
userPrincipalOptions: SelectOption[]
settingSectionMode: SettingTypeMode
setDefaultReviewersState: React.Dispatch<React.SetStateAction<string[]>>
}
}) => {
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 (
<>
<FormInput.CheckBox
className={css.checkboxLabel}
label={getString('branchProtection.enableDefaultReviewersTitle')}
name={'defaultReviewersEnabled'}
onChange={e => {
if (!(e.target as HTMLInputElement).checked) {
setFieldValue('requireMinDefaultReviewers', false)
formik.setFieldValue('defaultReviewersList', [])
}
}}
/>
<Text padding={{ left: 'xlarge' }} className={css.checkboxText}>
{getString('branchProtection.enableDefaultReviewersText')}
</Text>
<Render when={defaultReviewersEnabled}>
<Container padding={{ top: 'xlarge', left: 'xlarge' }}>
<FormInput.Select
items={filteredPrincipalOptions}
onQueryChange={setSearchTerm}
className={css.widthContainer}
value={{ label: '', value: '' }}
placeholder={getString('selectReviewers')}
onChange={item => {
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'}></FormInput.Select>
{formik.errors.defaultReviewersList && (
<Text color={Color.RED_350} padding={{ bottom: 'medium' }}>
{formik.errors.defaultReviewersList}
</Text>
)}
<DefaultReviewersList defaultReviewersList={defaultReviewersList} setFieldValue={formik.setFieldValue} />
<FormInput.CheckBox
className={css.checkboxLabel}
label={getString('branchProtection.requireMinDefaultReviewersTitle')}
name={'requireMinDefaultReviewers'}
onChange={e => {
if ((e.target as HTMLInputElement).checked) {
setFieldValue('minDefaultReviewers', 1)
setFieldValue('defaultReviewersEnabled', true)
}
}}
/>
<Text padding={{ left: 'xlarge' }} className={css.checkboxText}>
{getString('branchProtection.requireMinDefaultReviewersContent')}
</Text>
{minDefaultReviewers && (
<Container padding={{ left: 'xlarge', top: 'medium' }}>
<FormInput.Text
className={cx(css.widthContainer, css.minText)}
name={'minDefaultReviewers'}
placeholder={getString('branchProtection.minNumberPlaceholder')}
label={getString('branchProtection.minNumber')}
/>
</Container>
)}
</Container>
</Render>
<hr className={css.dividerContainer} />
</>
)
}
export default DefaultReviewersSection

View File

@ -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<React.SetStateAction<string>>
formik: FormikProps<RulesFormPayload>
defaultReviewerProps: {
setSearchTerm: React.Dispatch<React.SetStateAction<string>>
userPrincipalOptions: SelectOption[]
settingSectionMode: SettingTypeMode
setDefaultReviewersState: React.Dispatch<React.SetStateAction<string[]>>
}
}) => {
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: {
</Popover>
<hr className={css.dividerContainer} />
<DefaultReviewersSection formik={formik} defaultReviewerProps={defaultReviewerProps} />
<FormInput.CheckBox
className={css.checkboxLabel}
label={getString('branchProtection.requireMinReviewersTitle')}

View File

@ -219,6 +219,18 @@ const BranchProtectionListing = (props: {
requiredRule: {
[RuleFields.AUTO_ADD_CODE_OWNERS]: true
}
},
requireMinDefaultReviewersTitle: {
title: getString('branchProtection.requireMinDefaultReviewersTitle'),
requiredRule: {
[RuleFields.APPROVALS_REQUIRE_MINIMUM_DEFAULT_REVIEWERS]: true
}
},
defaultReviewersAdded: {
title: getString('branchProtection.enableDefaultReviewersTitle'),
requiredRule: {
[RuleFields.DEFAULT_REVIEWERS_ADDED]: true
}
}
}

View File

@ -69,6 +69,8 @@ export interface StringsMap {
'branchProtection.addCodeownersToReviewText': string
'branchProtection.addCodeownersToReviewTitle': string
'branchProtection.allRepoOwners': string
'branchProtection.atLeastMinReviewer': string
'branchProtection.atLeastMinReviewers': string
'branchProtection.autoDeleteText': string
'branchProtection.autoDeleteTitle': string
'branchProtection.blockBranchCreation': string
@ -102,6 +104,8 @@ export interface StringsMap {
'branchProtection.disableTheRule': string
'branchProtection.edit': string
'branchProtection.editRule': string
'branchProtection.enableDefaultReviewersText': string
'branchProtection.enableDefaultReviewersTitle': string
'branchProtection.enableTheRule': string
'branchProtection.limitMergeStrategies': string
'branchProtection.limitMergeStrategiesText': string
@ -124,6 +128,8 @@ export interface StringsMap {
'branchProtection.reqReviewFromCodeOwnerTitle': string
'branchProtection.reqStatusChecksText': string
'branchProtection.reqStatusChecksTitle': string
'branchProtection.requireMinDefaultReviewersContent': string
'branchProtection.requireMinDefaultReviewersTitle': string
'branchProtection.requireMinReviewersContent': string
'branchProtection.requireMinReviewersTitle': string
'branchProtection.requirePr': string
@ -170,24 +176,31 @@ export interface StringsMap {
'changesSection.changesApproved': string
'changesSection.changesApprovedByXReviewers': string
'changesSection.changesWereAppByCodeOwner': string
'changesSection.changesWereAppByDefaultReviewers': string
'changesSection.changesWereAppByLatestReqRev': string
'changesSection.codeOwnerReqChanges': string
'changesSection.codeOwnerReqChangesToPr': string
'changesSection.defaultReviewersChangesToPr': string
'changesSection.defaultReviewersStatus': string
'changesSection.latestChangesApprovedByXReviewers': string
'changesSection.latestChangesPendingReqRev': string
'changesSection.latestChangesWereAppByCodeOwner': string
'changesSection.latestChangesWereAppByDefaultReviewers': string
'changesSection.latestChangesWereApprovedByReq': string
'changesSection.noCodeOwnerReviewsReq': string
'changesSection.noReviewsReq': string
'changesSection.pendingAppFromCodeOwners': string
'changesSection.pendingLatestApprovalCodeOwners': string
'changesSection.pendingLatestApprovalDefaultReviewers': string
'changesSection.prMergeBlockedMessage': string
'changesSection.prMergeBlockedTitle': string
'changesSection.pullReqWithoutAnyReviews': string
'changesSection.reqChangeFromCodeOwners': string
'changesSection.someChangesWereAppByCodeOwner': string
'changesSection.waitingOnCodeOwner': string
'changesSection.waitingOnDefaultReviewers': string
'changesSection.waitingOnLatestCodeOwner': string
'changesSection.waitingOnLatestDefaultReviewers': string
'changesSection.waitingOnReviewers': string
'changesSection.xApprovalsArePending': string
characterLimit: string
@ -309,6 +322,7 @@ export interface StringsMap {
dangerDeleteProject: string
defaultBranch: string
defaultBranchTitle: string
defaultReviewers: string
delete: string
deleteBranch: string
deleteBranchConfirm: string
@ -673,6 +687,7 @@ export interface StringsMap {
ok: string
on: string
onDate: string
onLatestChanges: string
oneMustBeSelected: string
open: string
optional: string
@ -1036,6 +1051,7 @@ export interface StringsMap {
selectMergeStrat: string
selectRange: string
selectRepositoryPlaceholder: string
selectReviewers: string
selectSpace: string
selectSpaceText: string
selectStatuses: string

View File

@ -1079,6 +1079,12 @@ branchProtection:
commitDirectlyBlockText: Some rules don't allow you to commit directly
commitDirectlyAlertText: Some rules will be bypassed to commit directly
commitDirectlyAlertBtn: Bypass rules and commit directly
enableDefaultReviewersTitle: Enable default reviewers
enableDefaultReviewersText: Require approval from default reviewer for each pull request
requireMinDefaultReviewersTitle: Require a minimum number of default reviewers
requireMinDefaultReviewersContent: Require approval on pull requests from a minimum number of default reviewers
atLeastMinReviewers: 'Select at least {{count}} default reviewers'
atLeastMinReviewer: 'Select at least {{count}} default reviewer'
codeOwner:
title: Code Owner
changesRequested: '{count} {count|1:change,changes} requested'
@ -1089,6 +1095,7 @@ approved: Approved
comingSoon: Coming soon...
enterANumber: Enter a number
selectUsers: Select Users
selectReviewers: Select Reviewers
selectUsersAndServiceAcc: Select Users and Service Accounts
selectStatuses: Select Statuses
featureRoadmap: Feature Roadmap
@ -1150,8 +1157,11 @@ changesSection:
pendingAppFromCodeOwners: Approvals pending from code owners
pendingLatestApprovalCodeOwners: Latest changes are pending approval from code owners
waitingOnCodeOwner: Changes are pending approval from code owners
pendingLatestApprovalDefaultReviewers: Latest changes are pending approval from default reviewers
waitingOnDefaultReviewers: Changes are pending approval from default reviewers
waitingOnReviewers: Changes are pending approval from required reviewers
waitingOnLatestCodeOwner: Waiting on code owner reviews of latest changes
waitingOnLatestDefaultReviewers: Waiting on default reviewer's reviews of latest changes
approvalPending: Approvals pending
changesApproved: Changes approved
noReviewsReq: No reviews required
@ -1161,13 +1171,17 @@ changesSection:
changesApprovedByXReviewers: Changes were approved by {length} {length|1:reviewer,reviewers}
latestChangesApprovedByXReviewers: Latest changes were approved by {length} {length|1:reviewer,reviewers}
latestChangesWereAppByCodeOwner: Latest changes were approved by code owners
latestChangesWereAppByDefaultReviewers: Latest changes were approved by default reviewers
latestChangesPendingReqRev: Latest changes are pending approval from required reviewers
changesWereAppByCodeOwner: Changes were approved by code owners
changesWereAppByDefaultReviewers: Changes were approved by default reviewers
defaultReviewersStatus: Default reviewers were added to the PR
changesWereAppByLatestReqRev: Changes were approved by required reviewers
latestChangesWereApprovedByReq: Latest changes were approved by required reviewers
someChangesWereAppByCodeOwner: Some changes were approved by code owners
xApprovalsArePending: '{approved}/{min} approvals are pending'
codeOwnerReqChangesToPr: Code owners requested changes to the pull request
defaultReviewersChangesToPr: Default reviewers requested changes to the pull request
checkSection:
someReqChecksFailed: Some required checks have failed
someReqChecksPending: Some required checks are pending
@ -1203,6 +1217,7 @@ checklist: Check list
code: Code
approvedBy: Approved By
ownersHeading: OWNERS
defaultReviewers: DEFAULT REVIEWERS
changesRequestedBy: Changes Requested By
changesRequested: changesRequested
owners: Owners
@ -1322,3 +1337,4 @@ rebase: Rebase
rebaseBranch: Rebase branch
updateWithRebase: Update with rebase
updatedBranchMessageRebase: Updated branch with rebase
onLatestChanges: on latest changes

View File

@ -104,7 +104,7 @@ export function CodeOwnersOverview({
return codeOwners?.evaluation_entries?.length ? (
<Container
className={cx(css.main, { [css.codeOwner]: !standalone })}
margin={{ top: 'medium', bottom: pullReqMetadata.description ? undefined : 'large' }}
margin={{ top: 'medium', bottom: pullReqMetadata?.description ? undefined : 'large' }}
style={{ '--border-color': Utils.getRealCSSColor(borderColor) } as React.CSSProperties}>
<Match expr={isExpanded}>
<Truthy>
@ -165,7 +165,7 @@ export const CodeOwnerSection: React.FC<CodeOwnerSectionsProps> = ({
[
{
id: 'CODE',
width: '45%',
width: '40%',
sort: true,
Header: getString('code'),
accessor: 'CODE',
@ -183,7 +183,7 @@ export const CodeOwnerSection: React.FC<CodeOwnerSectionsProps> = ({
},
{
id: 'Owners',
width: '13%',
width: '18%',
sort: true,
Header: getString('ownersHeading'),
accessor: 'OWNERS',

View File

@ -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<TypesDefaultReviewerApprovalsResponse[]>([])
const [minReqLatestApproval, setMinReqLatestApproval] = useState(0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [resolvedCommentArr, setResolvedCommentArr] = useState<any>()
@ -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}
/>
</Render>
),

View File

@ -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) => {
</Layout.Horizontal>
)
}
const renderDefaultReviewersStatus = () => {
if (defReviewerLatestApprovalRequiredByRule && !defReviewerApprovedLatestChanges) {
return (
// Waiting on default reviewers reviews of latest changes
<Layout.Horizontal>
<Container padding={{ left: 'large' }}>
<img alt="emptyStatus" width={16} height={16} src={emptyStatus} />
</Container>
<Text padding={{ left: 'medium' }} className={css.sectionSubheader}>
{getString('changesSection.waitingOnLatestDefaultReviewers')}
</Text>
</Layout.Horizontal>
)
}
if (defReviewerApprovalRequiredByRule && !defReviewerApprovedChanges) {
//Changes are pending approval from default reviewers
return (
<Layout.Horizontal>
<Container padding={{ left: 'large' }}>
<img alt="emptyStatus" width={16} height={16} src={emptyStatus} />
</Container>
<Text padding={{ left: 'medium' }} className={css.sectionSubheader}>
{getString('changesSection.waitingOnDefaultReviewers')}
</Text>
</Layout.Horizontal>
)
}
if (defReviewerLatestApprovalRequiredByRule && defReviewerApprovedLatestChanges) {
// Latest changes were approved by default reviewers
return (
<Text
icon={'tick-circle'}
iconProps={{
size: 16,
color: Color.GREEN_700,
padding: { right: 'medium' }
}}
padding={{ left: 'large' }}
className={css.sectionSubheader}>
{getString('changesSection.latestChangesWereAppByDefaultReviewers')}
</Text>
)
}
if (defReviewerApprovalRequiredByRule && defReviewerApprovedChanges) {
//Changes were approved by default reviewers
return (
<Text
icon={'tick-circle'}
iconProps={{
size: 16,
color: Color.GREEN_700,
padding: { right: 'medium' }
}}
padding={{ left: 'large' }}
className={css.sectionSubheader}>
{getString('changesSection.changesWereAppByDefaultReviewers')}
</Text>
)
}
return (
<Text
icon={'tick-circle'}
iconProps={{
size: 16,
color: Color.GREEN_700,
padding: { right: 'medium' }
}}
padding={{ left: 'large' }}
className={css.sectionSubheader}>
{getString('changesSection.defaultReviewersStatus')}
</Text>
)
}
const viewBtn =
!mergeBlockedRule &&
(minApproval > minReqLatestApproval ||
@ -573,6 +686,56 @@ const ChangesSection = (props: ChangesSectionProps) => {
</Layout.Horizontal>
</Container>
)}
{!isEmpty(defaultReviewersInfoSet) &&
(defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule) && (
<Container className={css.borderContainer} padding={{ left: 'xlarge', right: 'small' }}>
<Layout.Horizontal className={css.paddingContainer} flex={{ justifyContent: 'space-between' }}>
{changesRequestedByDefReviewersArr && changesRequestedByDefReviewersArr?.length > 0 ? (
<Text
className={cx(
css.sectionSubheader,
defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule
? css.redIcon
: css.greyIcon
)}
icon={'error-transparent-no-outline'}
iconProps={{
size: 17,
color:
defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule
? Color.RED_600
: '',
padding: { right: 'medium' }
}}
padding={{ left: 'large' }}>
{getString('changesSection.defaultReviewersChangesToPr')}
</Text>
) : (
renderDefaultReviewersStatus()
)}
{(defReviewerApprovalRequiredByRule || defReviewerLatestApprovalRequiredByRule) && (
<Container className={css.changeContainerPadding}>
<Container className={css.requiredContainer}>
<Text className={css.requiredText}>{getString('required')}</Text>
</Container>
</Container>
)}
</Layout.Horizontal>
</Container>
)}
{!isEmpty(defaultReviewersInfoSet) && (
<Container
className={css.codeOwnerContainer}
padding={{ top: 'small', bottom: 'small', left: 'xxxlarge', right: 'small' }}>
<DefaultReviewersPanel
defaultRevApprovalResponse={updatedDefaultApprovalRes.filter(
res => res.minimum_required_count || res.minimum_required_count_latest
)} //to only consider response with min default reviewers required (>0)
pullReqMetadata={pullReqMetadata}
repoMetadata={repoMetadata}
/>
</Container>
)}
{!isEmpty(codeOwners) && !isEmpty(codeOwners.evaluation_entries) && (
<Container className={css.borderContainer} padding={{ left: 'xlarge', right: 'small' }}>
<Layout.Horizontal className={css.paddingContainer} flex={{ justifyContent: 'space-between' }}>
@ -604,19 +767,19 @@ const ChangesSection = (props: ChangesSectionProps) => {
</Layout.Horizontal>
</Container>
)}
{codeOwners && !isEmpty(codeOwners?.evaluation_entries) && (
<Container
className={css.codeOwnerContainer}
padding={{ top: 'small', bottom: 'small', left: 'xxxlarge', right: 'small' }}>
<CodeOwnerSection
data={codeOwners}
pullReqMetadata={pullReqMetadata}
repoMetadata={repoMetadata}
reqCodeOwnerLatestApproval={reqCodeOwnerLatestApproval}
/>
</Container>
)}
</Container>
{codeOwners && !isEmpty(codeOwners?.evaluation_entries) && (
<Container
className={css.codeOwnerContainer}
padding={{ top: 'small', bottom: 'small', left: 'xxxlarge', right: 'small' }}>
<CodeOwnerSection
data={codeOwners}
pullReqMetadata={pullReqMetadata}
repoMetadata={repoMetadata}
reqCodeOwnerLatestApproval={reqCodeOwnerLatestApproval}
/>
</Container>
)}
</Render>
</Render>
)

View File

@ -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 <strong>{uniqueNames[0]}</strong>
return <strong>{names[0]}</strong>
case 2:
return (
<>
<strong>{uniqueNames[0]}</strong> and <strong>{uniqueNames[1]}</strong>
<strong>{names[0]}</strong> and <strong>{names[1]}</strong>
</>
)
default:
return (
<>
{uniqueNames.slice(0, -1).map((name, index) => (
{names.slice(0, -1).map((name, index) => (
<React.Fragment key={index}>
<strong>{name}</strong>
{index < uniqueNames.length - 2 ? ', ' : ''}
{index < names.length - 2 ? ', ' : ''}
</React.Fragment>
))}{' '}
and <strong>{uniqueNames[uniqueNames.length - 1]}</strong>
and <strong>{names[names.length - 1]}</strong>
</>
)
}
@ -86,10 +84,18 @@ export const SystemComment: React.FC<SystemCommentProps> = ({ 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<SystemCommentProps> = ({ pullReqMetadata, c
case CommentType.REVIEWER_ADD: {
const activityMentions = formatListWithAndFragment(displayNameList)
const principalMentions = formatListWithAndFragment(principalNameList)
return (
<Container className={css.mergedBox}>
@ -499,7 +506,7 @@ export const SystemComment: React.FC<SystemCommentProps> = ({ pullReqMetadata, c
str={getString('prReview.codeowners')}
vars={{
author: <strong>{payload?.author?.display_name}</strong>,
codeowners: activityMentions
codeowners: principalMentions
}}
/>
</Case>
@ -508,7 +515,7 @@ export const SystemComment: React.FC<SystemCommentProps> = ({ pullReqMetadata, c
str={getString('prReview.defaultReviewers')}
vars={{
author: <strong>{payload?.author?.display_name}</strong>,
reviewers: activityMentions
reviewers: principalMentions
}}
/>
</Case>

View File

@ -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<GitInfoProps, 'repoMetadata' | 'pullReqMetadata'> {
defaultRevApprovalResponse: TypesDefaultReviewerApprovalsResponseWithRevDecision[]
}
export const DefaultReviewersPanel: React.FC<DefaultReviewersPanelProps> = ({
defaultRevApprovalResponse,
pullReqMetadata
}) => {
const { getString } = useStrings()
const columns = useMemo(
() =>
[
{
id: 'REQUIRED',
width: '40%',
sort: true,
Header: getString('required'),
accessor: 'REQUIRED',
Cell: ({ row }: CellProps<TypesDefaultReviewerApprovalsResponseWithRevDecision>) => {
if (row.original?.minimum_required_count && row.original?.minimum_required_count > 0)
return (
<Text
lineClamp={1}
padding={{ left: 'small', right: 'small' }}
color={Color.BLACK}
flex={{ justifyContent: 'space-between' }}>
{row.original.current_count} / {row.original.minimum_required_count}
</Text>
)
else if (row.original?.minimum_required_count_latest && row.original?.minimum_required_count_latest > 0)
return (
<Layout.Horizontal>
<Text
lineClamp={1}
padding={{ left: 'small', right: 'small' }}
color={Color.BLACK}
flex={{ justifyContent: 'space-between' }}>
{row.original.current_count} / {row.original.minimum_required_count_latest}
</Text>
<Text color={Color.GREY_350}>({getString('onLatestChanges')})</Text>
</Layout.Horizontal>
)
else <Text>{row.original.current_count}</Text>
}
},
{
id: 'DefaultReviewers',
width: '18%',
sort: true,
Header: getString('defaultReviewers'),
accessor: 'DefaultReviewers',
Cell: ({ row }: CellProps<TypesDefaultReviewerApprovalsResponseWithRevDecision>) => {
return (
<Layout.Horizontal
key={`keyContainer-${row.original.rule_info?.identifier}`}
className={css.ownerContainer}
spacing="tiny">
{row.original.principals?.map((principal, idx) => {
if (idx < 2) {
return (
<Avatar
key={`text-${principal?.display_name}-${idx}-avatar`}
hoverCard={true}
email={principal?.email || ' '}
size="small"
name={principal?.display_name || ''}
/>
)
}
if (idx === 2 && row.original.principals?.length && row.original.principals?.length > 2) {
return (
<Text
key={`text-${principal?.display_name}-${idx}-top`}
padding={{ top: 'xsmall' }}
tooltipProps={{ isDark: true }}
tooltip={
<Container width={215} padding={'small'}>
<Layout.Horizontal key={`tooltip-${idx}`} className={css.ownerTooltip}>
{row.original.principals?.map((entry, entryidx) => (
<Text
key={`text-${entry?.display_name}-${entryidx}`}
lineClamp={1}
color={Color.GREY_0}
padding={{ right: 'small' }}>
{row.original.principals?.length === entryidx + 1
? `${entry?.display_name}`
: `${entry?.display_name}, `}
</Text>
))}
</Layout.Horizontal>
</Container>
}
flex={{ alignItems: 'center' }}>{`+${row.original.principals?.length - 2}`}</Text>
)
}
return null
})}
</Layout.Horizontal>
)
}
},
{
id: 'changesRequested',
Header: getString('changesRequestedBy'),
width: '24%',
sort: true,
accessor: 'ChangesRequested',
Cell: ({ row }: CellProps<TypesDefaultReviewerApprovalsResponseWithRevDecision>) => {
const changeReqEvaluations = row?.original?.principals?.filter(
principal => principal?.review_decision === PullReqReviewDecision.CHANGEREQ
)
return (
<Layout.Horizontal className={css.ownerContainer} spacing="tiny">
{changeReqEvaluations?.map((principal, idx: number) => {
if (idx < 2) {
return (
<Avatar
key={`approved-${principal?.display_name}-avatar`}
hoverCard={true}
email={principal?.email || ' '}
size="small"
name={principal?.display_name || ''}
/>
)
}
if (idx === 2 && changeReqEvaluations.length && changeReqEvaluations.length > 2) {
return (
<Text
key={`approved-${principal?.display_name}-text`}
padding={{ top: 'xsmall' }}
tooltipProps={{ isDark: true }}
tooltip={
<Container width={215} padding={'small'}>
<Layout.Horizontal className={css.ownerTooltip}>
{changeReqEvaluations?.map(evalPrincipal => (
<Text
key={`approved-${evalPrincipal?.display_name}`}
lineClamp={1}
color={Color.GREY_0}
padding={{ right: 'small' }}>{`${evalPrincipal?.display_name}, `}</Text>
))}
</Layout.Horizontal>
</Container>
}
flex={{ alignItems: 'center' }}>{`+${changeReqEvaluations.length - 2}`}</Text>
)
}
return null
})}
</Layout.Horizontal>
)
}
},
{
id: 'approvedBy',
Header: getString('approvedBy'),
sort: true,
width: '15%',
accessor: 'APPROVED BY',
Cell: ({ row }: CellProps<TypesDefaultReviewerApprovalsResponseWithRevDecision>) => {
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 (
<Layout.Horizontal className={css.ownerContainer} spacing="tiny">
{approvedEvaluations?.map((principal, idx: number) => {
if (idx < 2) {
return (
<Avatar
key={`approved-${principal?.display_name}-avatar`}
hoverCard={true}
email={principal?.email || ' '}
size="small"
name={principal?.display_name || ''}
/>
)
}
if (idx === 2 && approvedEvaluations.length && approvedEvaluations.length > 2) {
return (
<Text
key={`approved-${principal?.display_name}-text`}
padding={{ top: 'xsmall' }}
tooltipProps={{ isDark: true }}
tooltip={
<Container width={215} padding={'small'}>
<Layout.Horizontal className={css.ownerTooltip}>
{approvedEvaluations?.map(appPrincipalObj => (
<Text
key={`approved-${appPrincipalObj?.display_name}`}
lineClamp={1}
color={Color.GREY_0}
padding={{ right: 'small' }}>{`${appPrincipalObj?.display_name}, `}</Text>
))}
</Layout.Horizontal>
</Container>
}
flex={{ alignItems: 'center' }}>{`+${approvedEvaluations.length - 2}`}</Text>
)
}
return null
})}
</Layout.Horizontal>
)
}
}
] as unknown as Column<TypesDefaultReviewerApprovalsResponseWithRevDecision>[], // eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
return (
<Render when={defaultRevApprovalResponse?.length}>
<Container>
<Layout.Vertical spacing="small">
<TableV2
className={css.codeOwnerTable}
sortable
columns={columns}
data={defaultRevApprovalResponse as TypesDefaultReviewerApprovalsResponseWithRevDecision[]}
getRowClassName={() => css.row}
/>
</Layout.Vertical>
</Container>
</Render>
)
}

View File

@ -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
}
})
}

View File

@ -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<OpenapiRule, UsererrorError, void, RuleAddRequestBody, RuleAddPathParams>,
export type RepoRuleAddProps = Omit<
MutateProps<OpenapiRule, UsererrorError, void, RepoRuleAddRequestBody, RepoRuleAddPathParams>,
'path' | 'verb'
> &
RuleAddPathParams
RepoRuleAddPathParams
export const RuleAdd = ({ repo_ref, ...props }: RuleAddProps) => (
<Mutate<OpenapiRule, UsererrorError, void, RuleAddRequestBody, RuleAddPathParams>
export const RepoRuleAdd = ({ repo_ref, ...props }: RepoRuleAddProps) => (
<Mutate<OpenapiRule, UsererrorError, void, RepoRuleAddRequestBody, RepoRuleAddPathParams>
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<OpenapiRule, UsererrorError, void, RuleAddRequestBody, RuleAddPathParams>,
export type UseRepoRuleAddProps = Omit<
UseMutateProps<OpenapiRule, UsererrorError, void, RepoRuleAddRequestBody, RepoRuleAddPathParams>,
'path' | 'verb'
> &
RuleAddPathParams
RepoRuleAddPathParams
export const useRuleAdd = ({ repo_ref, ...props }: UseRuleAddProps) =>
useMutate<OpenapiRule, UsererrorError, void, RuleAddRequestBody, RuleAddPathParams>(
export const useRepoRuleAdd = ({ repo_ref, ...props }: UseRepoRuleAddProps) =>
useMutate<OpenapiRule, UsererrorError, void, RepoRuleAddRequestBody, RepoRuleAddPathParams>(
'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<void, UsererrorError, void, string, RuleDeletePathParams>,
export type RepoRuleDeleteProps = Omit<
MutateProps<void, UsererrorError, void, string, RepoRuleDeletePathParams>,
'path' | 'verb'
> &
RuleDeletePathParams
RepoRuleDeletePathParams
export const RuleDelete = ({ repo_ref, ...props }: RuleDeleteProps) => (
<Mutate<void, UsererrorError, void, string, RuleDeletePathParams>
export const RepoRuleDelete = ({ repo_ref, ...props }: RepoRuleDeleteProps) => (
<Mutate<void, UsererrorError, void, string, RepoRuleDeletePathParams>
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<void, UsererrorError, void, string, RuleDeletePathParams>,
export type UseRepoRuleDeleteProps = Omit<
UseMutateProps<void, UsererrorError, void, string, RepoRuleDeletePathParams>,
'path' | 'verb'
> &
RuleDeletePathParams
RepoRuleDeletePathParams
export const useRuleDelete = ({ repo_ref, ...props }: UseRuleDeleteProps) =>
useMutate<void, UsererrorError, void, string, RuleDeletePathParams>(
export const useRepoRuleDelete = ({ repo_ref, ...props }: UseRepoRuleDeleteProps) =>
useMutate<void, UsererrorError, void, string, RepoRuleDeletePathParams>(
'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<GetProps<OpenapiRule, UsererrorError, void, RuleGetPathParams>, 'path'> &
RuleGetPathParams
export type RepoRuleGetProps = Omit<GetProps<OpenapiRule, UsererrorError, void, RepoRuleGetPathParams>, 'path'> &
RepoRuleGetPathParams
export const RuleGet = ({ repo_ref, rule_identifier, ...props }: RuleGetProps) => (
<Get<OpenapiRule, UsererrorError, void, RuleGetPathParams>
export const RepoRuleGet = ({ repo_ref, rule_identifier, ...props }: RepoRuleGetProps) => (
<Get<OpenapiRule, UsererrorError, void, RepoRuleGetPathParams>
path={`/repos/${repo_ref}/rules/${rule_identifier}`}
base={getConfig('code/api/v1')}
{...props}
/>
)
export type UseRuleGetProps = Omit<UseGetProps<OpenapiRule, UsererrorError, void, RuleGetPathParams>, 'path'> &
RuleGetPathParams
export type UseRepoRuleGetProps = Omit<UseGetProps<OpenapiRule, UsererrorError, void, RepoRuleGetPathParams>, 'path'> &
RepoRuleGetPathParams
export const useRuleGet = ({ repo_ref, rule_identifier, ...props }: UseRuleGetProps) =>
useGet<OpenapiRule, UsererrorError, void, RuleGetPathParams>(
(paramsInPath: RuleGetPathParams) => `/repos/${paramsInPath.repo_ref}/rules/${paramsInPath.rule_identifier}`,
export const useRepoRuleGet = ({ repo_ref, rule_identifier, ...props }: UseRepoRuleGetProps) =>
useGet<OpenapiRule, UsererrorError, void, RepoRuleGetPathParams>(
(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<OpenapiRule, UsererrorError, void, RuleUpdateRequestBody, RuleUpdatePathParams>,
export type RepoRuleUpdateProps = Omit<
MutateProps<OpenapiRule, UsererrorError, void, RepoRuleUpdateRequestBody, RepoRuleUpdatePathParams>,
'path' | 'verb'
> &
RuleUpdatePathParams
RepoRuleUpdatePathParams
export const RuleUpdate = ({ repo_ref, rule_identifier, ...props }: RuleUpdateProps) => (
<Mutate<OpenapiRule, UsererrorError, void, RuleUpdateRequestBody, RuleUpdatePathParams>
export const RepoRuleUpdate = ({ repo_ref, rule_identifier, ...props }: RepoRuleUpdateProps) => (
<Mutate<OpenapiRule, UsererrorError, void, RepoRuleUpdateRequestBody, RepoRuleUpdatePathParams>
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<OpenapiRule, UsererrorError, void, RuleUpdateRequestBody, RuleUpdatePathParams>,
export type UseRepoRuleUpdateProps = Omit<
UseMutateProps<OpenapiRule, UsererrorError, void, RepoRuleUpdateRequestBody, RepoRuleUpdatePathParams>,
'path' | 'verb'
> &
RuleUpdatePathParams
RepoRuleUpdatePathParams
export const useRuleUpdate = ({ repo_ref, rule_identifier, ...props }: UseRuleUpdateProps) =>
useMutate<OpenapiRule, UsererrorError, void, RuleUpdateRequestBody, RuleUpdatePathParams>(
export const useRepoRuleUpdate = ({ repo_ref, rule_identifier, ...props }: UseRepoRuleUpdateProps) =>
useMutate<OpenapiRule, UsererrorError, void, RepoRuleUpdateRequestBody, RepoRuleUpdatePathParams>(
'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 }
)

View File

@ -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:

View File

@ -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<number>) => void,
setReqCodeOwnerLatestApproval?: (value: React.SetStateAction<boolean>) => void,
setMinReqLatestApproval?: (value: React.SetStateAction<number>) => void,
setPRStateLoading?: (value: React.SetStateAction<boolean>) => void
setPRStateLoading?: (value: React.SetStateAction<boolean>) => void,
setDefaultReviewersInfoSet?: React.Dispatch<React.SetStateAction<TypesDefaultReviewerApprovalsResponse[]>>
) => {
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) || '')

View File

@ -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<string, BranchProtectionRule>
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
}