drone/web/src/pages/PullRequest/Conversation/PullRequestActionsBox/PullRequestActionsBox.tsx

664 lines
28 KiB
TypeScript

/*
* 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, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Avatar,
Button,
ButtonVariation,
Checkbox,
Container,
FlexExpander,
Layout,
SplitButton,
StringSubstitute,
Text,
useIsMounted,
useToaster
} from '@harnessio/uicore'
import { Icon } from '@harnessio/icons'
import { Color, FontVariation } from '@harnessio/design-system'
import { MutateMethod, useMutate } from 'restful-react'
import { Case, Else, Match, Render, Truthy } from 'react-jsx-match'
import { Menu, PopoverPosition, Icon as BIcon } from '@blueprintjs/core'
import cx from 'classnames'
import { defaultTo } from 'lodash-es'
import type {
CreateBranchPathParams,
DeletePullReqSourceBranchQueryParams,
OpenapiCreateBranchRequest,
OpenapiStatePullReqRequest,
RebaseBranchRequestBody,
TypesListCommitResponse,
TypesPullReq,
TypesRuleViolations
} from 'services/code'
import { useStrings } from 'framework/strings'
import { CodeIcon, GitInfoProps, MergeStrategy, PullRequestState, dryMerge } from 'utils/GitUtils'
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
import { useAppContext } from 'AppContext'
import { getMergeOptions, PRDraftOption, type PRMergeOption } from 'pages/PullRequest/PullRequestUtils'
import {
extractInfoFromRuleViolationArr,
getErrorMessage,
inlineMergeFormRefType,
MergeCheckStatus,
permissionProps
} from 'utils/Utils'
import { OptionsMenuButton } from 'components/OptionsMenuButton/OptionsMenuButton'
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
import { PullReqSuggestionsBatch } from 'components/PullReqSuggestionsBatch/PullReqSuggestionsBatch'
import { TimePopoverWithLocal } from 'utils/timePopoverLocal/TimePopoverWithLocal'
import { BranchActionsButton } from '../PullRequestOverviewPanel/sections/BranchActionsSection'
import InlineMergeBox from './InlineMergeBox'
import css from './PullRequestActionsBox.module.scss'
export interface PullRequestActionsBoxProps extends Pick<GitInfoProps, 'repoMetadata' | 'pullReqMetadata'> {
onPRStateChanged: () => void
refetchReviewers: () => void
allowedStrategy: string[]
pullReqCommits?: TypesListCommitResponse
PRStateLoading: boolean
conflictingFiles?: string[]
setConflictingFiles: React.Dispatch<React.SetStateAction<string[] | undefined>>
refetchPullReq: () => void
refetchActivities: () => void
createBranch: MutateMethod<any, any, OpenapiCreateBranchRequest, CreateBranchPathParams>
refetchBranch: () => Promise<void>
deleteBranch: MutateMethod<any, any, DeletePullReqSourceBranchQueryParams, unknown>
showRestoreBranchButton: boolean
showDeleteBranchButton: boolean
setShowDeleteBranchButton: React.Dispatch<React.SetStateAction<boolean>>
setShowRestoreBranchButton: React.Dispatch<React.SetStateAction<boolean>>
isSourceBranchDeleted: boolean
mergeOption: PRMergeOption
setMergeOption: (val: PRMergeOption) => void
rebasePossible: boolean
}
export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
repoMetadata,
pullReqMetadata,
onPRStateChanged,
allowedStrategy,
pullReqCommits,
PRStateLoading,
setConflictingFiles,
refetchPullReq,
refetchActivities,
createBranch,
refetchBranch,
deleteBranch,
showRestoreBranchButton,
showDeleteBranchButton,
setShowRestoreBranchButton,
setShowDeleteBranchButton,
isSourceBranchDeleted,
mergeOption,
setMergeOption,
rebasePossible
}) => {
const { getString } = useStrings()
const { showSuccess, showError } = useToaster()
const inlineMergeRef = useRef<inlineMergeFormRefType>(null)
const { hooks, standalone } = useAppContext()
const space = useGetSpaceParam()
const { pullRequestSection } = useGetRepositoryMetadata()
const { mutate: mergePR, loading } = useMutate({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullReqMetadata.number}/merge`
})
const [ruleViolation, setRuleViolation] = useState(false)
const [ruleViolationArr, setRuleViolationArr] = useState<{ data: { rule_violations: TypesRuleViolations[] } }>()
const [notBypassable, setNotBypassable] = useState(false)
const [bypass, setBypass] = useState(false)
const { mutate: updatePRState } = useMutate({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullReqMetadata.number}/state`
})
const { mutate: rebase } = useMutate<RebaseBranchRequestBody>({
verb: 'POST',
path: `/api/v1/repos/${repoMetadata.path}/+/rebase`
})
const rebaseRequestPayload = {
base_branch: pullReqMetadata.target_branch,
bypass_rules: true,
dry_run_rules: false,
head_branch: pullReqMetadata.source_branch,
head_commit_sha: pullReqMetadata.source_sha
}
const mergeable = useMemo(() => pullReqMetadata.merge_check_status === MergeCheckStatus.MERGEABLE, [pullReqMetadata])
const isClosed = pullReqMetadata.state === PullRequestState.CLOSED
const isOpen = pullReqMetadata.state === PullRequestState.OPEN
const isMerged = pullReqMetadata.state === PullRequestState.MERGED
const isDraft = pullReqMetadata.is_draft
const isConflict = pullReqMetadata.merge_check_status === MergeCheckStatus.CONFLICT
const isMounted = useIsMounted()
const unchecked = useMemo(
() => pullReqMetadata.merge_check_status === MergeCheckStatus.UNCHECKED && !isClosed,
[pullReqMetadata, isClosed]
)
const handleSubmit = useCallback(() => {
if (inlineMergeRef.current) {
inlineMergeRef.current.handleSubmit()
}
}, [])
// Flags to optimize rendering
const internalFlags = useRef({ dryRun: false })
useEffect(() => {
if (ruleViolationArr && !isDraft && ruleViolationArr.data.rule_violations) {
const { checkIfBypassNotAllowed } = extractInfoFromRuleViolationArr(ruleViolationArr.data.rule_violations)
setNotBypassable(checkIfBypassNotAllowed)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ruleViolationArr])
useEffect(() => {
// recheck PR in case source SHA changed or PR was marked as unchecked
// TODO: optimize call to handle all causes and avoid double calls by keeping track of SHA
dryMerge(
isMounted,
isClosed,
pullReqMetadata,
internalFlags,
mergePR,
setRuleViolation,
setRuleViolationArr,
setAllowedStrats,
pullRequestSection,
showError,
setConflictingFiles,
refetchPullReq
) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [unchecked, pullReqMetadata?.source_sha])
useEffect(() => {
const intervalId = setInterval(async () => {
if (!isMerged && !isClosed) {
dryMerge(
isMounted,
isClosed,
pullReqMetadata,
internalFlags,
mergePR,
setRuleViolation,
setRuleViolationArr,
setAllowedStrats,
pullRequestSection,
showError,
setConflictingFiles,
refetchPullReq
)
}
}, POLLING_INTERVAL) // Poll every 10 seconds
// Cleanup interval on component unmount
return () => {
clearInterval(intervalId)
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [onPRStateChanged, isMerged, isClosed, pullReqMetadata?.source_sha])
const mergeOptions = useMemo(() => getMergeOptions(getString, mergeable).slice(0, 4), [mergeable])
const [allowedStrats, setAllowedStrats] = useState<string[]>([
mergeOptions[0].method,
mergeOptions[1].method,
mergeOptions[2].method,
mergeOptions[3].method
])
const draftOptions: PRDraftOption[] = [
{
method: 'open',
title: getString('pr.draftOpenForReview.title'),
desc: getString('pr.draftOpenForReview.desc')
},
{
method: 'close',
title: getString('pr.mergeOptions.close'),
desc: getString('pr.mergeOptions.closeDesc')
}
]
const [showInlineMergeContainer, setShowInlineMergeContainer] = useState(false)
useEffect(() => {
if (allowedStrats) {
const matchingMethods = mergeOptions.filter(option => allowedStrats.includes(option.method))
if (matchingMethods.length > 0) {
if (!matchingMethods.map(({ method }) => method).includes(mergeOption.method)) {
setMergeOption(matchingMethods[0])
} else if (mergeOption) {
setMergeOption(mergeOption)
}
}
} else {
setMergeOption(mergeOptions[3])
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowedStrats, allowedStrategy])
const [draftOption, setDraftOption] = useState<PRDraftOption>(draftOptions[0])
const permPushResult = hooks?.usePermissionTranslate?.(
{
resource: {
resourceType: 'CODE_REPOSITORY',
resourceIdentifier: repoMetadata?.identifier as string
},
permissions: ['code_repo_push']
},
[space]
)
const initialValues = useMemo(() => {
let messageString = ''
let messageTitle = ''
if (pullReqCommits?.commits) {
pullReqCommits?.commits.map(commit => {
messageString += `* ${commit.message}\n`
messageTitle =
mergeOption.method === MergeStrategy.SQUASH
? `${pullReqMetadata?.title} (#${pullReqMetadata?.number})`
: `Merge branch ${pullReqMetadata?.source_branch} of ${repoMetadata?.path} (#${pullReqMetadata?.number})`
})
}
return {
commitTitle: messageTitle,
commitMessage: mergeOption.method === MergeStrategy.SQUASH ? messageString.slice(0, 1000) : ''
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [pullReqCommits, mergeOption, pullReqMetadata])
if (isMerged) {
return (
<MergeInfo
pullRequestMetadata={pullReqMetadata}
showRestoreBranchButton={showRestoreBranchButton}
showDeleteBranchButton={showDeleteBranchButton}
setShowDeleteBranchButton={setShowDeleteBranchButton}
setShowRestoreBranchButton={setShowRestoreBranchButton}
refetchActivities={refetchActivities}
refetchBranch={refetchBranch}
createBranch={createBranch}
deleteBranch={deleteBranch}
/>
)
}
return (
<Container
className={cx(css.main, {
[css.primary]: !PRStateLoading,
[css.error]: mergeable === false && !unchecked && !isClosed && !isDraft,
[css.fferror]: mergeOption.method === MergeStrategy.FAST_FORWARD && rebasePossible,
[css.unchecked]: unchecked,
[css.closed]: isClosed,
[css.draft]: isDraft,
[css.ruleViolation]: ruleViolation
})}>
<Layout.Vertical spacing="xlarge" className={css.verticalContainer}>
<Container>
<Layout.Horizontal
spacing="small"
flex={{ alignItems: 'center' }}
className={cx(css.layout, { [css.shimmer]: PRStateLoading })}>
<Render when={!PRStateLoading}>
<Text
padding={{ left: 'medium' }}
className={cx(css.sub, {
[css.unchecked]: unchecked,
[css.draft]: isDraft,
[css.closed]: isClosed,
[css.unmergeable]: mergeable === false && isOpen,
[css.ffblock]: mergeOption.method === MergeStrategy.FAST_FORWARD && rebasePossible && isOpen,
[css.ruleViolate]: ruleViolation && !isClosed
})}>
{getString(
isDraft
? 'prState.draftHeading'
: isClosed
? 'pr.prClosed'
: unchecked
? 'pr.checkingToMerge'
: mergeable === false && isOpen
? 'branchProtection.prFailedText'
: ruleViolation
? 'branchProtection.prFailedText'
: mergeOption.method === MergeStrategy.FAST_FORWARD && rebasePossible
? 'branchProtection.prFailedText'
: 'pr.branchHasNoConflicts'
)}
</Text>
<FlexExpander />
<PullReqSuggestionsBatch />
<Match expr={isDraft}>
<Truthy>
<SplitButton
text={draftOption.title}
disabled={loading && !internalFlags.current.dryRun}
className={css.secondaryButton}
variation={ButtonVariation.TERTIARY}
popoverProps={{
interactionKind: 'click',
usePortal: true,
popoverClassName: css.popover,
position: PopoverPosition.BOTTOM_RIGHT,
transitionDuration: 1000
}}
{...permissionProps(permPushResult, standalone)}
onClick={async () => {
if (draftOption.method === 'open') {
updatePRState({ is_draft: false, state: 'open' })
.then(onPRStateChanged)
.catch(exception => showError(getErrorMessage(exception)))
} else {
updatePRState({ state: 'closed' })
.then(onPRStateChanged)
.catch(exception => showError(getErrorMessage(exception)))
}
}}>
{draftOptions.map(option => {
return (
<Menu.Item
key={option.method}
className={css.menuItem}
disabled={option.disabled}
text={
<>
<BIcon icon={draftOption.method === option.method ? 'tick' : 'blank'} />
<strong>{option.title}</strong>
<p>{option.desc}</p>
</>
}
onClick={() => setDraftOption(option)}
/>
)
})}
</SplitButton>
</Truthy>
<Else>
<Container>
<Match expr={pullReqMetadata.state}>
<Case val={PullRequestState.CLOSED}>
<Button
margin={{ right: 'small' }}
className={css.secondaryButton}
text={getString('pr.openForReview')}
variation={ButtonVariation.SECONDARY}
disabled={isSourceBranchDeleted}
onClick={() => {
const payload: OpenapiStatePullReqRequest = { state: 'open' }
updatePRState(payload)
.then(onPRStateChanged)
.catch(exception => showError(getErrorMessage(exception)))
}}
/>
</Case>
<Case val={PullRequestState.OPEN}>
<Layout.Horizontal>
{!notBypassable && mergeable && !isDraft && ruleViolation && !showInlineMergeContainer ? (
<Checkbox
className={css.checkbox}
checked={bypass}
label={getString('branchProtection.mergeCheckboxAlert')}
onChange={event => {
setBypass(event.currentTarget.checked)
}}
/>
) : null}
{!showInlineMergeContainer && (
<Container
inline
padding={{ left: 'medium' }}
className={cx({
[css.btnWrapper]: mergeOption.method !== 'close',
[css.hasRuleViolated]: ruleViolation,
[css.disableBtn]: showInlineMergeContainer
})}>
<SplitButton
text={mergeOption.title}
disabled={
(loading && !internalFlags.current.dryRun) ||
(unchecked && mergeOption.method !== 'close') ||
(isConflict && mergeOption.method !== 'close') ||
(ruleViolation && !bypass && mergeOption.method !== 'close')
}
className={cx({
[css.secondaryButton]: mergeOption.method === 'close' || mergeable === false
})}
variation={
mergeOption.method === 'close' ? ButtonVariation.TERTIARY : ButtonVariation.SECONDARY
}
dropdownDisabled={
(loading && !internalFlags.current.dryRun) ||
(unchecked && mergeOption.method !== 'close') ||
(isConflict && mergeOption.method !== 'close') ||
(ruleViolation && !bypass && mergeOption.method !== 'close')
}
popoverProps={{
interactionKind: 'click',
usePortal: true,
popoverClassName: css.popover,
position: PopoverPosition.BOTTOM_RIGHT,
transitionDuration: 1000
}}
{...permissionProps(permPushResult, standalone)}
onClick={() => {
setShowInlineMergeContainer(true)
}}>
{mergeOptions.map(option => {
const mergeCheck =
allowedStrats !== undefined && allowedStrats.includes(option.method)
return (
<Menu.Item
key={option.method}
className={css.menuItem}
disabled={option.method !== 'close' ? !mergeCheck : option.disabled}
text={
<>
<BIcon icon={mergeOption.method === option.method ? 'tick' : 'blank'} />
<strong>{option.title}</strong>
<p>{option.desc}</p>
</>
}
onClick={() => {
setMergeOption(option)
setShowInlineMergeContainer(true)
}}
/>
)
})}
</SplitButton>
</Container>
)}
{showInlineMergeContainer && (
<Layout.Horizontal className={css.btnContainer}>
<Container padding={{ right: 'small' }}>
<Button
text={getString('cancel')}
variation={ButtonVariation.TERTIARY}
onClick={() => {
setShowInlineMergeContainer(false)
}}></Button>
</Container>
<Button
type="submit"
onClick={handleSubmit}
disabled={
(loading && !internalFlags.current.dryRun) ||
isMerged ||
(mergeOption.method === MergeStrategy.FAST_FORWARD && rebasePossible)
}
variation={ButtonVariation.PRIMARY}
text={getString('confirmStrat', { strat: mergeOption.title })}
/>
</Layout.Horizontal>
)}
<OptionsMenuButton
iconProps={{ size: 24 }}
className={css.optionMenuButton}
{...permissionProps(permPushResult, standalone)}
isDark
items={[
{
hasIcon: true,
iconName: CodeIcon.Draft,
text: getString('markAsDraft'),
onClick: () => {
updatePRState({ is_draft: true, state: 'open' })
.then(onPRStateChanged)
.catch(exception => showError(getErrorMessage(exception)))
}
},
{
hasIcon: true,
iconName: 'code-rejected',
text: getString('pr.mergeOptions.close'),
onClick: async () => {
updatePRState({ state: 'closed' })
.then(() => {
onPRStateChanged()
})
.catch(exception => showError(getErrorMessage(exception)))
}
},
...(rebasePossible
? [
{
hasIcon: true,
iconName: 'code-pull',
text: getString('rebase'),
onClick: () =>
rebase(rebaseRequestPayload)
.then(() => {
showSuccess(getString('updatedBranchMessageRebase'))
setTimeout(() => {
refetchActivities()
}, 1000)
})
.catch(err => showError(getErrorMessage(err)))
}
]
: [])
]}
tooltipProps={{
interactionKind: 'click',
isDark: true,
position: PopoverPosition.RIGHT,
popoverClassName: css.overviewPopover
}}
/>
</Layout.Horizontal>
</Case>
</Match>
</Container>
</Else>
</Match>
</Render>
</Layout.Horizontal>
</Container>
{showInlineMergeContainer && (
<InlineMergeBox
inlineMergeRef={inlineMergeRef}
bypass={bypass}
mergePR={mergePR}
setShowInlineMergeContainer={setShowInlineMergeContainer}
mergeOption={mergeOption}
onPRStateChanged={onPRStateChanged}
initialValues={initialValues}
setRuleViolationArr={setRuleViolationArr}
pullReqMetadata={pullReqMetadata}
refetchActivities={refetchActivities}
refetchPullReq={refetchPullReq}
/>
)}
</Layout.Vertical>
</Container>
)
}
const MergeInfo: React.FC<{
pullRequestMetadata: TypesPullReq
showRestoreBranchButton: boolean
showDeleteBranchButton: boolean
setShowDeleteBranchButton: React.Dispatch<React.SetStateAction<boolean>>
setShowRestoreBranchButton: React.Dispatch<React.SetStateAction<boolean>>
refetchActivities: () => void
refetchBranch: () => Promise<void>
createBranch: MutateMethod<any, any, OpenapiCreateBranchRequest, CreateBranchPathParams>
deleteBranch: MutateMethod<any, any, DeletePullReqSourceBranchQueryParams, unknown>
}> = props => {
const { pullRequestMetadata, showRestoreBranchButton, showDeleteBranchButton } = props
const { getString } = useStrings()
return (
<Container className={cx(css.main, css.merged)}>
<Layout.Horizontal spacing="medium" flex={{ alignItems: 'center' }} className={css.layout}>
<Container width={24} height={24} className={css.mergeContainer}>
<Avatar hoverCard={false} size="small" name={pullRequestMetadata?.merger?.display_name} />
</Container>
<Text flex={{ alignItems: 'center' }} className={cx(css.sub, css.merged)}>
<StringSubstitute
str={getString('pr.prMergedBannerInfo')}
vars={{
user: (
<Container padding={{ right: 'small' }}>
<strong className={css.boldText}>{pullRequestMetadata.merger?.display_name}</strong>
</Container>
),
source: (
<Container padding={{ left: 'small', right: 'small' }}>
<strong className={cx(css.boldText, css.purpleContainer)}>
<Icon name={CodeIcon.Merged} size={16} color={Color.PURPLE_700} />
<Text className={cx(css.boldText, css.widthContainer)} lineClamp={1}>
{pullRequestMetadata?.source_branch}
</Text>
</strong>
</Container>
),
target: (
<Container padding={{ left: 'small', right: 'small' }}>
<strong className={cx(css.boldText, css.purpleContainer)}>
<Icon name={CodeIcon.Merged} size={16} color={Color.PURPLE_700} />
<Text className={cx(css.boldText, css.widthContainer)} lineClamp={1}>
{pullRequestMetadata?.target_branch}
</Text>
</strong>
</Container>
),
time: (
<TimePopoverWithLocal
className={css.dateText}
time={defaultTo(pullRequestMetadata.merged as number, 0)}
inline={false}
font={{ variation: FontVariation.SMALL }}
color={Color.GREY_400}
/>
)
}}
/>
</Text>
<FlexExpander />
{(showDeleteBranchButton || showRestoreBranchButton) && (
<BranchActionsButton
{...props}
sourceSha={pullRequestMetadata.source_sha || ''}
sourceBranch={pullRequestMetadata.source_branch || ''}
/>
)}
</Layout.Horizontal>
</Container>
)
}
const POLLING_INTERVAL = 10000