/* * 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 { onPRStateChanged: () => void refetchReviewers: () => void allowedStrategy: string[] pullReqCommits?: TypesListCommitResponse PRStateLoading: boolean conflictingFiles?: string[] setConflictingFiles: React.Dispatch> refetchPullReq: () => void refetchActivities: () => void createBranch: MutateMethod refetchBranch: () => Promise deleteBranch: MutateMethod showRestoreBranchButton: boolean showDeleteBranchButton: boolean setShowDeleteBranchButton: React.Dispatch> setShowRestoreBranchButton: React.Dispatch> isSourceBranchDeleted: boolean mergeOption: PRMergeOption setMergeOption: (val: PRMergeOption) => void rebasePossible: boolean } export const PullRequestActionsBox: React.FC = ({ 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(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({ 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([ 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(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 ( ) } return ( {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' )} { 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 ( {option.title}

{option.desc}

} onClick={() => setDraftOption(option)} /> ) })}