From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- services/pull/pull.go | 1032 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1032 insertions(+) create mode 100644 services/pull/pull.go (limited to 'services/pull/pull.go') diff --git a/services/pull/pull.go b/services/pull/pull.go new file mode 100644 index 0000000..6af7d8b --- /dev/null +++ b/services/pull/pull.go @@ -0,0 +1,1032 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package pull + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "regexp" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/process" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sync" + "code.gitea.io/gitea/modules/util" + gitea_context "code.gitea.io/gitea/services/context" + issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" +) + +// TODO: use clustered lock (unique queue? or *abuse* cache) +var pullWorkingPool = sync.NewExclusivePool() + +// NewPullRequest creates new pull request with labels for repository. +func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, pr *issues_model.PullRequest, assigneeIDs []int64) error { + // Check if the doer is not blocked by the repository's owner. + if user_model.IsBlocked(ctx, repo.OwnerID, issue.PosterID) { + return user_model.ErrBlockedByUser + } + + prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) + if err != nil { + if !git_model.IsErrBranchNotExist(err) { + log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err) + } + return err + } + defer cancel() + + if err := testPatch(ctx, prCtx, pr); err != nil { + return err + } + + divergence, err := git.GetDivergingCommits(ctx, prCtx.tmpBasePath, baseBranch, trackingBranch) + if err != nil { + return err + } + pr.CommitsAhead = divergence.Ahead + pr.CommitsBehind = divergence.Behind + + assigneeCommentMap := make(map[int64]*issues_model.Comment) + + // add first push codes comment + baseGitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo) + if err != nil { + return err + } + defer baseGitRepo.Close() + + var reviewNotifers []*issue_service.ReviewRequestNotifier + if err := db.WithTx(ctx, func(ctx context.Context) error { + if err := issues_model.NewPullRequest(ctx, repo, issue, labelIDs, uuids, pr); err != nil { + return err + } + + for _, assigneeID := range assigneeIDs { + comment, err := issue_service.AddAssigneeIfNotAssigned(ctx, issue, issue.Poster, assigneeID, false) + if err != nil { + return err + } + assigneeCommentMap[assigneeID] = comment + } + + pr.Issue = issue + issue.PullRequest = pr + + if pr.Flow == issues_model.PullRequestFlowGithub { + err = PushToBaseRepo(ctx, pr) + } else { + err = UpdateRef(ctx, pr) + } + if err != nil { + return err + } + + compareInfo, err := baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), + git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName(), false, false) + if err != nil { + return err + } + if len(compareInfo.Commits) == 0 { + return nil + } + + data := issues_model.PushActionContent{IsForcePush: false} + data.CommitIDs = make([]string, 0, len(compareInfo.Commits)) + for i := len(compareInfo.Commits) - 1; i >= 0; i-- { + data.CommitIDs = append(data.CommitIDs, compareInfo.Commits[i].ID.String()) + } + + dataJSON, err := json.Marshal(data) + if err != nil { + return err + } + + ops := &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypePullRequestPush, + Doer: issue.Poster, + Repo: repo, + Issue: pr.Issue, + IsForcePush: false, + Content: string(dataJSON), + } + + if _, err = issues_model.CreateComment(ctx, ops); err != nil { + return err + } + + if !pr.IsWorkInProgress(ctx) { + reviewNotifers, err = issue_service.PullRequestCodeOwnersReview(ctx, issue, pr) + if err != nil { + return err + } + } + return nil + }); err != nil { + // cleanup: this will only remove the reference, the real commit will be clean up when next GC + if err1 := baseGitRepo.RemoveReference(pr.GetGitRefName()); err1 != nil { + log.Error("RemoveReference: %v", err1) + } + return err + } + baseGitRepo.Close() // close immediately to avoid notifications will open the repository again + + issue_service.ReviewRequestNotify(ctx, issue, issue.Poster, reviewNotifers) + + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, issue.Poster, issue.Content) + if err != nil { + return err + } + notify_service.NewPullRequest(ctx, pr, mentions) + if len(issue.Labels) > 0 { + notify_service.IssueChangeLabels(ctx, issue.Poster, issue, issue.Labels, nil) + } + if issue.Milestone != nil { + notify_service.IssueChangeMilestone(ctx, issue.Poster, issue, 0) + } + for _, assigneeID := range assigneeIDs { + assignee, err := user_model.GetUserByID(ctx, assigneeID) + if err != nil { + return ErrDependenciesLeft + } + notify_service.IssueChangeAssignee(ctx, issue.Poster, issue, assignee, false, assigneeCommentMap[assigneeID]) + } + + return nil +} + +// ChangeTargetBranch changes the target branch of this pull request, as the given user. +func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, targetBranch string) (err error) { + pullWorkingPool.CheckIn(fmt.Sprint(pr.ID)) + defer pullWorkingPool.CheckOut(fmt.Sprint(pr.ID)) + + // Current target branch is already the same + if pr.BaseBranch == targetBranch { + return nil + } + + if pr.Issue.IsClosed { + return issues_model.ErrIssueIsClosed{ + ID: pr.Issue.ID, + RepoID: pr.Issue.RepoID, + Index: pr.Issue.Index, + } + } + + if pr.HasMerged { + return models.ErrPullRequestHasMerged{ + ID: pr.ID, + IssueID: pr.Index, + HeadRepoID: pr.HeadRepoID, + BaseRepoID: pr.BaseRepoID, + HeadBranch: pr.HeadBranch, + BaseBranch: pr.BaseBranch, + } + } + + // Check if branches are equal + branchesEqual, err := IsHeadEqualWithBranch(ctx, pr, targetBranch) + if err != nil { + return err + } + if branchesEqual { + return git_model.ErrBranchesEqual{ + HeadBranchName: pr.HeadBranch, + BaseBranchName: targetBranch, + } + } + + // Check if pull request for the new target branch already exists + existingPr, err := issues_model.GetUnmergedPullRequest(ctx, pr.HeadRepoID, pr.BaseRepoID, pr.HeadBranch, targetBranch, issues_model.PullRequestFlowGithub) + if existingPr != nil { + return issues_model.ErrPullRequestAlreadyExists{ + ID: existingPr.ID, + IssueID: existingPr.Index, + HeadRepoID: existingPr.HeadRepoID, + BaseRepoID: existingPr.BaseRepoID, + HeadBranch: existingPr.HeadBranch, + BaseBranch: existingPr.BaseBranch, + } + } + if err != nil && !issues_model.IsErrPullRequestNotExist(err) { + return err + } + + // Set new target branch + oldBranch := pr.BaseBranch + pr.BaseBranch = targetBranch + + // Refresh patch + if err := TestPatch(pr); err != nil { + return err + } + + // Update target branch, PR diff and status + // This is the same as checkAndUpdateStatus in check service, but also updates base_branch + if pr.Status == issues_model.PullRequestStatusChecking { + pr.Status = issues_model.PullRequestStatusMergeable + } + + // Update Commit Divergence + divergence, err := GetDiverging(ctx, pr) + if err != nil { + return err + } + pr.CommitsAhead = divergence.Ahead + pr.CommitsBehind = divergence.Behind + + if err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files", "base_branch", "commits_ahead", "commits_behind"); err != nil { + return err + } + + // Create comment + options := &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeChangeTargetBranch, + Doer: doer, + Repo: pr.Issue.Repo, + Issue: pr.Issue, + OldRef: oldBranch, + NewRef: targetBranch, + } + if _, err = issues_model.CreateComment(ctx, options); err != nil { + return fmt.Errorf("CreateChangeTargetBranchComment: %w", err) + } + + return nil +} + +func checkForInvalidation(ctx context.Context, requests issues_model.PullRequestList, repoID int64, doer *user_model.User, branch string) error { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return fmt.Errorf("GetRepositoryByIDCtx: %w", err) + } + gitRepo, err := gitrepo.OpenRepository(ctx, repo) + if err != nil { + return fmt.Errorf("gitrepo.OpenRepository: %w", err) + } + go func() { + // FIXME: graceful: We need to tell the manager we're doing something... + err := InvalidateCodeComments(ctx, requests, doer, gitRepo, branch) + if err != nil { + log.Error("PullRequestList.InvalidateCodeComments: %v", err) + } + gitRepo.Close() + }() + return nil +} + +// AddTestPullRequestTask adds new test tasks by given head/base repository and head/base branch, +// and generate new patch for testing as needed. +func AddTestPullRequestTask(ctx context.Context, doer *user_model.User, repoID int64, branch string, isSync bool, oldCommitID, newCommitID string, timeNano int64) { + description := fmt.Sprintf("AddTestPullRequestTask [head_repo_id: %d, head_branch: %s]: only pull requests created before nano time %d will be considered", repoID, branch, timeNano) + log.Trace(description) + go graceful.GetManager().RunWithShutdownContext(func(shutdownCtx context.Context) { + // make it a process to allow for cancellation (especially during integration tests where no global shutdown happens) + ctx, _, finished := process.GetManager().AddContext(shutdownCtx, description) + defer finished() + // There is no sensible way to shut this down ":-(" + // If you don't let it run all the way then you will lose data + // TODO: graceful: TestPullRequest needs to become a queue! + + TestPullRequest(ctx, doer, repoID, timeNano, branch, isSync, oldCommitID, newCommitID) + }) +} + +func TestPullRequest(ctx context.Context, doer *user_model.User, repoID, olderThan int64, branch string, isSync bool, oldCommitID, newCommitID string) { + // Only consider PR that are older than olderThan, which is the time at + // which the newCommitID was added to repoID. + // + // * commit C is pushed + // * the git hook queues AddTestPullRequestTask for processing and returns with success + // * TestPullRequest is not called yet + // * a pull request P with commit C as the head is created + // * TestPullRequest runs and ignores P because it was created after the commit was received + // + // In other words, a PR must not be updated based on events that happened before it existed + prs, err := issues_model.GetUnmergedPullRequestsByHeadInfoMax(ctx, repoID, olderThan, branch) + if err != nil { + log.Error("Find pull requests [head_repo_id: %d, head_branch: %s]: %v", repoID, branch, err) + return + } + + for _, pr := range prs { + log.Trace("Updating PR[id=%d,index=%d]: composing new test task", pr.ID, pr.Index) + if pr.Flow == issues_model.PullRequestFlowGithub { + if err := PushToBaseRepo(ctx, pr); err != nil { + log.Error("PushToBaseRepo: %v", err) + continue + } + } else { + continue + } + + AddToTaskQueue(ctx, pr) + comment, err := CreatePushPullComment(ctx, doer, pr, oldCommitID, newCommitID) + if err == nil && comment != nil { + notify_service.PullRequestPushCommits(ctx, doer, pr, comment) + } + } + + if isSync { + requests := issues_model.PullRequestList(prs) + if err = requests.LoadAttributes(ctx); err != nil { + log.Error("PullRequestList.LoadAttributes: %v", err) + } + if invalidationErr := checkForInvalidation(ctx, requests, repoID, doer, branch); invalidationErr != nil { + log.Error("checkForInvalidation: %v", invalidationErr) + } + if err == nil { + for _, pr := range prs { + ValidatePullRequest(ctx, pr, newCommitID, oldCommitID, doer) + notify_service.PullRequestSynchronized(ctx, doer, pr) + } + } + } + + log.Trace("TestPullRequest [base_repo_id: %d, base_branch: %s]: finding pull requests", repoID, branch) + prs, err = issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch) + if err != nil { + log.Error("Find pull requests [base_repo_id: %d, base_branch: %s]: %v", repoID, branch, err) + return + } + for _, pr := range prs { + divergence, err := GetDiverging(ctx, pr) + if err != nil { + if git_model.IsErrBranchNotExist(err) && !git.IsBranchExist(ctx, pr.HeadRepo.RepoPath(), pr.HeadBranch) { + log.Warn("Cannot test PR %s/%d: head_branch %s no longer exists", pr.BaseRepo.Name, pr.IssueID, pr.HeadBranch) + } else { + log.Error("GetDiverging: %v", err) + } + } else { + err = pr.UpdateCommitDivergence(ctx, divergence.Ahead, divergence.Behind) + if err != nil { + log.Error("UpdateCommitDivergence: %v", err) + } + } + AddToTaskQueue(ctx, pr) + } +} + +// Mark old reviews as stale if diff to mergebase has changed. +// Dismiss all approval reviews if protected branch rule item enabled. +// Update commit divergence. +func ValidatePullRequest(ctx context.Context, pr *issues_model.PullRequest, newCommitID, oldCommitID string, doer *user_model.User) { + objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName) + if newCommitID != "" && newCommitID != objectFormat.EmptyObjectID().String() { + changed, err := checkIfPRContentChanged(ctx, pr, oldCommitID, newCommitID) + if err != nil { + log.Error("checkIfPRContentChanged: %v", err) + } + if changed { + if err := issues_model.MarkReviewsAsStale(ctx, pr.IssueID); err != nil { + log.Error("MarkReviewsAsStale: %v", err) + } + + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch) + if err != nil { + log.Error("GetFirstMatchProtectedBranchRule: %v", err) + } + if pb != nil && pb.DismissStaleApprovals { + if err := DismissApprovalReviews(ctx, doer, pr); err != nil { + log.Error("DismissApprovalReviews: %v", err) + } + } + } + if err := issues_model.MarkReviewsAsNotStale(ctx, pr.IssueID, newCommitID); err != nil { + log.Error("MarkReviewsAsNotStale: %v", err) + } + divergence, err := GetDiverging(ctx, pr) + if err != nil { + log.Error("GetDiverging: %v", err) + } else { + err = pr.UpdateCommitDivergence(ctx, divergence.Ahead, divergence.Behind) + if err != nil { + log.Error("UpdateCommitDivergence: %v", err) + } + } + } +} + +// checkIfPRContentChanged checks if diff to target branch has changed by push +// A commit can be considered to leave the PR untouched if the patch/diff with its merge base is unchanged +func checkIfPRContentChanged(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string) (hasChanged bool, err error) { + prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) + if err != nil { + log.Error("CreateTemporaryRepoForPR %-v: %v", pr, err) + return false, err + } + defer cancel() + + tmpRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath) + if err != nil { + return false, fmt.Errorf("OpenRepository: %w", err) + } + defer tmpRepo.Close() + + // Find the merge-base + _, base, err := tmpRepo.GetMergeBase("", "base", "tracking") + if err != nil { + return false, fmt.Errorf("GetMergeBase: %w", err) + } + + cmd := git.NewCommand(ctx, "diff", "--name-only", "-z").AddDynamicArguments(newCommitID, oldCommitID, base) + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return false, fmt.Errorf("unable to open pipe for to run diff: %w", err) + } + + stderr := new(bytes.Buffer) + if err := cmd.Run(&git.RunOpts{ + Dir: prCtx.tmpBasePath, + Stdout: stdoutWriter, + Stderr: stderr, + PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error { + _ = stdoutWriter.Close() + defer func() { + _ = stdoutReader.Close() + }() + return util.IsEmptyReader(stdoutReader) + }, + }); err != nil { + if err == util.ErrNotEmpty { + return true, nil + } + err = git.ConcatenateError(err, stderr.String()) + + log.Error("Unable to run diff on %s %s %s in tempRepo for PR[%d]%s/%s...%s/%s: Error: %v", + newCommitID, oldCommitID, base, + pr.ID, pr.BaseRepo.FullName(), pr.BaseBranch, pr.HeadRepo.FullName(), pr.HeadBranch, + err) + + return false, fmt.Errorf("Unable to run git diff --name-only -z %s %s %s: %w", newCommitID, oldCommitID, base, err) + } + + return false, nil +} + +// PushToBaseRepo pushes commits from branches of head repository to +// corresponding branches of base repository. +// FIXME: Only push branches that are actually updates? +func PushToBaseRepo(ctx context.Context, pr *issues_model.PullRequest) (err error) { + return pushToBaseRepoHelper(ctx, pr, "") +} + +func pushToBaseRepoHelper(ctx context.Context, pr *issues_model.PullRequest, prefixHeadBranch string) (err error) { + log.Trace("PushToBaseRepo[%d]: pushing commits to base repo '%s'", pr.BaseRepoID, pr.GetGitRefName()) + + if err := pr.LoadHeadRepo(ctx); err != nil { + log.Error("Unable to load head repository for PR[%d] Error: %v", pr.ID, err) + return err + } + headRepoPath := pr.HeadRepo.RepoPath() + + if err := pr.LoadBaseRepo(ctx); err != nil { + log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err) + return err + } + baseRepoPath := pr.BaseRepo.RepoPath() + + if err = pr.LoadIssue(ctx); err != nil { + return fmt.Errorf("unable to load issue %d for pr %d: %w", pr.IssueID, pr.ID, err) + } + if err = pr.Issue.LoadPoster(ctx); err != nil { + return fmt.Errorf("unable to load poster %d for pr %d: %w", pr.Issue.PosterID, pr.ID, err) + } + + gitRefName := pr.GetGitRefName() + + if err := git.Push(ctx, headRepoPath, git.PushOptions{ + Remote: baseRepoPath, + Branch: prefixHeadBranch + pr.HeadBranch + ":" + gitRefName, + Force: true, + // Use InternalPushingEnvironment here because we know that pre-receive and post-receive do not run on a refs/pulls/... + Env: repo_module.InternalPushingEnvironment(pr.Issue.Poster, pr.BaseRepo), + }); err != nil { + if git.IsErrPushOutOfDate(err) { + // This should not happen as we're using force! + log.Error("Unable to push PR head for %s#%d (%-v:%s) due to ErrPushOfDate: %v", pr.BaseRepo.FullName(), pr.Index, pr.BaseRepo, gitRefName, err) + return err + } else if git.IsErrPushRejected(err) { + rejectErr := err.(*git.ErrPushRejected) + log.Info("Unable to push PR head for %s#%d (%-v:%s) due to rejection:\nStdout: %s\nStderr: %s\nError: %v", pr.BaseRepo.FullName(), pr.Index, pr.BaseRepo, gitRefName, rejectErr.StdOut, rejectErr.StdErr, rejectErr.Err) + return err + } else if git.IsErrMoreThanOne(err) { + if prefixHeadBranch != "" { + log.Info("Can't push with %s%s", prefixHeadBranch, pr.HeadBranch) + return err + } + log.Info("Retrying to push with %s%s", git.BranchPrefix, pr.HeadBranch) + err = pushToBaseRepoHelper(ctx, pr, git.BranchPrefix) + return err + } + log.Error("Unable to push PR head for %s#%d (%-v:%s) due to Error: %v", pr.BaseRepo.FullName(), pr.Index, pr.BaseRepo, gitRefName, err) + return fmt.Errorf("Push: %s:%s %s:%s %w", pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseRepo.FullName(), gitRefName, err) + } + + return nil +} + +// UpdateRef update refs/pull/id/head directly for agit flow pull request +func UpdateRef(ctx context.Context, pr *issues_model.PullRequest) (err error) { + log.Trace("UpdateRef[%d]: upgate pull request ref in base repo '%s'", pr.ID, pr.GetGitRefName()) + if err := pr.LoadBaseRepo(ctx); err != nil { + log.Error("Unable to load base repository for PR[%d] Error: %v", pr.ID, err) + return err + } + + _, _, err = git.NewCommand(ctx, "update-ref").AddDynamicArguments(pr.GetGitRefName(), pr.HeadCommitID).RunStdString(&git.RunOpts{Dir: pr.BaseRepo.RepoPath()}) + if err != nil { + log.Error("Unable to update ref in base repository for PR[%d] Error: %v", pr.ID, err) + } + + return err +} + +type errlist []error + +func (errs errlist) Error() string { + if len(errs) > 0 { + var buf strings.Builder + for i, err := range errs { + if i > 0 { + buf.WriteString(", ") + } + buf.WriteString(err.Error()) + } + return buf.String() + } + return "" +} + +// RetargetChildrenOnMerge retarget children pull requests on merge if possible +func RetargetChildrenOnMerge(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) error { + if setting.Repository.PullRequest.RetargetChildrenOnMerge && pr.BaseRepoID == pr.HeadRepoID { + return RetargetBranchPulls(ctx, doer, pr.HeadRepoID, pr.HeadBranch, pr.BaseBranch) + } + return nil +} + +// RetargetBranchPulls change target branch for all pull requests whose base branch is the branch +// Both branch and targetBranch must be in the same repo (for security reasons) +func RetargetBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch, targetBranch string) error { + prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch) + if err != nil { + return err + } + + if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + return err + } + + var errs errlist + for _, pr := range prs { + if err = pr.Issue.LoadRepo(ctx); err != nil { + errs = append(errs, err) + } else if err = ChangeTargetBranch(ctx, pr, doer, targetBranch); err != nil && + !issues_model.IsErrIssueIsClosed(err) && !models.IsErrPullRequestHasMerged(err) && + !issues_model.IsErrPullRequestAlreadyExists(err) { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errs + } + return nil +} + +// CloseBranchPulls close all the pull requests who's head branch is the branch +func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, branch string) error { + prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repoID, branch) + if err != nil { + return err + } + + prs2, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repoID, branch) + if err != nil { + return err + } + + prs = append(prs, prs2...) + if err := issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + return err + } + + var errs errlist + for _, pr := range prs { + if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errs + } + return nil +} + +// CloseRepoBranchesPulls close all pull requests which head branches are in the given repository, but only whose base repo is not in the given repository +func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *repo_model.Repository) error { + branches, _, err := gitrepo.GetBranchesByPath(ctx, repo, 0, 0) + if err != nil { + return err + } + + var errs errlist + for _, branch := range branches { + prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(ctx, repo.ID, branch.Name) + if err != nil { + return err + } + + if err = issues_model.PullRequestList(prs).LoadAttributes(ctx); err != nil { + return err + } + + for _, pr := range prs { + // If the base repository for this pr is this repository there is no need to close it + // as it is going to be deleted anyway + if pr.BaseRepoID == repo.ID { + continue + } + if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) { + errs = append(errs, err) + } + } + } + + if len(errs) > 0 { + return errs + } + return nil +} + +var commitMessageTrailersPattern = regexp.MustCompile(`(?:^|\n\n)(?:[\w-]+[ \t]*:[^\n]+\n*(?:[ \t]+[^\n]+\n*)*)+$`) + +// GetSquashMergeCommitMessages returns the commit messages between head and merge base (if there is one) +func GetSquashMergeCommitMessages(ctx context.Context, pr *issues_model.PullRequest) string { + if err := pr.LoadIssue(ctx); err != nil { + log.Error("Cannot load issue %d for PR id %d: Error: %v", pr.IssueID, pr.ID, err) + return "" + } + + if err := pr.Issue.LoadPoster(ctx); err != nil { + log.Error("Cannot load poster %d for pr id %d, index %d Error: %v", pr.Issue.PosterID, pr.ID, pr.Index, err) + return "" + } + + if pr.HeadRepo == nil { + var err error + pr.HeadRepo, err = repo_model.GetRepositoryByID(ctx, pr.HeadRepoID) + if err != nil { + log.Error("GetRepositoryByIdCtx[%d]: %v", pr.HeadRepoID, err) + return "" + } + } + + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) + if err != nil { + log.Error("Unable to open head repository: Error: %v", err) + return "" + } + defer closer.Close() + + var headCommit *git.Commit + if pr.Flow == issues_model.PullRequestFlowGithub { + headCommit, err = gitRepo.GetBranchCommit(pr.HeadBranch) + } else { + pr.HeadCommitID, err = gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + log.Error("Unable to get head commit: %s Error: %v", pr.GetGitRefName(), err) + return "" + } + headCommit, err = gitRepo.GetCommit(pr.HeadCommitID) + } + if err != nil { + log.Error("Unable to get head commit: %s Error: %v", pr.HeadBranch, err) + return "" + } + + mergeBase, err := gitRepo.GetCommit(pr.MergeBase) + if err != nil { + log.Error("Unable to get merge base commit: %s Error: %v", pr.MergeBase, err) + return "" + } + + limit := setting.Repository.PullRequest.DefaultMergeMessageCommitsLimit + + commits, err := gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, 0) + if err != nil { + log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err) + return "" + } + + posterSig := pr.Issue.Poster.NewGitSig().String() + + uniqueAuthors := make(container.Set[string]) + authors := make([]string, 0, len(commits)) + stringBuilder := strings.Builder{} + + if !setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages { + message := strings.TrimSpace(pr.Issue.Content) + stringBuilder.WriteString(message) + if stringBuilder.Len() > 0 { + stringBuilder.WriteRune('\n') + if !commitMessageTrailersPattern.MatchString(message) { + stringBuilder.WriteRune('\n') + } + } + } + + // commits list is in reverse chronological order + first := true + for i := len(commits) - 1; i >= 0; i-- { + commit := commits[i] + + if setting.Repository.PullRequest.PopulateSquashCommentWithCommitMessages { + maxSize := setting.Repository.PullRequest.DefaultMergeMessageSize + if maxSize < 0 || stringBuilder.Len() < maxSize { + var toWrite []byte + if first { + first = false + toWrite = []byte(strings.TrimPrefix(commit.CommitMessage, pr.Issue.Title)) + } else { + toWrite = []byte(commit.CommitMessage) + } + + if len(toWrite) > maxSize-stringBuilder.Len() && maxSize > -1 { + toWrite = append(toWrite[:maxSize-stringBuilder.Len()], "..."...) + } + if _, err := stringBuilder.Write(toWrite); err != nil { + log.Error("Unable to write commit message Error: %v", err) + return "" + } + + if _, err := stringBuilder.WriteRune('\n'); err != nil { + log.Error("Unable to write commit message Error: %v", err) + return "" + } + } + } + + authorString := commit.Author.String() + if uniqueAuthors.Add(authorString) && authorString != posterSig { + // Compare use account as well to avoid adding the same author multiple times + // times when email addresses are private or multiple emails are used. + commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email) + if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID { + authors = append(authors, authorString) + } + } + } + + // Consider collecting the remaining authors + if limit >= 0 && setting.Repository.PullRequest.DefaultMergeMessageAllAuthors { + skip := limit + limit = 30 + for { + commits, err := gitRepo.CommitsBetweenLimit(headCommit, mergeBase, limit, skip) + if err != nil { + log.Error("Unable to get commits between: %s %s Error: %v", pr.HeadBranch, pr.MergeBase, err) + return "" + } + if len(commits) == 0 { + break + } + for _, commit := range commits { + authorString := commit.Author.String() + if uniqueAuthors.Add(authorString) && authorString != posterSig { + commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email) + if commitUser == nil || commitUser.ID != pr.Issue.Poster.ID { + authors = append(authors, authorString) + } + } + } + skip += limit + } + } + + for _, author := range authors { + if _, err := stringBuilder.WriteString("Co-authored-by: "); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + if _, err := stringBuilder.WriteString(author); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + if _, err := stringBuilder.WriteRune('\n'); err != nil { + log.Error("Unable to write to string builder Error: %v", err) + return "" + } + } + + return stringBuilder.String() +} + +// GetIssuesLastCommitStatus returns a map of issue ID to the most recent commit's latest status +func GetIssuesLastCommitStatus(ctx context.Context, issues issues_model.IssueList) (map[int64]*git_model.CommitStatus, error) { + _, lastStatus, err := GetIssuesAllCommitStatus(ctx, issues) + return lastStatus, err +} + +// GetIssuesAllCommitStatus returns a map of issue ID to a list of all statuses for the most recent commit as well as a map of issue ID to only the commit's latest status +func GetIssuesAllCommitStatus(ctx context.Context, issues issues_model.IssueList) (map[int64][]*git_model.CommitStatus, map[int64]*git_model.CommitStatus, error) { + if err := issues.LoadPullRequests(ctx); err != nil { + return nil, nil, err + } + if _, err := issues.LoadRepositories(ctx); err != nil { + return nil, nil, err + } + + var ( + gitRepos = make(map[int64]*git.Repository) + res = make(map[int64][]*git_model.CommitStatus) + lastRes = make(map[int64]*git_model.CommitStatus) + err error + ) + defer func() { + for _, gitRepo := range gitRepos { + gitRepo.Close() + } + }() + + for _, issue := range issues { + if !issue.IsPull { + continue + } + gitRepo, ok := gitRepos[issue.RepoID] + if !ok { + gitRepo, err = gitrepo.OpenRepository(ctx, issue.Repo) + if err != nil { + log.Error("Cannot open git repository %-v for issue #%d[%d]. Error: %v", issue.Repo, issue.Index, issue.ID, err) + continue + } + gitRepos[issue.RepoID] = gitRepo + } + + statuses, lastStatus, err := getAllCommitStatus(ctx, gitRepo, issue.PullRequest) + if err != nil { + log.Error("getAllCommitStatus: can't get commit statuses of pull [%d]: %v", issue.PullRequest.ID, err) + continue + } + res[issue.PullRequest.ID] = statuses + lastRes[issue.PullRequest.ID] = lastStatus + } + return res, lastRes, nil +} + +// getAllCommitStatus get pr's commit statuses. +func getAllCommitStatus(ctx context.Context, gitRepo *git.Repository, pr *issues_model.PullRequest) (statuses []*git_model.CommitStatus, lastStatus *git_model.CommitStatus, err error) { + sha, shaErr := gitRepo.GetRefCommitID(pr.GetGitRefName()) + if shaErr != nil { + return nil, nil, shaErr + } + + statuses, _, err = git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll) + lastStatus = git_model.CalcCommitStatus(statuses) + return statuses, lastStatus, err +} + +// IsHeadEqualWithBranch returns if the commits of branchName are available in pull request head +func IsHeadEqualWithBranch(ctx context.Context, pr *issues_model.PullRequest, branchName string) (bool, error) { + var err error + if err = pr.LoadBaseRepo(ctx); err != nil { + return false, err + } + baseGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) + if err != nil { + return false, err + } + defer closer.Close() + + baseCommit, err := baseGitRepo.GetBranchCommit(branchName) + if err != nil { + return false, err + } + + if err = pr.LoadHeadRepo(ctx); err != nil { + return false, err + } + var headGitRepo *git.Repository + if pr.HeadRepoID == pr.BaseRepoID { + headGitRepo = baseGitRepo + } else { + var closer io.Closer + + headGitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo) + if err != nil { + return false, err + } + defer closer.Close() + } + + var headCommit *git.Commit + if pr.Flow == issues_model.PullRequestFlowGithub { + headCommit, err = headGitRepo.GetBranchCommit(pr.HeadBranch) + if err != nil { + return false, err + } + } else { + pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + return false, err + } + if headCommit, err = baseGitRepo.GetCommit(pr.HeadCommitID); err != nil { + return false, err + } + } + return baseCommit.HasPreviousCommit(headCommit.ID) +} + +type CommitInfo struct { + Summary string `json:"summary"` + CommitterOrAuthorName string `json:"committer_or_author_name"` + ID string `json:"id"` + ShortSha string `json:"short_sha"` + Time string `json:"time"` +} + +// GetPullCommits returns all commits on given pull request and the last review commit sha +// Attention: The last review commit sha must be from the latest review whose commit id is not empty. +// So the type of the latest review cannot be "ReviewTypeRequest". +func GetPullCommits(ctx *gitea_context.Context, issue *issues_model.Issue) ([]CommitInfo, string, error) { + pull := issue.PullRequest + + baseGitRepo := ctx.Repo.GitRepo + + if err := pull.LoadBaseRepo(ctx); err != nil { + return nil, "", err + } + baseBranch := pull.BaseBranch + if pull.HasMerged { + baseBranch = pull.MergeBase + } + prInfo, err := baseGitRepo.GetCompareInfo(pull.BaseRepo.RepoPath(), baseBranch, pull.GetGitRefName(), true, false) + if err != nil { + return nil, "", err + } + + commits := make([]CommitInfo, 0, len(prInfo.Commits)) + + for _, commit := range prInfo.Commits { + var committerOrAuthorName string + var commitTime time.Time + if commit.Author != nil { + committerOrAuthorName = commit.Author.Name + commitTime = commit.Author.When + } else { + committerOrAuthorName = commit.Committer.Name + commitTime = commit.Committer.When + } + + commits = append(commits, CommitInfo{ + Summary: commit.Summary(), + CommitterOrAuthorName: committerOrAuthorName, + ID: commit.ID.String(), + ShortSha: base.ShortSha(commit.ID.String()), + Time: commitTime.Format(time.RFC3339), + }) + } + + var lastReviewCommitID string + if ctx.IsSigned { + // get last review of current user and store information in context (if available) + lastreview, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{ + IssueID: issue.ID, + ReviewerID: ctx.Doer.ID, + Types: []issues_model.ReviewType{ + issues_model.ReviewTypeApprove, + issues_model.ReviewTypeComment, + issues_model.ReviewTypeReject, + }, + }) + + if err != nil && !issues_model.IsErrReviewNotExist(err) { + return nil, "", err + } + if len(lastreview) > 0 { + lastReviewCommitID = lastreview[0].CommitID + } + } + + return commits, lastReviewCommitID, nil +} -- cgit v1.2.3