mirror of
https://github.com/harness/drone.git
synced 2025-05-05 15:32:56 +00:00
525 lines
21 KiB
TypeScript
525 lines
21 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, { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Button,
|
|
ButtonVariation,
|
|
Checkbox,
|
|
Container,
|
|
FlexExpander,
|
|
Layout,
|
|
SplitButton,
|
|
StringSubstitute,
|
|
Text,
|
|
useToaster
|
|
} from '@harnessio/uicore'
|
|
import { Icon } from '@harnessio/icons'
|
|
import { Color } from '@harnessio/design-system'
|
|
import { 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 ReactTimeago from 'react-timeago'
|
|
import type {
|
|
EnumPullReqState,
|
|
OpenapiMergePullReq,
|
|
OpenapiStatePullReqRequest,
|
|
TypesPullReq,
|
|
TypesRuleViolations
|
|
} from 'services/code'
|
|
import { useStrings } from 'framework/strings'
|
|
import { CodeIcon, PullRequestFilterOption, PullRequestState } from 'utils/GitUtils'
|
|
import { useGetSpaceParam } from 'hooks/useGetSpaceParam'
|
|
import { useAppContext } from 'AppContext'
|
|
import { Images } from 'images'
|
|
import {
|
|
extractInfoFromRuleViolationArr,
|
|
getErrorMessage,
|
|
MergeCheckStatus,
|
|
permissionProps,
|
|
PRDraftOption,
|
|
PRMergeOption,
|
|
PullRequestActionsBoxProps,
|
|
Violation
|
|
} from 'utils/Utils'
|
|
import { UserPreference, useUserPreference } from 'hooks/useUserPreference'
|
|
import ReviewSplitButton from 'components/Changes/ReviewSplitButton/ReviewSplitButton'
|
|
import RuleViolationAlertModal from 'components/RuleViolationAlertModal/RuleViolationAlertModal'
|
|
import css from './PullRequestActionsBox.module.scss'
|
|
|
|
const codeOwnersNotFoundMessage = 'CODEOWNERS file not found'
|
|
const codeOwnersNotFoundMessage2 = `path "CODEOWNERS" not found`
|
|
const codeOwnersNotFoundMessage3 = `failed to find node 'CODEOWNERS' in 'main': failed to get tree node: failed to ls file: path "CODEOWNERS" not found`
|
|
|
|
const POLLING_INTERVAL = 60000
|
|
export const PullRequestActionsBox: React.FC<PullRequestActionsBoxProps> = ({
|
|
repoMetadata,
|
|
pullRequestMetadata,
|
|
onPRStateChanged,
|
|
refetchReviewers
|
|
}) => {
|
|
const [isActionBoxOpen, setActionBoxOpen] = useState(false)
|
|
const { getString } = useStrings()
|
|
const { showError } = useToaster()
|
|
const { currentUser } = useAppContext()
|
|
const { hooks, standalone } = useAppContext()
|
|
const space = useGetSpaceParam()
|
|
const { mutate: mergePR, loading } = useMutate({
|
|
verb: 'POST',
|
|
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/merge`
|
|
})
|
|
const [ruleViolation, setRuleViolation] = useState(false)
|
|
const [ruleViolationArr, setRuleViolationArr] = useState<{ data: { rule_violations: TypesRuleViolations[] } }>()
|
|
const [length, setLength] = useState(0)
|
|
const [notBypassable, setNotBypassable] = useState(false)
|
|
const [finalRulesArr, setFinalRulesArr] = useState<Violation[]>()
|
|
const [bypass, setBypass] = useState(false)
|
|
const { mutate: updatePRState, loading: loadingState } = useMutate({
|
|
verb: 'POST',
|
|
path: `/api/v1/repos/${repoMetadata.path}/+/pullreq/${pullRequestMetadata.number}/state`
|
|
})
|
|
const mergeable = useMemo(
|
|
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.MERGEABLE,
|
|
[pullRequestMetadata]
|
|
)
|
|
const isClosed = pullRequestMetadata.state === PullRequestState.CLOSED
|
|
const isOpen = pullRequestMetadata.state === PullRequestState.OPEN
|
|
const isConflict = pullRequestMetadata.merge_check_status === MergeCheckStatus.CONFLICT
|
|
const unchecked = useMemo(
|
|
() => pullRequestMetadata.merge_check_status === MergeCheckStatus.UNCHECKED && !isClosed,
|
|
[pullRequestMetadata, isClosed]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (ruleViolationArr && !isDraft && ruleViolationArr.data.rule_violations) {
|
|
const { checkIfBypassAllowed, violationArr, uniqueViolations } = extractInfoFromRuleViolationArr(
|
|
ruleViolationArr.data.rule_violations
|
|
)
|
|
setNotBypassable(checkIfBypassAllowed)
|
|
setFinalRulesArr(violationArr)
|
|
setLength(uniqueViolations.size)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ruleViolationArr])
|
|
const dryMerge = () => {
|
|
if (!isClosed && pullRequestMetadata.state !== PullRequestState.MERGED) {
|
|
mergePR({ bypass_rules: true, dry_run: true, source_sha: pullRequestMetadata?.source_sha })
|
|
.then(res => {
|
|
if (res?.rule_violations?.length > 0) {
|
|
setRuleViolation(true)
|
|
setRuleViolationArr({ data: { rule_violations: res?.rule_violations } })
|
|
setAllowedStrats(res.allowed_methods)
|
|
} else {
|
|
setRuleViolation(false)
|
|
setAllowedStrats(res.allowed_methods)
|
|
}
|
|
})
|
|
.catch(err => {
|
|
if (err.status === 422) {
|
|
setRuleViolation(true)
|
|
setRuleViolationArr(err)
|
|
setAllowedStrats(err.allowed_methods)
|
|
} else if (
|
|
getErrorMessage(err) === codeOwnersNotFoundMessage ||
|
|
getErrorMessage(err) === codeOwnersNotFoundMessage2 ||
|
|
getErrorMessage(err) === codeOwnersNotFoundMessage3 ||
|
|
err.status === 423 // resource locked (merge / dry-run already ongoing)
|
|
) {
|
|
return
|
|
} else {
|
|
showError(getErrorMessage(err))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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() // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [unchecked, pullRequestMetadata?.source_sha])
|
|
|
|
useEffect(() => {
|
|
// dryMerge()
|
|
const intervalId = setInterval(async () => {
|
|
dryMerge()
|
|
}, POLLING_INTERVAL) // Poll every 20 seconds
|
|
// Cleanup interval on component unmount
|
|
return () => {
|
|
clearInterval(intervalId)
|
|
} // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [onPRStateChanged])
|
|
const isDraft = pullRequestMetadata.is_draft
|
|
const mergeOptions: PRMergeOption[] = [
|
|
{
|
|
method: 'squash',
|
|
title: getString('pr.mergeOptions.squashAndMerge'),
|
|
desc: getString('pr.mergeOptions.squashAndMergeDesc'),
|
|
disabled: mergeable === false
|
|
},
|
|
{
|
|
method: 'merge',
|
|
title: getString('pr.mergeOptions.createMergeCommit'),
|
|
desc: getString('pr.mergeOptions.createMergeCommitDesc'),
|
|
disabled: mergeable === false
|
|
},
|
|
{
|
|
method: 'rebase',
|
|
title: getString('pr.mergeOptions.rebaseAndMerge'),
|
|
desc: getString('pr.mergeOptions.rebaseAndMergeDesc'),
|
|
disabled: mergeable === false
|
|
},
|
|
{
|
|
method: 'close',
|
|
title: getString('pr.mergeOptions.close'),
|
|
desc: getString('pr.mergeOptions.closeDesc')
|
|
}
|
|
]
|
|
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 [mergeOption, setMergeOption, resetMergeOption] = useUserPreference<PRMergeOption>(
|
|
UserPreference.PULL_REQUEST_MERGE_STRATEGY,
|
|
mergeOptions[0],
|
|
option => option.method !== 'close'
|
|
)
|
|
useEffect(() => {
|
|
if (allowedStrats) {
|
|
const matchingMethods = mergeOptions.filter(option => allowedStrats.includes(option.method))
|
|
if (matchingMethods.length > 0) {
|
|
setMergeOption(matchingMethods[0])
|
|
}
|
|
} else {
|
|
setMergeOption(mergeOptions[3])
|
|
} // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [allowedStrats])
|
|
|
|
const [draftOption, setDraftOption] = useState<PRDraftOption>(draftOptions[0])
|
|
const permPushResult = hooks?.usePermissionTranslate?.(
|
|
{
|
|
resource: {
|
|
resourceType: 'CODE_REPOSITORY'
|
|
},
|
|
permissions: ['code_repo_push']
|
|
},
|
|
[space]
|
|
)
|
|
const isActiveUserPROwner = useMemo(() => {
|
|
return (
|
|
!!currentUser?.uid && !!pullRequestMetadata?.author?.uid && currentUser?.uid === pullRequestMetadata?.author?.uid
|
|
)
|
|
}, [currentUser, pullRequestMetadata])
|
|
|
|
if (pullRequestMetadata.state === PullRequestFilterOption.MERGED) {
|
|
return <MergeInfo pullRequestMetadata={pullRequestMetadata} />
|
|
}
|
|
return (
|
|
<Container
|
|
className={cx(css.main, {
|
|
[css.error]: mergeable === false && !unchecked && !isClosed && !isDraft,
|
|
[css.unchecked]: unchecked,
|
|
[css.closed]: isClosed,
|
|
[css.draft]: isDraft,
|
|
[css.ruleViolation]: ruleViolation
|
|
})}>
|
|
<Layout.Vertical spacing="xlarge">
|
|
<Container>
|
|
<Layout.Horizontal spacing="small" flex={{ alignItems: 'center' }} className={css.layout}>
|
|
{(unchecked && <img src={Images.PrUnchecked} width={20} height={20} />) || (
|
|
<Icon
|
|
name={
|
|
isDraft
|
|
? CodeIcon.Draft
|
|
: isClosed
|
|
? 'issue'
|
|
: mergeable === false
|
|
? 'warning-sign'
|
|
: ruleViolation
|
|
? 'warning-sign'
|
|
: 'tick-circle'
|
|
}
|
|
size={20}
|
|
color={
|
|
isDraft
|
|
? Color.ORANGE_900
|
|
: isClosed
|
|
? Color.GREY_500
|
|
: mergeable === false
|
|
? Color.RED_500
|
|
: ruleViolation
|
|
? Color.RED_500
|
|
: Color.GREEN_700
|
|
}
|
|
/>
|
|
)}
|
|
<Text
|
|
className={cx(css.sub, {
|
|
[css.unchecked]: unchecked,
|
|
[css.draft]: isDraft,
|
|
[css.closed]: isClosed,
|
|
[css.unmergeable]: mergeable === false && isOpen,
|
|
[css.ruleViolate]: ruleViolation && !isClosed
|
|
})}>
|
|
{getString(
|
|
isDraft
|
|
? 'prState.draftHeading'
|
|
: isClosed
|
|
? 'pr.prClosed'
|
|
: unchecked
|
|
? 'pr.checkingToMerge'
|
|
: mergeable === false && isOpen
|
|
? 'pr.cantBeMerged'
|
|
: ruleViolation
|
|
? 'branchProtection.prFailedText'
|
|
: 'pr.branchHasNoConflicts',
|
|
ruleViolation ? { ruleCount: length } : { ruleCount: 0 }
|
|
)}
|
|
{ruleViolation && mergeable && !isDraft ? (
|
|
<Button
|
|
className={css.viewDetailsBtn}
|
|
rightIcon={'chevron-right'}
|
|
variation={ButtonVariation.LINK}
|
|
text={getString('prChecks.viewExternal')}
|
|
onClick={() => {
|
|
setActionBoxOpen(true)
|
|
}}
|
|
/>
|
|
) : null}
|
|
<RuleViolationAlertModal
|
|
setOpen={setActionBoxOpen}
|
|
open={isActionBoxOpen}
|
|
title={getString('branchProtection.mergePrAlertTitle')}
|
|
text={getString('branchProtection.mergePrAlertText', { ruleCount: length })}
|
|
rules={finalRulesArr}
|
|
/>
|
|
</Text>
|
|
<FlexExpander />
|
|
<Render when={loading || loadingState}>
|
|
<Icon name={CodeIcon.InputSpinner} size={16} margin={{ right: 'xsmall' }} />
|
|
</Render>
|
|
<Match expr={isDraft}>
|
|
<Truthy>
|
|
<SplitButton
|
|
text={draftOption.title}
|
|
disabled={loading}
|
|
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={pullRequestMetadata.state}>
|
|
<Case val={PullRequestState.CLOSED}>
|
|
<Button
|
|
className={css.secondaryButton}
|
|
text={getString('pr.openForReview')}
|
|
variation={ButtonVariation.TERTIARY}
|
|
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 ? (
|
|
<Checkbox
|
|
className={css.checkbox}
|
|
checked={bypass}
|
|
label={getString('branchProtection.mergeCheckboxAlert')}
|
|
onChange={event => {
|
|
setBypass(event.currentTarget.checked)
|
|
}}
|
|
/>
|
|
) : null}
|
|
<ReviewSplitButton
|
|
shouldHide={(pullRequestMetadata?.state as EnumPullReqState) === 'merged'}
|
|
repoMetadata={repoMetadata}
|
|
pullRequestMetadata={pullRequestMetadata}
|
|
refreshPr={onPRStateChanged}
|
|
disabled={isActiveUserPROwner}
|
|
refetchReviewers={refetchReviewers}
|
|
/>
|
|
<Container
|
|
inline
|
|
padding={{ left: 'medium' }}
|
|
className={cx({
|
|
[css.btnWrapper]: mergeOption.method !== 'close',
|
|
[css.hasError]: mergeable === false,
|
|
[css.hasRuleViolated]: ruleViolation
|
|
})}>
|
|
<SplitButton
|
|
text={mergeOption.title}
|
|
disabled={
|
|
loading ||
|
|
(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' || mergeable === false || ruleViolation
|
|
? ButtonVariation.TERTIARY
|
|
: ButtonVariation.PRIMARY
|
|
}
|
|
popoverProps={{
|
|
interactionKind: 'click',
|
|
usePortal: true,
|
|
popoverClassName: css.popover,
|
|
position: PopoverPosition.BOTTOM_RIGHT,
|
|
transitionDuration: 1000
|
|
}}
|
|
{...permissionProps(permPushResult, standalone)}
|
|
onClick={async () => {
|
|
if (mergeOption.method !== 'close') {
|
|
const payload: OpenapiMergePullReq = {
|
|
method: mergeOption.method,
|
|
source_sha: pullRequestMetadata?.source_sha,
|
|
bypass_rules: bypass,
|
|
dry_run: false
|
|
}
|
|
mergePR(payload)
|
|
.then(() => {
|
|
onPRStateChanged()
|
|
setRuleViolationArr(undefined)
|
|
})
|
|
.catch(exception => showError(getErrorMessage(exception)))
|
|
} else {
|
|
updatePRState({ state: 'closed' })
|
|
.then(() => {
|
|
resetMergeOption()
|
|
onPRStateChanged()
|
|
setRuleViolationArr(undefined)
|
|
})
|
|
.catch(exception => showError(getErrorMessage(exception)))
|
|
}
|
|
}}>
|
|
{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)}
|
|
/>
|
|
)
|
|
})}
|
|
</SplitButton>
|
|
</Container>
|
|
</Layout.Horizontal>
|
|
</Case>
|
|
</Match>
|
|
</Container>
|
|
</Else>
|
|
</Match>
|
|
</Layout.Horizontal>
|
|
</Container>
|
|
</Layout.Vertical>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
const MergeInfo: React.FC<{ pullRequestMetadata: TypesPullReq }> = ({ pullRequestMetadata }) => {
|
|
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}>
|
|
<Icon name={CodeIcon.Merged} size={20} color={Color.PURPLE_700} />
|
|
</Container>
|
|
<Text className={cx(css.sub, css.merged)}>
|
|
<StringSubstitute
|
|
str={getString('pr.prMergedBannerInfo')}
|
|
vars={{
|
|
user: <strong>{pullRequestMetadata.merger?.display_name}</strong>,
|
|
source: <strong>{pullRequestMetadata.source_branch}</strong>,
|
|
target: <strong>{pullRequestMetadata.target_branch} </strong>,
|
|
time: <ReactTimeago date={pullRequestMetadata.merged as number} />
|
|
}}
|
|
/>
|
|
</Text>
|
|
<FlexExpander />
|
|
</Layout.Horizontal>
|
|
</Container>
|
|
)
|
|
}
|