// 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. package pullreq import ( "context" "fmt" "strings" "time" "github.com/harness/gitness/app/api/controller" "github.com/harness/gitness/app/api/usererror" "github.com/harness/gitness/app/auth" "github.com/harness/gitness/app/bootstrap" pullreqevents "github.com/harness/gitness/app/events/pullreq" "github.com/harness/gitness/app/services/codeowners" "github.com/harness/gitness/app/services/instrument" "github.com/harness/gitness/app/services/protection" "github.com/harness/gitness/contextutil" "github.com/harness/gitness/errors" "github.com/harness/gitness/git" gitenum "github.com/harness/gitness/git/enum" "github.com/harness/gitness/git/sha" "github.com/harness/gitness/types" "github.com/harness/gitness/types/enum" "github.com/gotidy/ptr" "github.com/rs/zerolog/log" ) type MergeInput struct { Method enum.MergeMethod `json:"method"` SourceSHA string `json:"source_sha"` Title string `json:"title"` Message string `json:"message"` BypassRules bool `json:"bypass_rules"` DryRun bool `json:"dry_run"` } func (in *MergeInput) sanitize() error { if in.Method == "" && !in.DryRun { return usererror.BadRequest("merge method must be provided if dry run is false") } if in.SourceSHA == "" { return usererror.BadRequest("source SHA must be provided") } if in.Method != "" { method, ok := in.Method.Sanitize() if !ok { return usererror.BadRequestf("unsupported merge method: %s", in.Method) } in.Method = method } // cleanup title / message (NOTE: git doesn't support white space only) in.Title = strings.TrimSpace(in.Title) in.Message = strings.TrimSpace(in.Message) if (in.Method == enum.MergeMethodRebase || in.Method == enum.MergeMethodFastForward) && (in.Title != "" || in.Message != "") { return usererror.BadRequestf( "merge method %q doesn't support customizing commit title and message", in.Method) } return nil } // Merge merges a pull request. // // It supports dry running by providing the DryRun=true. Dry running can be used to find any rule violations that // might block the merging. Dry running typically should be used with BypassRules=true. // // MergeMethod doesn't need to be provided for dry running. If no MergeMethod has been provided the function will // return allowed merge methods. Rules can limit allowed merge methods. // // If the pull request has been successfully merged the function will return the SHA of the merge commit. // //nolint:gocognit,gocyclo,cyclop func (c *Controller) Merge( ctx context.Context, session *auth.Session, repoRef string, pullreqNum int64, in *MergeInput, ) (*types.MergeResponse, *types.MergeViolations, error) { if err := in.sanitize(); err != nil { return nil, nil, err } requiredPermission := enum.PermissionRepoPush if in.DryRun { requiredPermission = enum.PermissionRepoView } targetRepo, err := c.getRepoCheckAccess(ctx, session, repoRef, requiredPermission) if err != nil { return nil, nil, fmt.Errorf("failed to acquire access to target repo: %w", err) } // the max time we give a merge to succeed const timeout = 3 * time.Minute var lockID int64 // 0 means locking repo level for prs (for actual merging) if in.DryRun { lockID = pullreqNum // dryrun doesn't need repo level lock } // if two requests for merging comes at the same time then unlock will lock // first one and second one will wait, when first one is done then second one // continue with latest data from db with state merged and return error that // pr is already merged. unlock, err := c.locker.LockPR( ctx, targetRepo.ID, lockID, timeout+30*time.Second, // add 30s to the lock to give enough time for pre + post merge ) if err != nil { return nil, nil, fmt.Errorf("failed to lock repository for pull request merge: %w", err) } defer unlock() pr, err := c.pullreqStore.FindByNumber(ctx, targetRepo.ID, pullreqNum) if err != nil { return nil, nil, fmt.Errorf("failed to get pull request by number: %w", err) } if pr.Merged != nil { return nil, nil, usererror.BadRequest("Pull request already merged") } if pr.State != enum.PullReqStateOpen { return nil, nil, usererror.BadRequest("Pull request must be open") } if pr.SourceSHA != in.SourceSHA { return nil, nil, usererror.BadRequest("A newer commit is available. Only the latest commit can be merged.") } if pr.IsDraft && !in.DryRun { return nil, nil, usererror.BadRequest( "Draft pull requests can't be merged. Clear the draft flag first.", ) } reviewers, err := c.reviewerStore.List(ctx, pr.ID) if err != nil { return nil, nil, fmt.Errorf("failed to load list of reviwers: %w", err) } targetWriteParams, err := controller.CreateRPCInternalWriteParams(ctx, c.urlProvider, session, targetRepo) if err != nil { return nil, nil, fmt.Errorf("failed to create RPC write params: %w", err) } sourceRepo := targetRepo sourceWriteParams := targetWriteParams if pr.SourceRepoID != pr.TargetRepoID { sourceWriteParams, err = controller.CreateRPCInternalWriteParams(ctx, c.urlProvider, session, sourceRepo) if err != nil { return nil, nil, fmt.Errorf("failed to create RPC write params: %w", err) } sourceRepo, err = c.repoStore.Find(ctx, pr.SourceRepoID) if err != nil { return nil, nil, fmt.Errorf("failed to get source repository: %w", err) } } protectionRules, isRepoOwner, err := c.fetchRules(ctx, session, targetRepo) if err != nil { return nil, nil, fmt.Errorf("failed to fetch rules: %w", err) } checkResults, err := c.checkStore.ListResults(ctx, targetRepo.ID, pr.SourceSHA) if err != nil { return nil, nil, fmt.Errorf("failed to list status checks: %w", err) } codeOwnerWithApproval, err := c.codeOwners.Evaluate(ctx, sourceRepo, pr, reviewers) // check for error and ignore if it is codeowners file not found else throw error if err != nil && !errors.Is(err, codeowners.ErrNotFound) { return nil, nil, fmt.Errorf("CODEOWNERS evaluation failed: %w", err) } ruleOut, violations, err := protectionRules.MergeVerify(ctx, protection.MergeVerifyInput{ ResolveUserGroupID: c.userGroupService.ListUserIDsByGroupIDs, Actor: &session.Principal, AllowBypass: in.BypassRules, IsRepoOwner: isRepoOwner, TargetRepo: targetRepo, SourceRepo: sourceRepo, PullReq: pr, Reviewers: reviewers, Method: in.Method, CheckResults: checkResults, CodeOwners: codeOwnerWithApproval, }) if err != nil { return nil, nil, fmt.Errorf("failed to verify protection rules: %w", err) } // we want to complete the merge independent of request cancel - start with new, time restricted context. // TODO: This is a small change to reduce likelihood of dirty state. // We still require a proper solution to handle an application crash or very slow execution times // (which could cause an unlocking pre operation completion). ctx, cancel := context.WithTimeout( contextutil.WithNewValues(context.Background(), ctx), timeout, ) defer cancel() //nolint:nestif if in.DryRun { // As the merge API is always executed under a global lock, we use the opportunity of dry-running the merge // to check the PR's mergeability status if it's currently "unchecked". This can happen if the target branch // has advanced. It's possible that the merge base commit is different too. // So, the next time the API gets called for the same PR the mergeability status will not be unchecked. // Without dry-run the execution would proceed below and would either merge the PR or set the conflict status. if pr.MergeCheckStatus == enum.MergeCheckStatusUnchecked { mergeOutput, err := c.git.Merge(ctx, &git.MergeParams{ WriteParams: targetWriteParams, BaseBranch: pr.TargetBranch, HeadRepoUID: sourceRepo.GitUID, HeadBranch: pr.SourceBranch, RefType: gitenum.RefTypeUndefined, // update no refs -> no commit will be created HeadExpectedSHA: sha.Must(in.SourceSHA), }) if err != nil { return nil, nil, fmt.Errorf("merge check execution failed: %w", err) } pr, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error { if pr.SourceSHA != mergeOutput.HeadSHA.String() { return errors.New("source SHA has changed") } // actual merge is using a different lock - ensure we don't overwrite any merge results. if pr.State != enum.PullReqStateOpen { return usererror.BadRequest("Pull request must be open") } if len(mergeOutput.ConflictFiles) > 0 { pr.MergeCheckStatus = enum.MergeCheckStatusConflict pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String() pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String()) pr.MergeSHA = nil pr.MergeConflicts = mergeOutput.ConflictFiles } else { pr.MergeCheckStatus = enum.MergeCheckStatusMergeable pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String() pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String()) pr.MergeSHA = nil // dry-run doesn't create a merge commit so output is empty. pr.MergeConflicts = nil } pr.Stats.DiffStats = types.NewDiffStats( mergeOutput.CommitCount, mergeOutput.ChangedFileCount, mergeOutput.Additions, mergeOutput.Deletions, ) return nil }) if err != nil { // non-critical error log.Ctx(ctx).Warn().Err(err).Msg("failed to update unchecked pull request") } else { if err = c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullRequestUpdated, pr); err != nil { log.Ctx(ctx).Warn().Err(err).Msg("failed to publish PR changed event") } } } // With in.DryRun=true this function never returns types.MergeViolations out := &types.MergeResponse{ BranchDeleted: ruleOut.DeleteSourceBranch, RuleViolations: violations, // values only returned by dry run DryRun: true, ConflictFiles: pr.MergeConflicts, AllowedMethods: ruleOut.AllowedMethods, RequiresCodeOwnersApproval: ruleOut.RequiresCodeOwnersApproval, RequiresCodeOwnersApprovalLatest: ruleOut.RequiresCodeOwnersApprovalLatest, RequiresCommentResolution: ruleOut.RequiresCommentResolution, RequiresNoChangeRequests: ruleOut.RequiresNoChangeRequests, MinimumRequiredApprovalsCount: ruleOut.MinimumRequiredApprovalsCount, MinimumRequiredApprovalsCountLatest: ruleOut.MinimumRequiredApprovalsCountLatest, } return out, nil, nil } if protection.IsCritical(violations) { sb := strings.Builder{} for i, ruleViolation := range violations { if i > 0 { sb.WriteByte(',') } sb.WriteString(ruleViolation.Rule.Identifier) sb.WriteString(":[") for j, v := range ruleViolation.Violations { if j > 0 { sb.WriteByte(',') } sb.WriteString(v.Code) } sb.WriteString("]") } log.Ctx(ctx).Info().Msgf("aborting pull request merge because of rule violations: %s", sb.String()) return nil, &types.MergeViolations{ RuleViolations: violations, Message: protection.GenerateErrorMessageForBlockingViolations(violations), }, nil } // commit details: author, committer and message var author *git.Identity switch in.Method { case enum.MergeMethodMerge: author = identityFromPrincipalInfo(*session.Principal.ToPrincipalInfo()) case enum.MergeMethodSquash: author = identityFromPrincipalInfo(pr.Author) case enum.MergeMethodRebase, enum.MergeMethodFastForward: author = nil // Not important for these merge methods: the author info in the commits will be preserved. } var committer *git.Identity switch in.Method { case enum.MergeMethodMerge, enum.MergeMethodSquash: committer = identityFromPrincipalInfo(*bootstrap.NewSystemServiceSession().Principal.ToPrincipalInfo()) case enum.MergeMethodRebase: committer = identityFromPrincipalInfo(*session.Principal.ToPrincipalInfo()) case enum.MergeMethodFastForward: committer = nil // Not important for fast-forward merge } // backfill commit title if none provided if in.Title == "" { switch in.Method { case enum.MergeMethodMerge: in.Title = fmt.Sprintf("Merge branch '%s' of %s (#%d)", pr.SourceBranch, sourceRepo.Path, pr.Number) case enum.MergeMethodSquash: in.Title = fmt.Sprintf("%s (#%d)", pr.Title, pr.Number) case enum.MergeMethodRebase, enum.MergeMethodFastForward: // Not used. } } // create merge commit(s) log.Ctx(ctx).Debug().Msgf("all pre-check passed, merge PR") now := time.Now() mergeOutput, err := c.git.Merge(ctx, &git.MergeParams{ WriteParams: targetWriteParams, BaseBranch: pr.TargetBranch, HeadRepoUID: sourceRepo.GitUID, HeadBranch: pr.SourceBranch, Title: in.Title, Message: in.Message, Committer: committer, CommitterDate: &now, Author: author, AuthorDate: &now, RefType: gitenum.RefTypeBranch, RefName: pr.TargetBranch, HeadExpectedSHA: sha.Must(in.SourceSHA), Method: gitenum.MergeMethod(in.Method), }) if err != nil { return nil, nil, fmt.Errorf("merge execution failed: %w", err) } //nolint:nestif if mergeOutput.MergeSHA.String() == "" || len(mergeOutput.ConflictFiles) > 0 { _, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error { if pr.SourceSHA != mergeOutput.HeadSHA.String() { return errors.New("source SHA has changed") } // update all Merge specific information pr.MergeCheckStatus = enum.MergeCheckStatusConflict pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String() pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String()) pr.MergeSHA = nil pr.MergeConflicts = mergeOutput.ConflictFiles pr.Stats.DiffStats = types.NewDiffStats( mergeOutput.CommitCount, mergeOutput.ChangedFileCount, mergeOutput.Additions, mergeOutput.Deletions, ) return nil }) if err != nil { // non-critical error log.Ctx(ctx).Warn().Err(err).Msg("failed to update pull request with conflict files") } else { if err = c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullRequestUpdated, pr); err != nil { log.Ctx(ctx).Warn().Err(err).Msg("failed to publish PR changed event") } } log.Ctx(ctx).Info().Msg("aborting pull request merge because of conflicts") return nil, &types.MergeViolations{ ConflictFiles: mergeOutput.ConflictFiles, RuleViolations: violations, // In case of conflicting files we prioritize those for the error message. Message: fmt.Sprintf("Merge blocked by conflicting files: %v", mergeOutput.ConflictFiles), }, nil } log.Ctx(ctx).Debug().Msgf("successfully merged PR") mergedBy := session.Principal.ID var activitySeqMerge, activitySeqBranchDeleted int64 pr, err = c.pullreqStore.UpdateOptLock(ctx, pr, func(pr *types.PullReq) error { pr.State = enum.PullReqStateMerged nowMilli := now.UnixMilli() pr.Merged = &nowMilli pr.MergedBy = &mergedBy pr.MergeMethod = &in.Method // update all Merge specific information (might be empty if previous merge check failed) // since this is the final operation on the PR, we update any sha that might've changed by now. pr.MergeCheckStatus = enum.MergeCheckStatusMergeable pr.SourceSHA = mergeOutput.HeadSHA.String() pr.MergeTargetSHA = ptr.String(mergeOutput.BaseSHA.String()) pr.MergeBaseSHA = mergeOutput.MergeBaseSHA.String() pr.MergeSHA = ptr.String(mergeOutput.MergeSHA.String()) pr.MergeConflicts = nil pr.Stats.DiffStats = types.NewDiffStats( mergeOutput.CommitCount, mergeOutput.ChangedFileCount, mergeOutput.Additions, mergeOutput.Deletions, ) // update sequence for PR activities pr.ActivitySeq++ activitySeqMerge = pr.ActivitySeq if ruleOut.DeleteSourceBranch { pr.ActivitySeq++ activitySeqBranchDeleted = pr.ActivitySeq } return nil }) if err != nil { return nil, nil, fmt.Errorf("failed to update pull request: %w", err) } pr.ActivitySeq = activitySeqMerge activityPayload := &types.PullRequestActivityPayloadMerge{ MergeMethod: in.Method, MergeSHA: mergeOutput.MergeSHA.String(), TargetSHA: mergeOutput.BaseSHA.String(), SourceSHA: mergeOutput.HeadSHA.String(), RulesBypassed: protection.IsBypassed(violations), } if _, errAct := c.activityStore.CreateWithPayload(ctx, pr, mergedBy, activityPayload, nil); errAct != nil { // non-critical error log.Ctx(ctx).Err(errAct).Msgf("failed to write pull req merge activity") } c.eventReporter.Merged(ctx, &pullreqevents.MergedPayload{ Base: eventBase(pr, &session.Principal), MergeMethod: in.Method, MergeSHA: mergeOutput.MergeSHA.String(), TargetSHA: mergeOutput.BaseSHA.String(), SourceSHA: mergeOutput.HeadSHA.String(), }) var branchDeleted bool if ruleOut.DeleteSourceBranch { errDelete := c.git.DeleteBranch(ctx, &git.DeleteBranchParams{ WriteParams: sourceWriteParams, BranchName: pr.SourceBranch, }) if errDelete != nil { // non-critical error log.Ctx(ctx).Err(errDelete).Msgf("failed to delete source branch after merging") } else { branchDeleted = true // NOTE: there is a chance someone pushed on the branch between merge and delete. // Either way, we'll use the SHA that was merged with for the activity to be consistent from PR perspective. pr.ActivitySeq = activitySeqBranchDeleted if _, errAct := c.activityStore.CreateWithPayload(ctx, pr, mergedBy, &types.PullRequestActivityPayloadBranchDelete{SHA: in.SourceSHA}, nil); errAct != nil { // non-critical error log.Ctx(ctx).Err(errAct). Msgf("failed to write pull request activity for successful automatic branch delete") } } } if err = c.sseStreamer.Publish(ctx, targetRepo.ParentID, enum.SSETypePullRequestUpdated, pr); err != nil { log.Ctx(ctx).Warn().Err(err).Msg("failed to publish PR changed event") } err = c.instrumentation.Track(ctx, instrument.Event{ Type: instrument.EventTypeMergePullRequest, Principal: session.Principal.ToPrincipalInfo(), Path: sourceRepo.Path, Properties: map[instrument.Property]any{ instrument.PropertyRepositoryID: sourceRepo.ID, instrument.PropertyRepositoryName: sourceRepo.Identifier, instrument.PropertyPullRequestID: pr.Number, instrument.PropertyMergeStrategy: in.Method, }, }) if err != nil { log.Ctx(ctx).Warn().Msgf("failed to insert instrumentation record for merge pr operation: %s", err) } return &types.MergeResponse{ SHA: mergeOutput.MergeSHA.String(), BranchDeleted: branchDeleted, RuleViolations: violations, }, nil, nil }