初始提交: Gitea 项目代码
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
"gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
"gitea.dev/services/automergequeue"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// prPatchCheckerQueue represents a queue to handle update pull request tests
|
||||
var prPatchCheckerQueue *queue.WorkerPoolQueue[string]
|
||||
|
||||
var (
|
||||
ErrIsClosed = errors.New("pull is closed")
|
||||
ErrNoPermissionToMerge = errors.New("no permission to merge")
|
||||
ErrNotReadyToMerge = errors.New("not ready to merge")
|
||||
ErrHasMerged = errors.New("has already been merged")
|
||||
ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged")
|
||||
ErrIsChecking = errors.New("cannot merge while conflict checking is in progress")
|
||||
ErrNotMergeableState = errors.New("not in mergeable state")
|
||||
ErrDependenciesLeft = errors.New("is blocked by an open dependency")
|
||||
ErrHeadCommitsNotAllVerified = errors.New("the branch requires signed commits but not all head commits are verified")
|
||||
)
|
||||
|
||||
func markPullRequestStatusAsChecking(ctx context.Context, pr *issues_model.PullRequest) bool {
|
||||
pr.Status = issues_model.PullRequestStatusChecking
|
||||
_, err := pr.UpdateColsIfNotMerged(ctx, "status")
|
||||
if err != nil {
|
||||
log.Error("UpdateColsIfNotMerged failed, pr: %-v, err: %v", pr, err)
|
||||
return false
|
||||
}
|
||||
pr, err = issues_model.GetPullRequestByID(ctx, pr.ID)
|
||||
if err != nil {
|
||||
log.Error("GetPullRequestByID failed, pr: %-v, err: %v", pr, err)
|
||||
return false
|
||||
}
|
||||
return pr.Status == issues_model.PullRequestStatusChecking
|
||||
}
|
||||
|
||||
var AddPullRequestToCheckQueue = realAddPullRequestToCheckQueue
|
||||
|
||||
func realAddPullRequestToCheckQueue(prID int64) {
|
||||
err := prPatchCheckerQueue.Push(strconv.FormatInt(prID, 10))
|
||||
if err != nil && !errors.Is(err, queue.ErrAlreadyInQueue) {
|
||||
log.Error("Error adding %v to the pull requests check queue: %v", prID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func StartPullRequestCheckImmediately(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
if !markPullRequestStatusAsChecking(ctx, pr) {
|
||||
return
|
||||
}
|
||||
AddPullRequestToCheckQueue(pr.ID)
|
||||
}
|
||||
|
||||
// StartPullRequestCheckDelayable will delay the check if the pull request was not updated recently.
|
||||
// When the "base" branch gets updated, all PRs targeting that "base" branch need to re-check whether
|
||||
// they are mergeable.
|
||||
// When there are too many stale PRs, each "base" branch update will consume a lot of system resources.
|
||||
// So we can delay the checks for PRs that were not updated recently, only mark their status as
|
||||
// "checking", and then next time when these PRs are updated or viewed, the real checks will run.
|
||||
func StartPullRequestCheckDelayable(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
if !markPullRequestStatusAsChecking(ctx, pr) {
|
||||
return
|
||||
}
|
||||
|
||||
if setting.Repository.PullRequest.DelayCheckForInactiveDays >= 0 {
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
duration := 24 * time.Hour * time.Duration(setting.Repository.PullRequest.DelayCheckForInactiveDays)
|
||||
if pr.Issue.UpdatedUnix.AddDuration(duration) <= timeutil.TimeStampNow() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
AddPullRequestToCheckQueue(pr.ID)
|
||||
}
|
||||
|
||||
func StartPullRequestCheckOnView(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
// TODO: its correctness totally depends on the "unique queue" feature and the global lock.
|
||||
// So duplicate "start" requests will be ignored if there is already a task in the queue or one is running.
|
||||
// Ideally in the future we should decouple the "unique queue" feature from the "start" request.
|
||||
if pr.Status == issues_model.PullRequestStatusChecking {
|
||||
if setting.IsInTesting {
|
||||
// In testing mode, there might be an "immediate" queue, which is not a real queue, everything is executed in the same goroutine
|
||||
// So we can't use the global lock here, otherwise it will cause a deadlock.
|
||||
AddPullRequestToCheckQueue(pr.ID)
|
||||
} else {
|
||||
// When a PR check starts, the task is popped from the queue and the task handler acquires the global lock
|
||||
// So we need to acquire the global lock here to prevent from duplicate tasks
|
||||
_, _ = globallock.TryLockAndDo(ctx, getPullWorkingLockKey(pr.ID), func(ctx context.Context) error {
|
||||
AddPullRequestToCheckQueue(pr.ID) // the queue is a unique queue and won't add the same task again
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MergeCheckType int
|
||||
|
||||
const (
|
||||
MergeCheckTypeGeneral MergeCheckType = iota // general merge checks for "merge", "rebase", "squash", etc
|
||||
MergeCheckTypeManually // Manually Merged button (mark a PR as merged manually)
|
||||
MergeCheckTypeAuto // Auto Merge (Scheduled Merge) After Checks Succeed
|
||||
)
|
||||
|
||||
// CheckPullMergeable check if the pull mergeable based on all conditions (branch protection, merge options, ...)
|
||||
// mergeStyle tailors the "require signed commits" prechecks:
|
||||
// - fast-forward-only: no Gitea commit is produced, so Gitea's merge-signing check is skipped;
|
||||
// only the user's head commits are verified.
|
||||
// - merge: both the head commits must be verified and Gitea must sign the merge commit.
|
||||
// - rebase, rebase-merge, squash: Gitea rewrites the commits and signs each, so only Gitea's
|
||||
// signing ability is checked.
|
||||
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, forceMerge bool) error {
|
||||
return db.WithTx(stdCtx, func(ctx context.Context) error {
|
||||
if pr.HasMerged {
|
||||
return ErrHasMerged
|
||||
}
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("Unable to load issue[%d] for %-v: %v", pr.IssueID, pr, err)
|
||||
return err
|
||||
} else if pr.Issue.IsClosed {
|
||||
return ErrIsClosed
|
||||
}
|
||||
|
||||
if allowedMerge, err := IsUserAllowedToMerge(ctx, pr, *perm, doer); err != nil {
|
||||
log.Error("Error whilst checking if %-v is allowed to merge %-v: %v", doer, pr, err)
|
||||
return err
|
||||
} else if !allowedMerge {
|
||||
return ErrNoPermissionToMerge
|
||||
}
|
||||
|
||||
if mergeCheckType == MergeCheckTypeManually {
|
||||
// if doer is doing "manually merge" (mark as merged manually), do not check anything
|
||||
return nil
|
||||
}
|
||||
|
||||
if pr.IsWorkInProgress(ctx) {
|
||||
return ErrIsWorkInProgress
|
||||
}
|
||||
|
||||
if !pr.IsStatusMergeable() && !pr.IsEmpty() {
|
||||
return ErrNotMergeableState
|
||||
}
|
||||
|
||||
if pr.IsChecking() {
|
||||
return ErrIsChecking
|
||||
}
|
||||
|
||||
if errProtection := CheckPullBranchProtections(ctx, pr, false); errProtection != nil {
|
||||
if !errors.Is(errProtection, ErrNotReadyToMerge) {
|
||||
log.Error("Error whilst checking pull branch protection for %-v: %v", pr, errProtection)
|
||||
return errProtection
|
||||
}
|
||||
|
||||
// Now the branch protection check failed, check whether the failure could be skipped (skip by setting err = nil)
|
||||
|
||||
// * when doing Auto Merge (Scheduled Merge After Checks Succeed), skip the branch protection check
|
||||
if mergeCheckType == MergeCheckTypeAuto {
|
||||
errProtection = nil
|
||||
}
|
||||
|
||||
// * if the doer tries to "Force Merge", check whether it is really allowed
|
||||
if forceMerge {
|
||||
isRepoAdmin, errForceMerge := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer)
|
||||
if errForceMerge != nil {
|
||||
return fmt.Errorf("IsUserRepoAdmin failed, repo: %v, doer: %v, err: %w", pr.BaseRepoID, doer.ID, errForceMerge)
|
||||
}
|
||||
|
||||
protectedBranchRule, errForceMerge := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if errForceMerge != nil {
|
||||
return fmt.Errorf("GetFirstMatchProtectedBranchRule failed, repo: %v, base branch: %v, err: %w", pr.BaseRepoID, pr.BaseBranch, errForceMerge)
|
||||
}
|
||||
|
||||
canForceMerge := isRepoAdmin
|
||||
if protectedBranchRule != nil {
|
||||
canForceMerge = git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin)
|
||||
}
|
||||
if canForceMerge {
|
||||
errProtection = nil
|
||||
}
|
||||
}
|
||||
|
||||
// If there is still a branch protection check error, return it
|
||||
if errProtection != nil {
|
||||
return errProtection
|
||||
}
|
||||
}
|
||||
|
||||
if err := checkSigningRequirements(ctx, pr, doer, mergeStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if noDeps, err := issues_model.IssueNoDependenciesLeft(ctx, pr.Issue); err != nil {
|
||||
return err
|
||||
} else if !noDeps {
|
||||
return ErrDependenciesLeft
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// checkSigningRequirements enforces the target branch's RequireSignedCommits rule
|
||||
// against the selected merge style:
|
||||
// - fast-forward-only and merge keep the user's commits on the base branch, so
|
||||
// those commits must all be verified, or the pre-receive hook will reject the
|
||||
// push with a generic error.
|
||||
// - fast-forward-only creates no Gitea commit, so Gitea's signing key is not used.
|
||||
// - merge, rebase, rebase-merge and squash produce a Gitea-signed commit, so
|
||||
// Gitea must be configured to sign it.
|
||||
func checkSigningRequirements(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle) error {
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pb == nil || !pb.RequireSignedCommits {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if mergeStyle == repo_model.MergeStyleFastForwardOnly || mergeStyle == repo_model.MergeStyleMerge {
|
||||
verified, err := asymkey_service.AllHeadCommitsVerified(ctx, pr, gitRepo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !verified {
|
||||
return ErrHeadCommitsNotAllVerified
|
||||
}
|
||||
}
|
||||
|
||||
if mergeStyle != repo_model.MergeStyleFastForwardOnly {
|
||||
if _, _, _, err := asymkey_service.SignMerge(ctx, pr, doer, gitRepo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status,
|
||||
// and set to be either conflict or mergeable.
|
||||
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
// If the status has not been changed to conflict by the conflict checking functions then we are mergeable
|
||||
if pr.Status == issues_model.PullRequestStatusChecking {
|
||||
pr.Status = issues_model.PullRequestStatusMergeable
|
||||
}
|
||||
|
||||
// Make sure there is no waiting test to process before leaving the checking status.
|
||||
has, err := prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10))
|
||||
if err != nil {
|
||||
log.Error("Unable to check if the queue is waiting to reprocess %-v. Error: %v", pr, err)
|
||||
}
|
||||
|
||||
if has {
|
||||
log.Trace("Not updating status for %-v as it is due to be rechecked", pr)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := pr.UpdateColsIfNotMerged(ctx, "merge_base", "status", "conflicted_files", "changed_protected_files"); err != nil {
|
||||
log.Error("Update[%-v]: %v", pr, err)
|
||||
}
|
||||
|
||||
// if there is a scheduled merge for this pull request, start the auto merge check (again)
|
||||
exist, _, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
|
||||
if err != nil {
|
||||
log.Error("GetScheduledMergeByPullID[%-v]: %v", pr, err)
|
||||
return
|
||||
} else if !exist {
|
||||
return
|
||||
}
|
||||
automergequeue.StartPRCheckAndAutoMerge(ctx, pr)
|
||||
}
|
||||
|
||||
// getMergeCommit checks if a pull request has been merged
|
||||
// Returns the git.Commit of the pull request if merged
|
||||
func getMergeCommit(ctx context.Context, pr *issues_model.PullRequest) (*git.Commit, error) {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return nil, fmt.Errorf("unable to load base repo for %s: %w", pr, err)
|
||||
}
|
||||
|
||||
prHeadRef := pr.GetGitHeadRefName()
|
||||
|
||||
// Check if the pull request is merged into BaseBranch
|
||||
cmd := gitcmd.NewCommand("merge-base", "--is-ancestor").AddDynamicArguments(prHeadRef, pr.BaseBranch)
|
||||
if err := gitrepo.RunCmdWithStderr(ctx, pr.BaseRepo, cmd); err != nil {
|
||||
if gitcmd.IsErrorExitCode(err, 1) {
|
||||
// prHeadRef is not an ancestor of the base branch
|
||||
return nil, nil //nolint:nilnil // return nil to indicate that the PR head is not merged
|
||||
}
|
||||
// Errors are signaled by a non-zero status that is not 1
|
||||
return nil, fmt.Errorf("%-v git merge-base --is-ancestor: %w", pr, err)
|
||||
}
|
||||
|
||||
// If merge-base successfully exits then prHeadRef is an ancestor of pr.BaseBranch
|
||||
|
||||
// Find the head commit id
|
||||
prHeadCommitID, err := gitrepo.GetFullCommitID(ctx, pr.BaseRepo, prHeadRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetFullCommitID(%s) in %s: %w", prHeadRef, pr.BaseRepo.FullName(), err)
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%-v OpenRepository: %w", pr.BaseRepo, err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
||||
|
||||
// Get the commit from BaseBranch where the pull request got merged.
|
||||
// When several PRs targeting the same base are merged in a single push,
|
||||
// rev-list returns one line per merge commit on the ancestry path; we
|
||||
// only want the first one (the oldest, with --reverse, i.e. the merge
|
||||
// commit that actually introduced this PR).
|
||||
mergeCommit, _, err := gitrepo.RunCmdString(ctx, pr.BaseRepo,
|
||||
gitcmd.NewCommand("rev-list", "--ancestry-path", "--merges", "--reverse").
|
||||
AddDynamicArguments(prHeadCommitID+".."+pr.BaseBranch))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git rev-list --ancestry-path --merges --reverse: %w", err)
|
||||
}
|
||||
|
||||
// only use the latest commit as merge commit if the output contains multiple commits
|
||||
mergeCommit = strings.TrimSpace(mergeCommit)
|
||||
mergeCommit, _, _ = strings.Cut(mergeCommit, "\n")
|
||||
if len(mergeCommit) < objectFormat.FullLength() {
|
||||
// PR was maybe fast-forwarded, so just use last commit of PR
|
||||
mergeCommit = prHeadCommitID
|
||||
}
|
||||
commit, err := gitRepo.GetCommit(mergeCommit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetMergeCommit[%s]: %w", mergeCommit, err)
|
||||
}
|
||||
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func getMergerForManuallyMergedPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*user_model.User, error) {
|
||||
var errs []error
|
||||
if branch, err := git_model.GetBranch(ctx, pr.BaseRepoID, pr.BaseBranch); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
err := branch.LoadPusher(ctx) // LoadPusher uses ghost for non-existing user
|
||||
if branch.Pusher != nil && branch.Pusher.ID > 0 {
|
||||
return branch.Pusher, nil
|
||||
} else if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the doer (pusher) is unknown set the BaseRepo owner as merger
|
||||
err := pr.BaseRepo.LoadOwner(ctx)
|
||||
if err == nil {
|
||||
return pr.BaseRepo.Owner, nil
|
||||
}
|
||||
errs = append(errs, err)
|
||||
return nil, fmt.Errorf("unable to find merger for manually merged pull request: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// manuallyMerged checks if a pull request got manually merged
|
||||
// When a pull request got manually merged mark the pull request as merged
|
||||
func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("%-v LoadBaseRepo: %v", pr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if unit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests); err == nil {
|
||||
config := unit.PullRequestsConfig()
|
||||
if !config.AutodetectManualMerge {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
log.Error("%-v BaseRepo.GetUnit(unit.TypePullRequests): %v", pr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
commit, err := getMergeCommit(ctx, pr)
|
||||
if err != nil {
|
||||
log.Error("%-v getMergeCommit: %v", pr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if commit == nil {
|
||||
// no merge commit found
|
||||
return false
|
||||
}
|
||||
|
||||
merger, err := getMergerForManuallyMergedPullRequest(ctx, pr)
|
||||
if err != nil {
|
||||
log.Error("%-v getMergerForManuallyMergedPullRequest: %v", pr, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if merged, err := SetMerged(ctx, pr, commit.ID.String(), timeutil.TimeStamp(commit.Author.When.Unix()), merger, issues_model.PullRequestStatusManuallyMerged); err != nil {
|
||||
log.Error("%-v setMerged : %v", pr, err)
|
||||
return false
|
||||
} else if !merged {
|
||||
return false
|
||||
}
|
||||
|
||||
notify_service.MergePullRequest(ctx, merger, pr)
|
||||
|
||||
log.Info("manuallyMerged[%-v]: Marked as manually merged into %s/%s by commit id: %s", pr, pr.BaseRepo.Name, pr.BaseBranch, commit.ID.String())
|
||||
return true
|
||||
}
|
||||
|
||||
// InitializePullRequests checks and tests untested patches of pull requests.
|
||||
func InitializePullRequests(ctx context.Context) {
|
||||
// If we prefer to delay the checks, then no need to do any check during startup, there should be not much difference
|
||||
if setting.Repository.PullRequest.DelayCheckForInactiveDays >= 0 {
|
||||
return
|
||||
}
|
||||
prs, err := issues_model.GetPullRequestIDsByCheckStatus(ctx, issues_model.PullRequestStatusChecking)
|
||||
if err != nil {
|
||||
log.Error("Find Checking PRs: %v", err)
|
||||
return
|
||||
}
|
||||
for _, prID := range prs {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
AddPullRequestToCheckQueue(prID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkPullRequestMergeable(id int64) {
|
||||
ctx := graceful.GetManager().HammerContext()
|
||||
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(id))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Test PR[%d] from patch checking queue", id))
|
||||
defer finished()
|
||||
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, id)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetPullRequestByID[%d] for checkPullRequestMergeable: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Trace("Testing %-v", pr)
|
||||
defer func() {
|
||||
log.Trace("Done testing %-v (status: %s)", pr, pr.Status)
|
||||
}()
|
||||
|
||||
if pr.HasMerged {
|
||||
log.Trace("%-v is already merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
|
||||
return
|
||||
}
|
||||
|
||||
if manuallyMerged(ctx, pr) {
|
||||
log.Trace("%-v is manually merged (status: %s, merge commit: %s)", pr, pr.Status, pr.MergedCommitID)
|
||||
return
|
||||
}
|
||||
|
||||
if err := checkPullRequestBranchMergeable(ctx, pr); err != nil {
|
||||
log.Error("checkPullRequestBranchMergeable[%-v]: %v", pr, err)
|
||||
pr.Status = issues_model.PullRequestStatusError
|
||||
if err := pr.UpdateCols(ctx, "status"); err != nil {
|
||||
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
markPullRequestAsMergeable(ctx, pr)
|
||||
}
|
||||
|
||||
// CheckPRsForBaseBranch check all pulls with baseBrannch
|
||||
func CheckPRsForBaseBranch(ctx context.Context, baseRepo *repo_model.Repository, baseBranchName string) error {
|
||||
prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, baseRepo.ID, baseBranchName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pr := range prs {
|
||||
StartPullRequestCheckImmediately(ctx, pr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Init runs the task queue to test all the checking status pull requests
|
||||
func Init() error {
|
||||
prPatchCheckerQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_patch_checker", func(items ...string) []string {
|
||||
for _, s := range items {
|
||||
id, _ := strconv.ParseInt(s, 10, 64)
|
||||
checkPullRequestMergeable(id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if prPatchCheckerQueue == nil {
|
||||
return errors.New("unable to create pr_patch_checker queue")
|
||||
}
|
||||
|
||||
go graceful.GetManager().RunWithCancel(prPatchCheckerQueue)
|
||||
go graceful.GetManager().RunWithShutdownContext(InitializePullRequests)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright 2019 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/queue"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/services/automergequeue"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPullRequest_AddToTaskQueue(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
idChan := make(chan int64, 10)
|
||||
testHandler := func(items ...string) []string {
|
||||
for _, s := range items {
|
||||
id, _ := strconv.ParseInt(s, 10, 64)
|
||||
idChan <- id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := setting.GetQueueSettings(setting.CfgProvider, "pr_patch_checker")
|
||||
assert.NoError(t, err)
|
||||
prPatchCheckerQueue, err = queue.NewWorkerPoolQueueWithContext(t.Context(), "pr_patch_checker", cfg, testHandler, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
StartPullRequestCheckImmediately(t.Context(), pr)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
return pr.Status == issues_model.PullRequestStatusChecking
|
||||
}, 1*time.Second, 100*time.Millisecond)
|
||||
|
||||
has, err := prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10))
|
||||
assert.True(t, has)
|
||||
assert.NoError(t, err)
|
||||
|
||||
go prPatchCheckerQueue.Run()
|
||||
|
||||
select {
|
||||
case id := <-idChan:
|
||||
assert.Equal(t, pr.ID, id)
|
||||
case <-time.After(time.Second):
|
||||
assert.FailNow(t, "Timeout: nothing was added to pullRequestQueue")
|
||||
}
|
||||
|
||||
has, err = prPatchCheckerQueue.Has(strconv.FormatInt(pr.ID, 10))
|
||||
assert.False(t, has)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
assert.Equal(t, issues_model.PullRequestStatusChecking, pr.Status)
|
||||
|
||||
prPatchCheckerQueue.ShutdownWait(time.Second)
|
||||
prPatchCheckerQueue = nil
|
||||
}
|
||||
|
||||
func TestCheckSigningRequirementsHeadCommits(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
require.NoError(t, pr.LoadBaseRepo(ctx))
|
||||
require.NoError(t, pr.LoadHeadRepo(ctx))
|
||||
|
||||
check := func() error {
|
||||
return checkSigningRequirements(ctx, pr, nil, repo_model.MergeStyleFastForwardOnly)
|
||||
}
|
||||
|
||||
// No protected branch rule on the base branch: the check must pass.
|
||||
require.NoError(t, check())
|
||||
|
||||
// Protected branch without RequireSignedCommits: the check must still pass.
|
||||
require.NoError(t, git_model.UpdateProtectBranch(ctx, pr.BaseRepo, &git_model.ProtectedBranch{
|
||||
RepoID: pr.BaseRepoID,
|
||||
RuleName: pr.BaseBranch,
|
||||
RequireSignedCommits: false,
|
||||
}, git_model.WhitelistOptions{}))
|
||||
require.NoError(t, check())
|
||||
|
||||
// With RequireSignedCommits enabled: the test fixture commits have no signatures,
|
||||
// so the check must report ErrHeadCommitsNotAllVerified.
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pb)
|
||||
pb.RequireSignedCommits = true
|
||||
require.NoError(t, git_model.UpdateProtectBranch(ctx, pr.BaseRepo, pb, git_model.WhitelistOptions{}))
|
||||
require.ErrorIs(t, check(), ErrHeadCommitsNotAllVerified)
|
||||
}
|
||||
|
||||
func TestMarkPullRequestAsMergeable(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
prPatchCheckerQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "pr_patch_checker", func(items ...string) []string { return nil })
|
||||
go prPatchCheckerQueue.Run()
|
||||
defer func() {
|
||||
prPatchCheckerQueue.ShutdownWait(time.Second)
|
||||
prPatchCheckerQueue = nil
|
||||
}()
|
||||
|
||||
addToQueueShaChan := make(chan string, 1)
|
||||
defer test.MockVariableValue(&automergequeue.AddToQueue, func(pr *issues_model.PullRequest, sha string) {
|
||||
addToQueueShaChan <- sha
|
||||
})()
|
||||
ctx := t.Context()
|
||||
_, _ = db.GetEngine(ctx).ID(2).Update(&issues_model.PullRequest{Status: issues_model.PullRequestStatusChecking})
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
require.False(t, pr.HasMerged)
|
||||
require.Equal(t, issues_model.PullRequestStatusChecking, pr.Status)
|
||||
|
||||
err := pull.ScheduleAutoMerge(ctx, &user_model.User{ID: 99999}, pr.ID, repo_model.MergeStyleMerge, "test msg", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
exist, scheduleMerge, err := pull.GetScheduledMergeByPullID(ctx, pr.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
assert.True(t, scheduleMerge.Doer.IsGhost())
|
||||
|
||||
markPullRequestAsMergeable(ctx, pr)
|
||||
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
require.Equal(t, issues_model.PullRequestStatusMergeable, pr.Status)
|
||||
|
||||
select {
|
||||
case sha := <-addToQueueShaChan:
|
||||
assert.Equal(t, "985f0301dba5e7b34be866819cd15ad3d8f508ee", sha) // ref: refs/pull/3/head
|
||||
case <-time.After(1 * time.Second):
|
||||
assert.FailNow(t, "Timeout: nothing was added to automergequeue")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
const maxPushCommitsInCommentCount = 1000
|
||||
|
||||
func preparePushPullCommentPushActionContent(ctx context.Context, pr *issues_model.PullRequest, oldCommitID, newCommitID string, isForcePush bool) (data issues_model.PushActionContent, shouldCreate bool, err error) {
|
||||
if isForcePush {
|
||||
// if it's a force push, we need to get the whole pull request commits
|
||||
// the force-push timeline comment should always be created, so all errors are ignored and logged only.
|
||||
mergeBase, err := gitrepo.MergeBase(ctx, pr.BaseRepo, pr.BaseBranch, newCommitID)
|
||||
if err != nil {
|
||||
log.Debug("MergeBase %q..%q failed: %v", pr.BaseBranch, newCommitID, err)
|
||||
} else {
|
||||
data.CommitIDs, err = gitrepo.GetCommitIDsBetweenReverse(ctx, pr.BaseRepo, mergeBase, newCommitID, "", maxPushCommitsInCommentCount)
|
||||
if err != nil {
|
||||
log.Debug("GetCommitIDsBetweenReverse %q..%q failed: %v", mergeBase, newCommitID, err)
|
||||
}
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// for a normal push, it maybe an empty pull request, only non-empty pull request need to create push comment
|
||||
data.CommitIDs, err = gitrepo.GetCommitIDsBetweenReverse(ctx, pr.BaseRepo, oldCommitID, newCommitID, pr.BaseBranch, maxPushCommitsInCommentCount)
|
||||
return data, len(data.CommitIDs) > 0, err
|
||||
}
|
||||
|
||||
func reconcileOldCommitCommentsForForcePush(ctx context.Context, oldCommitComments []*issues_model.Comment, newData *issues_model.PushActionContent) (needDeleteCommentIDs []int64) {
|
||||
newPushCommitIDMaps := container.SetOf(newData.CommitIDs...)
|
||||
for _, oldCommitComment := range oldCommitComments {
|
||||
oldData, err := oldCommitComment.GetPushActionContent()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if oldData.IsForcePush {
|
||||
// old comment is for force push, it should be kept
|
||||
continue
|
||||
}
|
||||
|
||||
// remove the old comment's commit IDs which are not in the new "force" push
|
||||
oldData.CommitIDs = slices.DeleteFunc(oldData.CommitIDs, func(oldCommitID string) bool { return !newPushCommitIDMaps.Contains(oldCommitID) })
|
||||
// if old comment doesn't contain any commit ID after the force push, then it can be deleted
|
||||
if len(oldData.CommitIDs) == 0 {
|
||||
needDeleteCommentIDs = append(needDeleteCommentIDs, oldCommitComment.ID)
|
||||
continue
|
||||
}
|
||||
// remove new comment's commit IDs which are already in old comment
|
||||
for _, oldCommitID := range oldData.CommitIDs {
|
||||
newData.CommitIDs = slices.DeleteFunc(newData.CommitIDs, func(newCommitID string) bool { return newCommitID == oldCommitID })
|
||||
}
|
||||
|
||||
// update the old comment's content (the commit IDs have been changed)
|
||||
updatedOldContent, _ := json.Marshal(oldData)
|
||||
_, err = db.GetEngine(ctx).ID(oldCommitComment.ID).Cols("content").NoAutoTime().Update(&issues_model.Comment{Content: string(updatedOldContent)})
|
||||
if err != nil {
|
||||
log.Error("Update Comment content failed: %v", err)
|
||||
}
|
||||
}
|
||||
return needDeleteCommentIDs
|
||||
}
|
||||
|
||||
func cleanUpOldCommitCommentsForNewForcePush(ctx context.Context, pr *issues_model.PullRequest, data *issues_model.PushActionContent) error {
|
||||
// All old non-force-push commit comments will be deleted if they are not in the new commit list.
|
||||
var oldCommitComments []*issues_model.Comment
|
||||
err := db.GetEngine(ctx).Table("comment").
|
||||
Where("issue_id = ?", pr.IssueID).And("type = ?", issues_model.CommentTypePullRequestPush).
|
||||
Find(&oldCommitComments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
needDeleteCommentIDs := reconcileOldCommitCommentsForForcePush(ctx, oldCommitComments, data)
|
||||
if len(needDeleteCommentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = db.GetEngine(ctx).In("id", needDeleteCommentIDs).Delete(&issues_model.Comment{})
|
||||
return err
|
||||
}
|
||||
|
||||
// CreatePushPullComment create push code to pull base comment
|
||||
func CreatePushPullComment(ctx context.Context, pusher *user_model.User, pr *issues_model.PullRequest, oldRef, newRef string, isForcePush bool) (comment *issues_model.Comment, created bool, err error) {
|
||||
if pr.HasMerged || oldRef == "" || newRef == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
oldCommitID := oldRef
|
||||
if !git.IsEmptyCommitID(oldRef) {
|
||||
oldCommitID, err = gitRepo.GetRefCommitID(oldRef)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
newCommitID, err := gitRepo.GetRefCommitID(newRef)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
data, shouldCreate, err := preparePushPullCommentPushActionContent(ctx, pr, oldCommitID, newCommitID, isForcePush)
|
||||
if !shouldCreate {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
comment, err = db.WithTx2(ctx, func(ctx context.Context) (comment *issues_model.Comment, err error) {
|
||||
if isForcePush {
|
||||
err := cleanUpOldCommitCommentsForNewForcePush(ctx, pr, &data)
|
||||
if err != nil {
|
||||
log.Error("CleanUpOldCommitComments failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.CommitIDs) > 0 {
|
||||
// if the push has commit IDs, add a "normal push" commit comment
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
opts := &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypePullRequestPush,
|
||||
Doer: pusher,
|
||||
Repo: pr.BaseRepo,
|
||||
Issue: pr.Issue,
|
||||
Content: string(dataJSON),
|
||||
}
|
||||
comment, err = issues_model.CreateComment(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if isForcePush {
|
||||
// if it's a force push, we need to add a force push comment
|
||||
forcePushDataJSON, _ := json.Marshal(&issues_model.PushActionContent{IsForcePush: true, CommitIDs: []string{oldCommitID, newCommitID}})
|
||||
opts := &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypePullRequestPush,
|
||||
Doer: pusher,
|
||||
Repo: pr.BaseRepo,
|
||||
Issue: pr.Issue,
|
||||
Content: string(forcePushDataJSON),
|
||||
|
||||
// It seems the field is unnecessary anymore because PushActionContent includes IsForcePush field.
|
||||
// However, it can't be simply removed.
|
||||
IsForcePush: true, // See the comment of "Comment.IsForcePush"
|
||||
}
|
||||
comment, err = issues_model.CreateComment(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return comment, nil
|
||||
})
|
||||
return comment, comment != nil, err
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/json"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreatePushPullCommentForcePushDeletesOldComments(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
pusher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
require.NoError(t, pr.LoadIssue(t.Context()))
|
||||
require.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo)
|
||||
require.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
insertCommitComment := func(t *testing.T, content issues_model.PushActionContent) {
|
||||
contentJSON, _ := json.Marshal(content)
|
||||
_, err := issues_model.CreateComment(t.Context(), &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypePullRequestPush,
|
||||
Doer: pusher,
|
||||
Repo: pr.BaseRepo,
|
||||
Issue: pr.Issue,
|
||||
Content: string(contentJSON),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assertCommitCommentCount := func(t *testing.T, expectedTotalCount, expectedForcePushCount int) {
|
||||
comments, err := issues_model.FindComments(t.Context(), &issues_model.FindCommentsOptions{
|
||||
IssueID: pr.IssueID,
|
||||
Type: issues_model.CommentTypePullRequestPush,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
totalCount, forcePushCount := len(comments), 0
|
||||
for _, comment := range comments {
|
||||
pushData, err := comment.GetPushActionContent()
|
||||
require.NoError(t, err)
|
||||
if pushData.IsForcePush {
|
||||
forcePushCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expectedTotalCount, totalCount, "total comment count should match")
|
||||
assert.Equal(t, expectedForcePushCount, forcePushCount, "force push comment count should match")
|
||||
}
|
||||
|
||||
t.Run("base-branch-only", func(t *testing.T) {
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &issues_model.Comment{}))
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
assertCommitCommentCount(t, 2, 0)
|
||||
|
||||
baseCommit, err := gitRepo.GetBranchCommit(pr.BaseBranch)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// force push, the old push comments should be deleted, and one new force-push comment should be created.
|
||||
// the pushed branch is the same as base branch, so no commit between old and new commit, no regular push comment
|
||||
comment, _, err := CreatePushPullComment(t.Context(), pusher, pr, baseCommit.ID.String(), baseCommit.ID.String(), true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, comment)
|
||||
assertCommitCommentCount(t, 1, 1)
|
||||
|
||||
createdData, err := comment.GetPushActionContent()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, createdData.IsForcePush)
|
||||
assert.Equal(t, []string{baseCommit.ID.String(), baseCommit.ID.String()}, createdData.CommitIDs)
|
||||
})
|
||||
|
||||
t.Run("force-push-ignores-missing-old-commit", func(t *testing.T) {
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &issues_model.Comment{}))
|
||||
headCommit, err := gitRepo.GetBranchCommit(pr.HeadBranch)
|
||||
require.NoError(t, err)
|
||||
|
||||
commitIDZero := git.Sha1ObjectFormat.EmptyObjectID().String()
|
||||
comment, _, err := CreatePushPullComment(t.Context(), pusher, pr, commitIDZero, headCommit.ID.String(), true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, comment)
|
||||
createdData, err := comment.GetPushActionContent()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, createdData.IsForcePush)
|
||||
assert.Equal(t, []string{commitIDZero, headCommit.ID.String()}, createdData.CommitIDs)
|
||||
assertCommitCommentCount(t, 2, 1)
|
||||
|
||||
// force push again, the old force push comment should not be deleted, new we have 2 force push comments.
|
||||
_, _, err = CreatePushPullComment(t.Context(), pusher, pr, commitIDZero, headCommit.ID.String(), true)
|
||||
require.NoError(t, err)
|
||||
assertCommitCommentCount(t, 3, 2)
|
||||
})
|
||||
|
||||
t.Run("head-vs-base-branch", func(t *testing.T) {
|
||||
require.NoError(t, db.TruncateBeans(t.Context(), &issues_model.Comment{}))
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
insertCommitComment(t, issues_model.PushActionContent{})
|
||||
assertCommitCommentCount(t, 4, 0)
|
||||
|
||||
baseCommit, err := gitRepo.GetBranchCommit(pr.BaseBranch)
|
||||
require.NoError(t, err)
|
||||
headCommit, err := gitRepo.GetBranchCommit(pr.HeadBranch)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = CreatePushPullComment(t.Context(), pusher, pr, baseCommit.ID.String(), headCommit.ID.String(), true)
|
||||
require.NoError(t, err)
|
||||
// 2 comments should exist now: one regular push comment and one force-push comment.
|
||||
assertCommitCommentCount(t, 2, 1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// MergeRequiredContextsCommitStatus returns a commit status state for given required contexts
|
||||
func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus, requiredContexts []string) commitstatus.CommitStatusState {
|
||||
if len(commitStatuses) == 0 {
|
||||
return commitstatus.CommitStatusPending
|
||||
}
|
||||
|
||||
if len(requiredContexts) == 0 {
|
||||
return git_model.CalcCommitStatus(commitStatuses).State
|
||||
}
|
||||
|
||||
requiredContextsGlob := make(map[string]glob.Glob, len(requiredContexts))
|
||||
for _, ctx := range requiredContexts {
|
||||
if gp, err := glob.Compile(ctx); err != nil {
|
||||
log.Error("glob.Compile %s failed. Error: %v", ctx, err)
|
||||
} else {
|
||||
requiredContextsGlob[ctx] = gp
|
||||
}
|
||||
}
|
||||
|
||||
requiredCommitStatuses := make([]*git_model.CommitStatus, 0, len(commitStatuses))
|
||||
allRequiredContextsMatched := true
|
||||
for _, gp := range requiredContextsGlob {
|
||||
requiredContextMatched := false
|
||||
for _, commitStatus := range commitStatuses {
|
||||
if gp.Match(commitStatus.Context) {
|
||||
requiredCommitStatuses = append(requiredCommitStatuses, commitStatus)
|
||||
requiredContextMatched = true
|
||||
}
|
||||
}
|
||||
allRequiredContextsMatched = allRequiredContextsMatched && requiredContextMatched
|
||||
}
|
||||
if len(requiredCommitStatuses) == 0 {
|
||||
return commitstatus.CommitStatusPending
|
||||
}
|
||||
|
||||
returnedStatus := git_model.CalcCommitStatus(requiredCommitStatuses).State
|
||||
if allRequiredContextsMatched {
|
||||
return returnedStatus
|
||||
}
|
||||
|
||||
if returnedStatus == commitstatus.CommitStatusFailure {
|
||||
return commitstatus.CommitStatusFailure
|
||||
}
|
||||
// even if part of success, return pending
|
||||
return commitstatus.CommitStatusPending
|
||||
}
|
||||
|
||||
// IsPullCommitStatusPass returns if all required status checks PASS
|
||||
func IsPullCommitStatusPass(ctx context.Context, pr *issues_model.PullRequest) (bool, error) {
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
if pb == nil || !pb.EnableStatusCheck {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
state, err := GetPullRequestCommitStatusState(ctx, pr)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return state.IsSuccess(), nil
|
||||
}
|
||||
|
||||
// GetPullRequestCommitStatusState returns pull request merged commit status state
|
||||
func GetPullRequestCommitStatusState(ctx context.Context, pr *issues_model.PullRequest) (commitstatus.CommitStatusState, error) {
|
||||
// Ensure HeadRepo is loaded
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return "", fmt.Errorf("LoadHeadRepo: %w", err)
|
||||
}
|
||||
|
||||
// check if all required status checks are successful
|
||||
headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||
if exist, err := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); err != nil {
|
||||
return "", fmt.Errorf("IsBranchExist: %w", err)
|
||||
} else if !exist {
|
||||
return "", errors.New("Head branch does not exist, can not merge")
|
||||
}
|
||||
}
|
||||
if pr.Flow == issues_model.PullRequestFlowAGit && !gitrepo.IsReferenceExist(ctx, pr.HeadRepo, pr.GetGitHeadRefName()) {
|
||||
return "", errors.New("Head branch does not exist, can not merge")
|
||||
}
|
||||
|
||||
var sha string
|
||||
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||
sha, err = headGitRepo.GetBranchCommitID(pr.HeadBranch)
|
||||
} else {
|
||||
sha, err = headGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return "", fmt.Errorf("LoadBaseRepo: %w", err)
|
||||
}
|
||||
|
||||
commitStatuses, err := git_model.GetLatestCommitStatus(ctx, pr.BaseRepo.ID, sha, db.ListOptionsAll)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GetLatestCommitStatus: %w", err)
|
||||
}
|
||||
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("LoadProtectedBranch: %w", err)
|
||||
}
|
||||
var requiredContexts []string
|
||||
if pb != nil {
|
||||
requiredContexts = pb.StatusCheckContexts
|
||||
}
|
||||
|
||||
return MergeRequiredContextsCommitStatus(commitStatuses, requiredContexts), nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2024 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMergeRequiredContextsCommitStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
commitStatuses []*git_model.CommitStatus
|
||||
requiredContexts []string
|
||||
expected commitstatus.CommitStatusState
|
||||
}{
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{},
|
||||
requiredContexts: []string{},
|
||||
expected: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build xxx", State: commitstatus.CommitStatusSkipped},
|
||||
},
|
||||
requiredContexts: []string{"Build*"},
|
||||
expected: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSkipped},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 3", State: commitstatus.CommitStatusSuccess},
|
||||
},
|
||||
requiredContexts: []string{"Build*"},
|
||||
expected: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2t", State: commitstatus.CommitStatusPending},
|
||||
},
|
||||
requiredContexts: []string{"Build*", "Build 2t*"},
|
||||
expected: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2t", State: commitstatus.CommitStatusFailure},
|
||||
},
|
||||
requiredContexts: []string{"Build*", "Build 2t*"},
|
||||
expected: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2t", State: commitstatus.CommitStatusFailure},
|
||||
},
|
||||
requiredContexts: []string{"Build*"},
|
||||
expected: commitstatus.CommitStatusFailure,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2t", State: commitstatus.CommitStatusSuccess},
|
||||
},
|
||||
requiredContexts: []string{"Build*", "Build 2t*", "Build 3*"},
|
||||
expected: commitstatus.CommitStatusPending,
|
||||
},
|
||||
{
|
||||
commitStatuses: []*git_model.CommitStatus{
|
||||
{Context: "Build 1", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2", State: commitstatus.CommitStatusSuccess},
|
||||
{Context: "Build 2t", State: commitstatus.CommitStatusSuccess},
|
||||
},
|
||||
requiredContexts: []string{"Build*", "Build *", "Build 2t*", "Build 1*"},
|
||||
expected: commitstatus.CommitStatusSuccess,
|
||||
},
|
||||
}
|
||||
for i, c := range cases {
|
||||
assert.Equal(t, c.expected, MergeRequiredContextsCommitStatus(c.commitStatuses, c.requiredContexts), "case %d", i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright 2022 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
unit_model "gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
)
|
||||
|
||||
var ErrUserHasNoPermissionForAction = errors.New("user not allowed to do this action")
|
||||
|
||||
// SetAllowEdits allow edits from maintainers to PRs
|
||||
func SetAllowEdits(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest, allow bool) error {
|
||||
if doer == nil || !pr.Issue.IsPoster(doer.ID) {
|
||||
return ErrUserHasNoPermissionForAction
|
||||
}
|
||||
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permission, err := access_model.GetDoerRepoPermission(ctx, pr.HeadRepo, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !permission.CanWrite(unit_model.TypeCode) {
|
||||
return ErrUserHasNoPermissionForAction
|
||||
}
|
||||
|
||||
pr.AllowMaintainerEdit = allow
|
||||
return issues_model.UpdateAllowEdits(ctx, pr)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/git/pipeline"
|
||||
"gitea.dev/modules/lfs"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// LFSPush pushes lfs objects referred to in new commits in the head repository from the base repository
|
||||
func LFSPush(ctx context.Context, tmpBasePath, mergeHeadSHA, mergeBaseSHA string, pr *issues_model.PullRequest) error {
|
||||
// Now we have to implement git lfs push
|
||||
// git rev-list --objects --filter=blob:limit=1k HEAD --not base
|
||||
// pass blob shas in to git cat-file --batch-check (possibly unnecessary)
|
||||
// ensure only blobs and <=1k size then pass in to git cat-file --batch
|
||||
// to read each sha and check each as a pointer
|
||||
// Then if they are lfs -> add them to the baseRepo
|
||||
|
||||
cmd1RevList, cmd3BathCheck, cmd5BatchContent := gitcmd.NewCommand(), gitcmd.NewCommand(), gitcmd.NewCommand()
|
||||
cmd1RevListOut, cmd1RevListClose := cmd1RevList.MakeStdoutPipe()
|
||||
defer cmd1RevListClose()
|
||||
|
||||
cmd3BatchCheckIn, cmd3BatchCheckOut, cmd3BatchCheckClose := cmd3BathCheck.MakeStdinStdoutPipe()
|
||||
defer cmd3BatchCheckClose()
|
||||
|
||||
cmd5BatchContentIn, cmd5BatchContentOut, cmd5BatchContentClose := cmd5BatchContent.MakeStdinStdoutPipe()
|
||||
defer cmd5BatchContentClose()
|
||||
|
||||
// Create the go-routines in reverse order (update: the order is not needed any more, the pipes are properly prepared)
|
||||
wg := &errgroup.Group{}
|
||||
|
||||
// 6. Take the output of cat-file --batch and check if each file in turn
|
||||
// to see if they're pointers to files in the LFS store associated with
|
||||
// the head repo and add them to the base repo if so
|
||||
wg.Go(func() error {
|
||||
return createLFSMetaObjectsFromCatFileBatch(ctx, cmd5BatchContentOut, pr)
|
||||
})
|
||||
|
||||
// 5. Take the shas of the blobs and batch read them
|
||||
wg.Go(func() error {
|
||||
return pipeline.CatFileBatch(ctx, cmd5BatchContent, tmpBasePath)
|
||||
})
|
||||
|
||||
// 4. From the provided objects restrict to blobs <=1k
|
||||
wg.Go(func() error {
|
||||
return pipeline.BlobsLessThan1024FromCatFileBatchCheck(cmd3BatchCheckOut, cmd5BatchContentIn)
|
||||
})
|
||||
|
||||
// 3. Run batch-check on the objects retrieved from rev-list
|
||||
wg.Go(func() error {
|
||||
return pipeline.CatFileBatchCheck(ctx, cmd3BathCheck, tmpBasePath)
|
||||
})
|
||||
|
||||
// 2. Check each object retrieved rejecting those without names as they will be commits or trees
|
||||
wg.Go(func() error {
|
||||
return pipeline.BlobsFromRevListObjects(cmd1RevListOut, cmd3BatchCheckIn)
|
||||
})
|
||||
|
||||
// 1. Run rev-list objects from mergeHead to mergeBase
|
||||
wg.Go(func() error {
|
||||
return pipeline.RevListObjects(ctx, cmd1RevList, tmpBasePath, mergeHeadSHA, mergeBaseSHA)
|
||||
})
|
||||
|
||||
return wg.Wait()
|
||||
}
|
||||
|
||||
func createLFSMetaObjectsFromCatFileBatch(ctx context.Context, catFileBatchReader io.ReadCloser, pr *issues_model.PullRequest) error {
|
||||
defer catFileBatchReader.Close()
|
||||
|
||||
contentStore := lfs.NewContentStore()
|
||||
bufferedReader := bufio.NewReader(catFileBatchReader)
|
||||
buf := make([]byte, 1025)
|
||||
for {
|
||||
// File descriptor line: sha
|
||||
_, err := bufferedReader.ReadString(' ')
|
||||
if err != nil {
|
||||
return util.Iif(errors.Is(err, io.EOF), nil, err)
|
||||
}
|
||||
// Throw away the blob
|
||||
if _, err := bufferedReader.ReadString(' '); err != nil {
|
||||
return err
|
||||
}
|
||||
sizeStr, err := bufferedReader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pointerBuf := buf[:size+1]
|
||||
if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
|
||||
return err
|
||||
}
|
||||
pointerBuf = pointerBuf[:size]
|
||||
// Now we need to check if the pointerBuf is an LFS pointer
|
||||
pointer, _ := lfs.ReadPointerFromBuffer(pointerBuf)
|
||||
if !pointer.IsValid() {
|
||||
continue
|
||||
}
|
||||
|
||||
exist, _ := contentStore.Exists(pointer)
|
||||
if !exist {
|
||||
continue
|
||||
}
|
||||
|
||||
// Then we need to check that this pointer is in the db
|
||||
if _, err := git_model.GetLFSMetaObjectByOid(ctx, pr.HeadRepoID, pointer.Oid); err != nil {
|
||||
if err == git_model.ErrLFSObjectNotExist {
|
||||
log.Warn("During merge of: %d in %-v, there is a pointer to LFS Oid: %s which although present in the LFS store is not associated with the head repo %-v", pr.Index, pr.BaseRepo, pointer.Oid, pr.HeadRepo)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
// OK we have a pointer that is associated with the head repo
|
||||
// and is actually a file in the LFS
|
||||
// Therefore it should be associated with the base repo
|
||||
if _, err := git_model.NewLFSMetaObject(ctx, pr.BaseRepoID, pointer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
_ "gitea.dev/models/actions"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
unittest.MainTest(m)
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
pull_model "gitea.dev/models/pull"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
issue_service "gitea.dev/services/issue"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// getMergeMessage composes the message used when merging a pull request.
|
||||
func getMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issues_model.PullRequest, mergeStyle repo_model.MergeStyle, extraVars map[string]string) (message, body string, err error) {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := pr.Issue.LoadPoster(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
isExternalTracker := pr.BaseRepo.UnitEnabled(ctx, unit.TypeExternalTracker)
|
||||
issueReference := "#"
|
||||
if isExternalTracker {
|
||||
issueReference = "!"
|
||||
}
|
||||
|
||||
reviewedOn := "Reviewed-on: " + httplib.MakeAbsoluteURL(ctx, pr.Issue.Link())
|
||||
reviewedBy := pr.GetApprovers(ctx)
|
||||
|
||||
if mergeStyle != "" {
|
||||
templateFilepath := fmt.Sprintf(".gitea/default_merge_message/%s_TEMPLATE.md", strings.ToUpper(string(mergeStyle)))
|
||||
commit, err := baseGitRepo.GetBranchCommit(pr.BaseRepo.DefaultBranch)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
templateContent, err := commit.GetFileContent(templateFilepath, setting.Repository.PullRequest.DefaultMergeMessageSize)
|
||||
if err != nil {
|
||||
if !git.IsErrNotExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
} else {
|
||||
vars := map[string]string{
|
||||
"BaseRepoOwnerName": pr.BaseRepo.OwnerName,
|
||||
"BaseRepoName": pr.BaseRepo.Name,
|
||||
"BaseBranch": pr.BaseBranch,
|
||||
"HeadRepoOwnerName": "",
|
||||
"HeadRepoName": "",
|
||||
"HeadBranch": pr.HeadBranch,
|
||||
"PullRequestTitle": pr.Issue.Title,
|
||||
"PullRequestDescription": pr.Issue.Content,
|
||||
"PullRequestPosterName": pr.Issue.Poster.Name,
|
||||
"PullRequestIndex": strconv.FormatInt(pr.Index, 10),
|
||||
"PullRequestReference": fmt.Sprintf("%s%d", issueReference, pr.Index),
|
||||
"ReviewedOn": reviewedOn,
|
||||
"ReviewedBy": reviewedBy,
|
||||
}
|
||||
if pr.HeadRepo != nil {
|
||||
vars["HeadRepoOwnerName"] = pr.HeadRepo.OwnerName
|
||||
vars["HeadRepoName"] = pr.HeadRepo.Name
|
||||
}
|
||||
maps.Copy(vars, extraVars)
|
||||
refs, err := pr.ResolveCrossReferences(ctx)
|
||||
if err == nil {
|
||||
closeIssueIndexes := make([]string, 0, len(refs))
|
||||
closeWord := "close"
|
||||
if len(setting.Repository.PullRequest.CloseKeywords) > 0 {
|
||||
closeWord = setting.Repository.PullRequest.CloseKeywords[0]
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.RefAction == references.XRefActionCloses {
|
||||
if err := ref.LoadIssue(ctx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
closeIssueIndexes = append(closeIssueIndexes, fmt.Sprintf("%s %s%d", closeWord, issueReference, ref.Issue.Index))
|
||||
}
|
||||
}
|
||||
if len(closeIssueIndexes) > 0 {
|
||||
vars["ClosingIssues"] = strings.Join(closeIssueIndexes, ", ")
|
||||
} else {
|
||||
vars["ClosingIssues"] = ""
|
||||
}
|
||||
}
|
||||
message, body = expandDefaultMergeMessage(templateContent, vars)
|
||||
return message, body, nil
|
||||
}
|
||||
}
|
||||
|
||||
if mergeStyle == repo_model.MergeStyleRebase {
|
||||
// for fast-forward rebase, do not amend the last commit if there is no template
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
body = fmt.Sprintf("%s\n%s", reviewedOn, reviewedBy)
|
||||
|
||||
// Squash merge has a different from other styles.
|
||||
if mergeStyle == repo_model.MergeStyleSquash {
|
||||
return fmt.Sprintf("%s (%s%d)", pr.Issue.Title, issueReference, pr.Issue.Index), body, nil
|
||||
}
|
||||
|
||||
if pr.BaseRepoID == pr.HeadRepoID {
|
||||
return fmt.Sprintf("Merge pull request '%s' (%s%d) from %s into %s", pr.Issue.Title, issueReference, pr.Issue.Index, pr.HeadBranch, pr.BaseBranch), body, nil
|
||||
}
|
||||
|
||||
if pr.HeadRepo == nil {
|
||||
return fmt.Sprintf("Merge pull request '%s' (%s%d) from <deleted>:%s into %s", pr.Issue.Title, issueReference, pr.Issue.Index, pr.HeadBranch, pr.BaseBranch), body, nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Merge pull request '%s' (%s%d) from %s:%s into %s", pr.Issue.Title, issueReference, pr.Issue.Index, pr.HeadRepo.FullName(), pr.HeadBranch, pr.BaseBranch), body, nil
|
||||
}
|
||||
|
||||
func expandDefaultMergeMessage(template string, vars map[string]string) (message, body string) {
|
||||
message = strings.TrimSpace(template)
|
||||
if splits := strings.SplitN(message, "\n", 2); len(splits) == 2 {
|
||||
message = splits[0]
|
||||
body = strings.TrimSpace(splits[1])
|
||||
}
|
||||
mapping := func(s string) string { return vars[s] }
|
||||
return os.Expand(message, mapping), os.Expand(body, mapping)
|
||||
}
|
||||
|
||||
// GetDefaultMergeMessage returns default message used when merging pull request
|
||||
func GetDefaultMergeMessage(ctx context.Context, baseGitRepo *git.Repository, pr *issues_model.PullRequest, mergeStyle repo_model.MergeStyle) (message, body string, err error) {
|
||||
return getMergeMessage(ctx, baseGitRepo, pr, mergeStyle, nil)
|
||||
}
|
||||
|
||||
func AddCommitMessageTailer(message, tailerKey, tailerValue string) string {
|
||||
tailerLine := tailerKey + ": " + tailerValue
|
||||
message = strings.ReplaceAll(message, "\r\n", "\n")
|
||||
message = strings.ReplaceAll(message, "\r", "\n")
|
||||
if strings.Contains(message, "\n"+tailerLine+"\n") || strings.HasSuffix(message, "\n"+tailerLine) {
|
||||
return message
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(message, "\n") {
|
||||
message += "\n"
|
||||
}
|
||||
pos1 := strings.LastIndexByte(message[:len(message)-1], '\n')
|
||||
pos2 := -1
|
||||
if pos1 != -1 {
|
||||
pos2 = strings.IndexByte(message[pos1:], ':')
|
||||
if pos2 != -1 {
|
||||
pos2 += pos1
|
||||
}
|
||||
}
|
||||
var lastLineKey string
|
||||
if pos1 != -1 && pos2 != -1 {
|
||||
lastLineKey = message[pos1+1 : pos2]
|
||||
}
|
||||
|
||||
isLikelyTailerLine := lastLineKey != "" && unicode.IsUpper(rune(lastLineKey[0])) && strings.Contains(message, "-")
|
||||
for i := 0; isLikelyTailerLine && i < len(lastLineKey); i++ {
|
||||
r := rune(lastLineKey[i])
|
||||
isLikelyTailerLine = unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-'
|
||||
}
|
||||
if !strings.HasSuffix(message, "\n\n") && !isLikelyTailerLine {
|
||||
message += "\n"
|
||||
}
|
||||
return message + tailerLine
|
||||
}
|
||||
|
||||
// ErrInvalidMergeStyle represents an error if merging with disabled merge strategy
|
||||
type ErrInvalidMergeStyle struct {
|
||||
ID int64
|
||||
Style repo_model.MergeStyle
|
||||
}
|
||||
|
||||
// IsErrInvalidMergeStyle checks if an error is a ErrInvalidMergeStyle.
|
||||
func IsErrInvalidMergeStyle(err error) bool {
|
||||
_, ok := err.(ErrInvalidMergeStyle)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrInvalidMergeStyle) Error() string {
|
||||
return fmt.Sprintf("merge strategy is not allowed or is invalid [repo_id: %d, strategy: %s]",
|
||||
err.ID, err.Style)
|
||||
}
|
||||
|
||||
func (err ErrInvalidMergeStyle) Unwrap() error {
|
||||
return util.ErrInvalidArgument
|
||||
}
|
||||
|
||||
// Merge merges pull request to base repository.
|
||||
// Caller should check PR is ready to be merged (review and status checks)
|
||||
func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, wasAutoMerged bool) error {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("Unable to load base repo: %v", err)
|
||||
return fmt.Errorf("unable to load base repo: %w", err)
|
||||
} else if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
log.Error("Unable to load head repo: %v", err)
|
||||
return fmt.Errorf("unable to load head repo: %w", err)
|
||||
}
|
||||
|
||||
prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
log.Error("pr.BaseRepo.GetUnit(unit.TypePullRequests): %v", err)
|
||||
return err
|
||||
}
|
||||
prConfig := prUnit.PullRequestsConfig()
|
||||
|
||||
// Check if merge style is correct and allowed
|
||||
if !prConfig.IsMergeStyleAllowed(mergeStyle) {
|
||||
return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
|
||||
}
|
||||
|
||||
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
defer func() {
|
||||
// This is a duplicated call to AddTestPullRequestTask (it will also be called by the post-receive hook, via a push queue).
|
||||
// This call will do some operations (push to base repo, sync commit divergence, add PR conflict check queue task, etc)
|
||||
// immediately instead of waiting for the "push queue"'s task. The code is from https://github.com/go-gitea/gitea/pull/7082.
|
||||
// But it's really questionable whether it's worth to do it ahead without waiting for the "push queue" task to run.
|
||||
// TODO: DUPLICATE-PR-TASK: maybe can try to remove this in 1.26 to see if there is any issue.
|
||||
go AddTestPullRequestTask(TestPullRequestOptions{
|
||||
RepoID: pr.BaseRepo.ID,
|
||||
Doer: doer,
|
||||
Branch: pr.BaseBranch,
|
||||
IsSync: false,
|
||||
IsForcePush: false,
|
||||
OldCommitID: "",
|
||||
NewCommitID: "",
|
||||
})
|
||||
}()
|
||||
|
||||
_, err = doMergeAndPush(ctx, pr, doer, mergeStyle, expectedHeadCommitID, message, repo_module.PushTriggerPRMergeToBase)
|
||||
releaser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// reload pull request because it has been updated by post receive hook
|
||||
pr, err = issues_model.GetPullRequestByID(ctx, pr.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
log.Error("LoadIssue %-v: %v", pr, err)
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
log.Error("pr.Issue.LoadRepo %-v: %v", pr, err)
|
||||
}
|
||||
if err := pr.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
log.Error("LoadOwner for %-v: %v", pr, err)
|
||||
}
|
||||
|
||||
if wasAutoMerged {
|
||||
notify_service.AutoMergePullRequest(ctx, doer, pr)
|
||||
} else {
|
||||
notify_service.MergePullRequest(ctx, doer, pr)
|
||||
}
|
||||
|
||||
// Reset cached commit count
|
||||
cache.Remove(pr.Issue.Repo.GetCommitsCountCacheKey(pr.BaseBranch, true))
|
||||
|
||||
return handleCloseCrossReferences(ctx, pr, doer)
|
||||
}
|
||||
|
||||
func handleCloseCrossReferences(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User) error {
|
||||
// Resolve cross references
|
||||
refs, err := pr.ResolveCrossReferences(ctx)
|
||||
if err != nil {
|
||||
log.Error("ResolveCrossReferences: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
if err = ref.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = ref.Issue.LoadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.RefAction == references.XRefActionCloses && !ref.Issue.IsClosed {
|
||||
if err = issue_service.CloseIssue(ctx, ref.Issue, doer, pr.MergedCommitID); err != nil {
|
||||
// Allow ErrDependenciesLeft
|
||||
if !issues_model.IsErrDependenciesLeft(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if ref.RefAction == references.XRefActionReopens && ref.Issue.IsClosed {
|
||||
if err = issue_service.ReopenIssue(ctx, ref.Issue, doer, pr.MergedCommitID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// doMergeAndPush performs the merge operation without changing any pull information in database and pushes it up to the base repository
|
||||
func doMergeAndPush(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, mergeStyle repo_model.MergeStyle, expectedHeadCommitID, message string, pushTrigger repo_module.PushTrigger) (string, error) { //nolint:unparam // non-error result is never used
|
||||
// Clone base repo.
|
||||
mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, expectedHeadCommitID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// Merge commits.
|
||||
switch mergeStyle {
|
||||
case repo_model.MergeStyleMerge:
|
||||
if err := doMergeStyleMerge(mergeCtx, message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case repo_model.MergeStyleRebase, repo_model.MergeStyleRebaseMerge:
|
||||
if err := doMergeStyleRebase(mergeCtx, mergeStyle, message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case repo_model.MergeStyleSquash:
|
||||
if err := doMergeStyleSquash(mergeCtx, message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case repo_model.MergeStyleFastForwardOnly:
|
||||
if err := doMergeStyleFastForwardOnly(mergeCtx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: mergeStyle}
|
||||
}
|
||||
|
||||
// OK we should cache our current head and origin/headbranch
|
||||
mergeHeadSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, "HEAD")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
||||
}
|
||||
mergeBaseSHA, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, "original_"+tmpRepoBaseBranch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to get full commit id for origin/%s: %w", pr.BaseBranch, err)
|
||||
}
|
||||
mergeCommitID, err := git.GetFullCommitID(ctx, mergeCtx.tmpBasePath, tmpRepoBaseBranch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Failed to get full commit id for the new merge: %w", err)
|
||||
}
|
||||
|
||||
// Now it's questionable about where this should go - either after or before the push
|
||||
// I think in the interests of data safety - failures to push to the lfs should prevent
|
||||
// the merge as you can always remerge.
|
||||
if setting.LFS.StartServer {
|
||||
if err := LFSPush(ctx, mergeCtx.tmpBasePath, mergeHeadSHA, mergeBaseSHA, pr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var headUser *user_model.User
|
||||
err = pr.HeadRepo.LoadOwner(ctx)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("Can't find user: %d for head repository in %-v: %v", pr.HeadRepo.OwnerID, pr, err)
|
||||
return "", err
|
||||
}
|
||||
log.Warn("Can't find user: %d for head repository in %-v - defaulting to doer: %s - %v", pr.HeadRepo.OwnerID, pr, doer.Name, err)
|
||||
headUser = doer
|
||||
} else {
|
||||
headUser = pr.HeadRepo.Owner
|
||||
}
|
||||
|
||||
mergeCtx.env = repo_module.FullPushingEnvironment(
|
||||
headUser,
|
||||
doer,
|
||||
pr.BaseRepo,
|
||||
pr.BaseRepo.Name,
|
||||
pr.ID,
|
||||
pr.Index,
|
||||
)
|
||||
|
||||
mergeCtx.env = append(mergeCtx.env, repo_module.EnvPushTrigger+"="+string(pushTrigger))
|
||||
pushCmd := gitcmd.NewCommand("push", "origin").AddDynamicArguments(tmpRepoBaseBranch + ":" + git.BranchPrefix + pr.BaseBranch)
|
||||
|
||||
// Push back to upstream.
|
||||
// This cause an api call to "/api/internal/hook/post-receive/...",
|
||||
// If it's merge, all db transaction and operations should be there but not here to prevent deadlock.
|
||||
if err := mergeCtx.PrepareGitCmd(pushCmd).RunWithStderr(ctx); err != nil {
|
||||
if strings.Contains(err.Stderr(), "non-fast-forward") {
|
||||
return "", &git.ErrPushOutOfDate{
|
||||
StdOut: mergeCtx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
} else if strings.Contains(err.Stderr(), "! [remote rejected]") {
|
||||
err := &git.ErrPushRejected{
|
||||
StdOut: mergeCtx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
err.GenerateMessage()
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("git push: %s", err.Stderr())
|
||||
}
|
||||
mergeCtx.outbuf.Reset()
|
||||
return mergeCommitID, nil
|
||||
}
|
||||
|
||||
func commitAndSignNoAuthor(ctx *mergeContext, message string) error {
|
||||
cmdCommit := gitcmd.NewCommand("commit").AddOptionFormat("--message=%s", message)
|
||||
addCommitSigningOptions(cmdCommit, ctx.signKey)
|
||||
if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil {
|
||||
return fmt.Errorf("git commit %v: %w\n%s", ctx.pr, err, ctx.outbuf.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addCommitSigningOptions(cmd *gitcmd.Command, signKey *git.SigningKey) {
|
||||
if signKey == nil {
|
||||
cmd.AddArguments("--no-gpg-sign")
|
||||
return
|
||||
}
|
||||
if signKey.Format != "" {
|
||||
cmd.AddConfig("gpg.format", signKey.Format)
|
||||
}
|
||||
cmd.AddOptionFormat("--gpg-sign=%s", signKey.KeyID)
|
||||
}
|
||||
|
||||
// ErrMergeConflicts represents an error if merging fails with a conflict
|
||||
type ErrMergeConflicts struct {
|
||||
Style repo_model.MergeStyle
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrMergeConflicts checks if an error is a ErrMergeConflicts.
|
||||
func IsErrMergeConflicts(err error) bool {
|
||||
_, ok := err.(ErrMergeConflicts)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrMergeConflicts) Error() string {
|
||||
return fmt.Sprintf("Merge Conflict Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// ErrMergeUnrelatedHistories represents an error if merging fails due to unrelated histories
|
||||
type ErrMergeUnrelatedHistories struct {
|
||||
Style repo_model.MergeStyle
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrMergeUnrelatedHistories checks if an error is a ErrMergeUnrelatedHistories.
|
||||
func IsErrMergeUnrelatedHistories(err error) bool {
|
||||
_, ok := err.(ErrMergeUnrelatedHistories)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrMergeUnrelatedHistories) Error() string {
|
||||
return fmt.Sprintf("Merge UnrelatedHistories Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// ErrMergeDivergingFastForwardOnly represents an error if a fast-forward-only merge fails because the branches diverge
|
||||
type ErrMergeDivergingFastForwardOnly struct {
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrMergeDivergingFastForwardOnly checks if an error is a ErrMergeDivergingFastForwardOnly.
|
||||
func IsErrMergeDivergingFastForwardOnly(err error) bool {
|
||||
_, ok := err.(ErrMergeDivergingFastForwardOnly)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrMergeDivergingFastForwardOnly) Error() string {
|
||||
return fmt.Sprintf("Merge DivergingFastForwardOnly Error: %v: %s\n%s", err.Err, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
func runMergeCommand(ctx *mergeContext, mergeStyle repo_model.MergeStyle, cmd *gitcmd.Command) error {
|
||||
if err := ctx.PrepareGitCmd(cmd).RunWithStderr(ctx); err != nil {
|
||||
// Merge will leave a MERGE_HEAD file in the .git folder if there is a conflict
|
||||
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "MERGE_HEAD")); statErr == nil {
|
||||
// We have a merge conflict error
|
||||
log.Debug("MergeConflict %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return ErrMergeConflicts{
|
||||
Style: mergeStyle,
|
||||
StdOut: ctx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
} else if strings.Contains(err.Stderr(), "refusing to merge unrelated histories") {
|
||||
log.Debug("MergeUnrelatedHistories %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return ErrMergeUnrelatedHistories{
|
||||
Style: mergeStyle,
|
||||
StdOut: ctx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
} else if mergeStyle == repo_model.MergeStyleFastForwardOnly && strings.Contains(err.Stderr(), "Not possible to fast-forward, aborting") {
|
||||
log.Debug("MergeDivergingFastForwardOnly %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return ErrMergeDivergingFastForwardOnly{
|
||||
StdOut: ctx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
log.Error("git merge %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return fmt.Errorf("git merge %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
var escapedSymbols = regexp.MustCompile(`([*[?! \\])`)
|
||||
|
||||
// IsUserAllowedToMerge check if user is allowed to merge PR with given permissions and branch protections
|
||||
func IsUserAllowedToMerge(ctx context.Context, pr *issues_model.PullRequest, p access_model.Permission, user *user_model.User) (bool, error) {
|
||||
return isUserAllowedToMergeInRepoBranch(ctx, pr.BaseRepoID, pr.BaseBranch, p, user)
|
||||
}
|
||||
|
||||
func isUserAllowedToMergeInRepoBranch(ctx context.Context, repoID int64, branch string, p access_model.Permission, user *user_model.User) (bool, error) {
|
||||
if user == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repoID, branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if (p.CanWrite(unit.TypeCode) && pb == nil) || (pb != nil && git_model.IsUserMergeWhitelisted(ctx, pb, user.ID, p)) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// CheckPullBranchProtections checks whether the PR is ready to be merged (reviews and status checks)
|
||||
func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullRequest, skipProtectedFilesCheck bool) (err error) {
|
||||
if err = pr.LoadBaseRepo(ctx); err != nil {
|
||||
return fmt.Errorf("LoadBaseRepo: %w", err)
|
||||
}
|
||||
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LoadProtectedBranch: %v", err)
|
||||
}
|
||||
if pb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
isPass, err := IsPullCommitStatusPass(ctx, pr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isPass {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Not all required status checks successful")
|
||||
}
|
||||
|
||||
if !issues_model.HasEnoughApprovals(ctx, pb, pr) {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Does not have enough approvals")
|
||||
}
|
||||
if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "There are requested changes")
|
||||
}
|
||||
if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "There are official review requests")
|
||||
}
|
||||
|
||||
if issues_model.MergeBlockedByOutdatedBranch(pb, pr) {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "The head branch is behind the base branch")
|
||||
}
|
||||
|
||||
if skipProtectedFilesCheck {
|
||||
return nil
|
||||
}
|
||||
|
||||
if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) {
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Changed protected files")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergedManually mark pr as merged manually
|
||||
func MergedManually(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, baseGitRepo *git.Repository, commitID string) error {
|
||||
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
err = db.WithTx(ctx, func(ctx context.Context) error {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
prUnit, err := pr.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prConfig := prUnit.PullRequestsConfig()
|
||||
|
||||
// Check if merge style is correct and allowed
|
||||
if !prConfig.IsMergeStyleAllowed(repo_model.MergeStyleManuallyMerged) {
|
||||
return ErrInvalidMergeStyle{ID: pr.BaseRepo.ID, Style: repo_model.MergeStyleManuallyMerged}
|
||||
}
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
||||
if len(commitID) != objectFormat.FullLength() {
|
||||
return errors.New("Wrong commit ID")
|
||||
}
|
||||
|
||||
commit, err := baseGitRepo.GetCommit(commitID)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
return errors.New("Wrong commit ID")
|
||||
}
|
||||
return err
|
||||
}
|
||||
commitID = commit.ID.String()
|
||||
|
||||
ok, err := baseGitRepo.IsCommitInBranch(commitID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("Wrong commit ID")
|
||||
}
|
||||
|
||||
var merged bool
|
||||
if merged, err = SetMerged(ctx, pr, commitID, timeutil.TimeStamp(commit.Author.When.Unix()), doer, issues_model.PullRequestStatusManuallyMerged); err != nil {
|
||||
return err
|
||||
} else if !merged {
|
||||
return errors.New("SetMerged failed")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
releaser()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notify_service.MergePullRequest(ctx, doer, pr)
|
||||
log.Info("manuallyMerged[%d]: Marked as manually merged into %s/%s by commit id: %s", pr.ID, pr.BaseRepo.Name, pr.BaseBranch, commitID)
|
||||
|
||||
return handleCloseCrossReferences(ctx, pr, doer)
|
||||
}
|
||||
|
||||
// SetMerged sets a pull request to merged and closes the corresponding issue
|
||||
func SetMerged(ctx context.Context, pr *issues_model.PullRequest, mergedCommitID string, mergedTimeStamp timeutil.TimeStamp, merger *user_model.User, mergeStatus issues_model.PullRequestStatus) (bool, error) {
|
||||
if pr.HasMerged {
|
||||
return false, fmt.Errorf("PullRequest[%d] already merged", pr.Index)
|
||||
}
|
||||
|
||||
pr.HasMerged = true
|
||||
pr.MergedCommitID = mergedCommitID
|
||||
pr.MergedUnix = mergedTimeStamp
|
||||
pr.Merger = merger
|
||||
pr.MergerID = merger.ID
|
||||
pr.Status = mergeStatus
|
||||
// reset the conflicted files as there cannot be any if we're merged
|
||||
pr.ConflictedFiles = []string{}
|
||||
|
||||
if pr.MergedCommitID == "" || pr.MergedUnix == 0 || pr.Merger == nil {
|
||||
return false, fmt.Errorf("unable to merge PullRequest[%d], some required fields are empty", pr.Index)
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (bool, error) {
|
||||
pr.Issue = nil
|
||||
if err := pr.LoadIssue(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := pr.Issue.LoadRepo(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := pr.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Removing an auto merge pull and ignore if not exist
|
||||
if err := pull_model.DeleteScheduledAutoMerge(ctx, pr.ID); err != nil && !db.IsErrNotExist(err) {
|
||||
return false, fmt.Errorf("DeleteScheduledAutoMerge[%d]: %v", pr.ID, err)
|
||||
}
|
||||
|
||||
// Set issue as closed
|
||||
if _, err := issues_model.SetIssueAsClosed(ctx, pr.Issue, pr.Merger, true); err != nil {
|
||||
return false, fmt.Errorf("ChangeIssueStatus: %w", err)
|
||||
}
|
||||
|
||||
// We need to save all of the data used to compute this merge as it may have already been changed by checkPullRequestBranchMergeable. FIXME: need to set some state to prevent checkPullRequestBranchMergeable from running whilst we are merging.
|
||||
if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
|
||||
And("has_merged = ?", false).
|
||||
Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").
|
||||
Update(pr); err != nil {
|
||||
return false, fmt.Errorf("failed to update pr[%d]: %w", pr.ID, err)
|
||||
} else if cnt != 1 {
|
||||
return false, issues_model.ErrIssueAlreadyChanged
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
func ShouldDeleteBranchAfterMerge(ctx context.Context, userOption *bool, repo *repo_model.Repository, pr *issues_model.PullRequest) (bool, error) {
|
||||
if pr.Flow != issues_model.PullRequestFlowGithub {
|
||||
// only support GitHub workflow (branch-based)
|
||||
// for agit workflow, there is no branch, so nothing to delete
|
||||
// TODO: maybe in the future, it should delete the "agit reference (refs/for/xxxx)"?
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// if user has set an option, respect it
|
||||
if userOption != nil {
|
||||
return *userOption, nil
|
||||
}
|
||||
|
||||
// otherwise, use repository default
|
||||
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return prUnit.PullRequestsConfig().DefaultDeleteBranchAfterMerge, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// doMergeStyleFastForwardOnly merges the tracking into the current HEAD - which is assumed to be staging branch (equal to the pr.BaseBranch)
|
||||
func doMergeStyleFastForwardOnly(ctx *mergeContext) error {
|
||||
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleFastForwardOnly, cmd); err != nil {
|
||||
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// doMergeStyleMerge merges the tracking branch into the current HEAD - which is assumed to be the staging branch (equal to the pr.BaseBranch)
|
||||
func doMergeStyleMerge(ctx *mergeContext, message string) error {
|
||||
cmd := gitcmd.NewCommand("merge", "--no-ff", "--no-commit").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleMerge, cmd); err != nil {
|
||||
log.Error("%-v Unable to merge tracking into base: %v", ctx.pr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := commitAndSignNoAuthor(ctx, message); err != nil {
|
||||
log.Error("%-v Unable to make final commit: %v", ctx.pr, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
)
|
||||
|
||||
type mergeContext struct {
|
||||
*prTmpRepoContext
|
||||
doer *user_model.User
|
||||
sig *git.Signature
|
||||
committer *git.Signature
|
||||
signKey *git.SigningKey
|
||||
env []string
|
||||
}
|
||||
|
||||
// PrepareGitCmd prepares a git command with the correct directory, environment, and output buffers
|
||||
// This function can only be called with gitcmd.Run()
|
||||
// Do NOT use it with gitcmd.RunStd*() functions, otherwise it will panic
|
||||
func (ctx *mergeContext) PrepareGitCmd(cmd *gitcmd.Command) *gitcmd.Command {
|
||||
ctx.outbuf.Reset()
|
||||
return cmd.WithEnv(ctx.env).
|
||||
WithDir(ctx.tmpBasePath).
|
||||
WithParentCallerInfo().
|
||||
WithStdoutBuffer(ctx.outbuf)
|
||||
}
|
||||
|
||||
// ErrSHADoesNotMatch represents a "SHADoesNotMatch" kind of error.
|
||||
type ErrSHADoesNotMatch struct {
|
||||
Path string
|
||||
GivenSHA string
|
||||
CurrentSHA string
|
||||
}
|
||||
|
||||
// IsErrSHADoesNotMatch checks if an error is a ErrSHADoesNotMatch.
|
||||
func IsErrSHADoesNotMatch(err error) bool {
|
||||
_, ok := err.(ErrSHADoesNotMatch)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrSHADoesNotMatch) Error() string {
|
||||
return fmt.Sprintf("sha does not match [given: %s, expected: %s]", err.GivenSHA, err.CurrentSHA)
|
||||
}
|
||||
|
||||
func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, expectedHeadCommitID string) (mergeCtx *mergeContext, cancel context.CancelFunc, err error) {
|
||||
// Clone base repo.
|
||||
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
||||
if err != nil {
|
||||
log.Error("createTemporaryRepoForPR: %v", err)
|
||||
return nil, cancel, err
|
||||
}
|
||||
|
||||
mergeCtx = &mergeContext{
|
||||
prTmpRepoContext: prCtx,
|
||||
doer: doer,
|
||||
}
|
||||
|
||||
if expectedHeadCommitID != "" {
|
||||
trackingCommitID, _, err := gitcmd.NewCommand("show-ref", "--hash").
|
||||
AddDynamicArguments(git.BranchPrefix + tmpRepoTrackingBranch).
|
||||
WithEnv(mergeCtx.env).
|
||||
WithDir(mergeCtx.tmpBasePath).
|
||||
RunStdString(ctx)
|
||||
if err != nil {
|
||||
defer cancel()
|
||||
log.Error("failed to get sha of head branch in %-v: show-ref[%s] --hash refs/heads/tracking: %v", mergeCtx.pr, mergeCtx.tmpBasePath, err)
|
||||
return nil, nil, fmt.Errorf("unable to get sha of head branch in pr[%d]: %w", pr.ID, err)
|
||||
}
|
||||
if strings.TrimSpace(trackingCommitID) != expectedHeadCommitID {
|
||||
defer cancel()
|
||||
return nil, nil, ErrSHADoesNotMatch{
|
||||
GivenSHA: expectedHeadCommitID,
|
||||
CurrentSHA: trackingCommitID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergeCtx.outbuf.Reset()
|
||||
if err := prepareTemporaryRepoForMerge(mergeCtx); err != nil {
|
||||
defer cancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mergeCtx.sig = doer.NewGitSig()
|
||||
mergeCtx.committer = mergeCtx.sig
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
defer cancel()
|
||||
return nil, nil, fmt.Errorf("failed to open temp git repo for pr[%d]: %w", mergeCtx.pr.ID, err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Determine if we should sign
|
||||
sign, key, signer, _ := asymkey_service.SignMerge(ctx, pr, doer, gitRepo)
|
||||
if sign {
|
||||
mergeCtx.signKey = key
|
||||
if pr.BaseRepo.GetTrustModel() == repo_model.CommitterTrustModel || pr.BaseRepo.GetTrustModel() == repo_model.CollaboratorCommitterTrustModel {
|
||||
mergeCtx.committer = signer
|
||||
}
|
||||
}
|
||||
|
||||
commitTimeStr := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Because this may call hooks we should pass in the environment
|
||||
mergeCtx.env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+mergeCtx.sig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+mergeCtx.sig.Email,
|
||||
"GIT_AUTHOR_DATE="+commitTimeStr,
|
||||
"GIT_COMMITTER_NAME="+mergeCtx.committer.Name,
|
||||
"GIT_COMMITTER_EMAIL="+mergeCtx.committer.Email,
|
||||
"GIT_COMMITTER_DATE="+commitTimeStr,
|
||||
)
|
||||
|
||||
return mergeCtx, cancel, nil
|
||||
}
|
||||
|
||||
// prepareTemporaryRepoForMerge takes a repository that has been created using createTemporaryRepo
|
||||
// it then sets up the sparse-checkout and other things
|
||||
func prepareTemporaryRepoForMerge(ctx *mergeContext) error {
|
||||
infoPath := filepath.Join(ctx.tmpBasePath, ".git", "info")
|
||||
if err := os.MkdirAll(infoPath, 0o700); err != nil {
|
||||
log.Error("%-v Unable to create .git/info in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
||||
return fmt.Errorf("Unable to create .git/info in tmpBasePath: %w", err)
|
||||
}
|
||||
|
||||
// Enable sparse-checkout
|
||||
// Here we use the .git/info/sparse-checkout file as described in the git documentation
|
||||
sparseCheckoutListFile, err := os.OpenFile(filepath.Join(infoPath, "sparse-checkout"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
log.Error("%-v Unable to write .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
||||
return fmt.Errorf("Unable to write .git/info/sparse-checkout file in tmpBasePath: %w", err)
|
||||
}
|
||||
defer sparseCheckoutListFile.Close() // we will close it earlier but we need to ensure it is closed if there is an error
|
||||
|
||||
if err := getDiffTree(ctx, ctx.tmpBasePath, tmpRepoBaseBranch, tmpRepoTrackingBranch, sparseCheckoutListFile); err != nil {
|
||||
log.Error("%-v getDiffTree(%s, %s, %s): %v", ctx.pr, ctx.tmpBasePath, tmpRepoBaseBranch, tmpRepoTrackingBranch, err)
|
||||
return fmt.Errorf("getDiffTree: %w", err)
|
||||
}
|
||||
|
||||
if err := sparseCheckoutListFile.Close(); err != nil {
|
||||
log.Error("%-v Unable to close .git/info/sparse-checkout file in %s: %v", ctx.pr, ctx.tmpBasePath, err)
|
||||
return fmt.Errorf("Unable to close .git/info/sparse-checkout file in tmpBasePath: %w", err)
|
||||
}
|
||||
|
||||
setConfig := func(key, value string) error {
|
||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("config", "--local").AddDynamicArguments(key, value)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("git config [%s -> %q]: %v\n%s\n%s", key, value, err, ctx.outbuf.String(), err.Stderr())
|
||||
return fmt.Errorf("git config [%s -> %q]: %w\n%s\n%s", key, value, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Switch off LFS process (set required, clean and smudge here also)
|
||||
if err := setConfig("filter.lfs.process", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setConfig("filter.lfs.required", "false"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setConfig("filter.lfs.clean", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setConfig("filter.lfs.smudge", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := setConfig("core.sparseCheckout", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read base branch index
|
||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("read-tree", "HEAD")).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("git read-tree HEAD: %v\n%s\n%s", err, ctx.outbuf.String(), err.Stderr())
|
||||
return fmt.Errorf("Unable to read base branch in to the index: %w\n%s\n%s", err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDiffTree returns a string containing all the files that were changed between headBranch and baseBranch
|
||||
// the filenames are escaped so as to fit the format required for .git/info/sparse-checkout
|
||||
func getDiffTree(ctx context.Context, repoPath, baseBranch, headBranch string, out io.Writer) error {
|
||||
cmd := gitcmd.NewCommand("diff-tree", "--no-commit-id", "--name-only", "-r", "-r", "-z", "--root")
|
||||
diffOutReader, diffOutReaderClose := cmd.MakeStdoutPipe()
|
||||
defer diffOutReaderClose()
|
||||
err := cmd.AddDynamicArguments(baseBranch, headBranch).
|
||||
WithDir(repoPath).
|
||||
WithPipelineFunc(func(ctx gitcmd.Context) error {
|
||||
// Now scan the output from the command
|
||||
scanner := bufio.NewScanner(diffOutReader)
|
||||
scanner.Split(util.BufioScannerSplit(0))
|
||||
for scanner.Scan() {
|
||||
treePath := scanner.Text()
|
||||
// escape '*', '?', '[', spaces and '!' prefix
|
||||
treePath = escapedSymbols.ReplaceAllString(treePath, `\$1`)
|
||||
// no necessary to escape the first '#' symbol because the first symbol is '/'
|
||||
if _, err := fmt.Fprintf(out, "/%s\n", treePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}).
|
||||
Run(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ErrRebaseConflicts represents an error if rebase fails with a conflict
|
||||
type ErrRebaseConflicts struct {
|
||||
Style repo_model.MergeStyle
|
||||
CommitSHA string
|
||||
StdOut string
|
||||
StdErr string
|
||||
Err error
|
||||
}
|
||||
|
||||
// IsErrRebaseConflicts checks if an error is a ErrRebaseConflicts.
|
||||
func IsErrRebaseConflicts(err error) bool {
|
||||
_, ok := err.(ErrRebaseConflicts)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRebaseConflicts) Error() string {
|
||||
return fmt.Sprintf("Rebase Error: %v: Whilst Rebasing: %s\n%s\n%s", err.Err, err.CommitSHA, err.StdErr, err.StdOut)
|
||||
}
|
||||
|
||||
// rebaseTrackingOnToBase checks out the tracking branch as staging and rebases it on to the base branch
|
||||
// if there is a conflict it will return an ErrRebaseConflicts
|
||||
func rebaseTrackingOnToBase(ctx *mergeContext, mergeStyle repo_model.MergeStyle) error {
|
||||
// Checkout head branch
|
||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout", "-b").AddDynamicArguments(tmpRepoStagingBranch, tmpRepoTrackingBranch)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
return fmt.Errorf("unable to git checkout tracking as staging in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
|
||||
// Rebase before merging
|
||||
cmdRebase := gitcmd.NewCommand("rebase").AddDynamicArguments(tmpRepoBaseBranch)
|
||||
addCommitSigningOptions(cmdRebase, ctx.signKey)
|
||||
if err := ctx.PrepareGitCmd(cmdRebase).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
// Rebase will leave a REBASE_HEAD file in .git if there is a conflict
|
||||
if _, statErr := os.Stat(filepath.Join(ctx.tmpBasePath, ".git", "REBASE_HEAD")); statErr == nil {
|
||||
var commitSha string
|
||||
ok := false
|
||||
failingCommitPaths := []string{
|
||||
filepath.Join(ctx.tmpBasePath, ".git", "rebase-apply", "original-commit"), // Git < 2.26
|
||||
filepath.Join(ctx.tmpBasePath, ".git", "rebase-merge", "stopped-sha"), // Git >= 2.26
|
||||
}
|
||||
for _, failingCommitPath := range failingCommitPaths {
|
||||
if _, statErr := os.Stat(failingCommitPath); statErr == nil {
|
||||
commitShaBytes, readErr := os.ReadFile(failingCommitPath)
|
||||
if readErr != nil {
|
||||
// Abandon this attempt to handle the error
|
||||
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
commitSha = strings.TrimSpace(string(commitShaBytes))
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
log.Error("Unable to determine failing commit sha for failing rebase in temp repo for %-v. Cannot cast as ErrRebaseConflicts.", ctx.pr)
|
||||
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
log.Debug("Conflict when rebasing staging on to base in %-v at %s: %v\n%s\n%s", ctx.pr, commitSha, err, ctx.outbuf.String(), err.Stderr())
|
||||
return ErrRebaseConflicts{
|
||||
CommitSHA: commitSha,
|
||||
Style: mergeStyle,
|
||||
StdOut: ctx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unable to git rebase staging on to base in temp repo for %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// getRebaseAmendMessage composes the message to amend commits in rebase merge of a pull request.
|
||||
func getRebaseAmendMessage(ctx *mergeContext, baseGitRepo *git.Repository) (message string, err error) {
|
||||
// Get existing commit message.
|
||||
commitMessage, _, err := gitcmd.NewCommand("show", "--format=%B", "-s").WithDir(ctx.tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
commitTitle, commitBody, _ := strings.Cut(commitMessage, "\n")
|
||||
extraVars := map[string]string{"CommitTitle": strings.TrimSpace(commitTitle), "CommitBody": strings.TrimSpace(commitBody)}
|
||||
|
||||
message, body, err := getMergeMessage(ctx, baseGitRepo, ctx.pr, repo_model.MergeStyleRebase, extraVars)
|
||||
if err != nil || message == "" {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(body) > 0 {
|
||||
message = message + "\n\n" + body
|
||||
}
|
||||
return message, err
|
||||
}
|
||||
|
||||
// Perform rebase merge without merge commit.
|
||||
func doMergeRebaseFastForward(ctx *mergeContext) error {
|
||||
baseHeadSHA, err := git.GetFullCommitID(ctx, ctx.tmpBasePath, "HEAD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
||||
}
|
||||
|
||||
cmd := gitcmd.NewCommand("merge", "--ff-only").AddDynamicArguments(tmpRepoStagingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleRebase, cmd); err != nil {
|
||||
log.Error("Unable to merge staging into base: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if anything actually changed before we amend the message, fast forward can skip commits.
|
||||
newMergeHeadSHA, err := git.GetFullCommitID(ctx, ctx.tmpBasePath, "HEAD")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to get full commit id for HEAD: %w", err)
|
||||
}
|
||||
if baseHeadSHA == newMergeHeadSHA {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Original repo to read template from.
|
||||
baseGitRepo, err := gitrepo.OpenRepository(ctx, ctx.pr.BaseRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to get Git repo for rebase: %v", err)
|
||||
return err
|
||||
}
|
||||
defer baseGitRepo.Close()
|
||||
|
||||
// Amend last commit message based on template, if one exists
|
||||
newMessage, err := getRebaseAmendMessage(ctx, baseGitRepo)
|
||||
if err != nil {
|
||||
log.Error("Unable to get commit message for amend: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if newMessage != "" {
|
||||
cmdCommit := gitcmd.NewCommand("commit", "--amend").
|
||||
AddOptionFormat("--message=%s", newMessage)
|
||||
addCommitSigningOptions(cmdCommit, ctx.signKey)
|
||||
if err := cmdCommit.WithDir(ctx.tmpBasePath).Run(ctx); err != nil {
|
||||
log.Error("Unable to amend commit message: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform rebase merge with merge commit.
|
||||
func doMergeRebaseMergeCommit(ctx *mergeContext, message string) error {
|
||||
cmd := gitcmd.NewCommand("merge").AddArguments("--no-ff", "--no-commit").AddDynamicArguments(tmpRepoStagingBranch)
|
||||
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleRebaseMerge, cmd); err != nil {
|
||||
log.Error("Unable to merge staging into base: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := commitAndSignNoAuthor(ctx, message); err != nil {
|
||||
log.Error("Unable to make final commit: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// doMergeStyleRebase rebases the tracking branch on the base branch as the current HEAD with or with a merge commit to the original pr branch
|
||||
func doMergeStyleRebase(ctx *mergeContext, mergeStyle repo_model.MergeStyle, message string) error {
|
||||
if err := rebaseTrackingOnToBase(ctx, mergeStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Checkout base branch again
|
||||
if err := ctx.PrepareGitCmd(gitcmd.NewCommand("checkout").AddDynamicArguments(tmpRepoBaseBranch)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("git checkout base prior to merge post staging rebase %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return fmt.Errorf("git checkout base prior to merge post staging rebase %v: %w\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
if mergeStyle == repo_model.MergeStyleRebase {
|
||||
return doMergeRebaseFastForward(ctx)
|
||||
}
|
||||
|
||||
return doMergeRebaseMergeCommit(ctx, message)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// doMergeStyleSquash gets a commit author signature for squash commits
|
||||
func getAuthorSignatureSquash(ctx *mergeContext) (*git.Signature, error) {
|
||||
if err := ctx.pr.Issue.LoadPoster(ctx); err != nil {
|
||||
log.Error("%-v Issue[%d].LoadPoster: %v", ctx.pr, ctx.pr.Issue.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to get an signature from the same user in one of the commits, as the
|
||||
// poster email might be private or commits might have a different signature
|
||||
// than the primary email address of the poster.
|
||||
gitRepo, err := git.OpenRepository(ctx, ctx.tmpBasePath)
|
||||
if err != nil {
|
||||
log.Error("%-v Unable to open base repository: %v", ctx.pr, err)
|
||||
return nil, err
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commits, err := gitRepo.CommitsBetweenIDs(tmpRepoTrackingBranch, "HEAD")
|
||||
if err != nil {
|
||||
log.Error("%-v Unable to get commits between: %s %s: %v", ctx.pr, "HEAD", tmpRepoTrackingBranch, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniqueEmails := make(container.Set[string])
|
||||
for _, commit := range commits {
|
||||
if commit.Author != nil && uniqueEmails.Add(commit.Author.Email) {
|
||||
commitUser, _ := user_model.GetUserByEmail(ctx, commit.Author.Email)
|
||||
if commitUser != nil && commitUser.ID == ctx.pr.Issue.Poster.ID {
|
||||
return commit.Author, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.pr.Issue.Poster.NewGitSig(), nil
|
||||
}
|
||||
|
||||
// doMergeStyleSquash squashes the tracking branch on the current HEAD (=base)
|
||||
func doMergeStyleSquash(ctx *mergeContext, message string) error {
|
||||
sig, err := getAuthorSignatureSquash(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAuthorSignatureSquash: %w", err)
|
||||
}
|
||||
|
||||
cmdMerge := gitcmd.NewCommand("merge", "--squash").AddDynamicArguments(tmpRepoTrackingBranch)
|
||||
if err := runMergeCommand(ctx, repo_model.MergeStyleSquash, cmdMerge); err != nil {
|
||||
log.Error("%-v Unable to merge --squash tracking into base: %v", ctx.pr, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.Repository.PullRequest.AddCoCommitterTrailers && ctx.committer.String() != sig.String() {
|
||||
// add trailer
|
||||
message = AddCommitMessageTailer(message, "Co-authored-by", sig.String())
|
||||
message = AddCommitMessageTailer(message, "Co-committed-by", sig.String()) // FIXME: this one should be removed, it is not really used or widely used
|
||||
}
|
||||
cmdCommit := gitcmd.NewCommand("commit").
|
||||
AddOptionFormat("--author='%s <%s>'", sig.Name, sig.Email).
|
||||
AddOptionFormat("--message=%s", message).
|
||||
AddArguments("--allow-empty")
|
||||
addCommitSigningOptions(cmdCommit, ctx.signKey)
|
||||
if err := ctx.PrepareGitCmd(cmdCommit).RunWithStderr(ctx); err != nil {
|
||||
log.Error("git commit %-v: %v\n%s\n%s", ctx.pr, err, ctx.outbuf.String(), err.Stderr())
|
||||
return fmt.Errorf("git commit [%s:%s -> %s:%s]: %w\n%s\n%s", ctx.pr.HeadRepo.FullName(), ctx.pr.HeadBranch, ctx.pr.BaseRepo.FullName(), ctx.pr.BaseBranch, err, ctx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
ctx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_expandDefaultMergeMessage(t *testing.T) {
|
||||
type args struct {
|
||||
template string
|
||||
vars map[string]string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "single line",
|
||||
args: args{
|
||||
template: "Merge ${PullRequestTitle}",
|
||||
vars: map[string]string{
|
||||
"PullRequestTitle": "PullRequestTitle",
|
||||
"PullRequestDescription": "Pull\nRequest\nDescription\n",
|
||||
},
|
||||
},
|
||||
want: "Merge PullRequestTitle",
|
||||
wantBody: "",
|
||||
},
|
||||
{
|
||||
name: "multiple lines",
|
||||
args: args{
|
||||
template: "Merge ${PullRequestTitle}\nDescription:\n\n${PullRequestDescription}\n",
|
||||
vars: map[string]string{
|
||||
"PullRequestTitle": "PullRequestTitle",
|
||||
"PullRequestDescription": "Pull\nRequest\nDescription\n",
|
||||
},
|
||||
},
|
||||
want: "Merge PullRequestTitle",
|
||||
wantBody: "Description:\n\nPull\nRequest\nDescription\n",
|
||||
},
|
||||
{
|
||||
name: "leading newlines",
|
||||
args: args{
|
||||
template: "\n\n\nMerge ${PullRequestTitle}\n\n\nDescription:\n\n${PullRequestDescription}\n",
|
||||
vars: map[string]string{
|
||||
"PullRequestTitle": "PullRequestTitle",
|
||||
"PullRequestDescription": "Pull\nRequest\nDescription\n",
|
||||
},
|
||||
},
|
||||
want: "Merge PullRequestTitle",
|
||||
wantBody: "Description:\n\nPull\nRequest\nDescription\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, got1 := expandDefaultMergeMessage(tt.args.template, tt.args.vars)
|
||||
assert.Equalf(t, tt.want, got, "expandDefaultMergeMessage(%v, %v)", tt.args.template, tt.args.vars)
|
||||
assert.Equalf(t, tt.wantBody, got1, "expandDefaultMergeMessage(%v, %v)", tt.args.template, tt.args.vars)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCommitMessageTailer(t *testing.T) {
|
||||
// add tailer for empty message
|
||||
assert.Equal(t, "\n\nTest-tailer: TestValue", AddCommitMessageTailer("", "Test-tailer", "TestValue"))
|
||||
|
||||
// add tailer for message without newlines
|
||||
assert.Equal(t, "title\n\nTest-tailer: TestValue", AddCommitMessageTailer("title", "Test-tailer", "TestValue"))
|
||||
assert.Equal(t, "title\n\nNot tailer: xxx\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n\nNot tailer: xxx", "Test-tailer", "TestValue"))
|
||||
assert.Equal(t, "title\n\nNotTailer: xxx\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n\nNotTailer: xxx", "Test-tailer", "TestValue"))
|
||||
assert.Equal(t, "title\n\nnot-tailer: xxx\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n\nnot-tailer: xxx", "Test-tailer", "TestValue"))
|
||||
|
||||
// add tailer for message with one EOL
|
||||
assert.Equal(t, "title\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n", "Test-tailer", "TestValue"))
|
||||
|
||||
// add tailer for message with two EOLs
|
||||
assert.Equal(t, "title\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n\n", "Test-tailer", "TestValue"))
|
||||
|
||||
// add tailer for message with existing tailer (won't duplicate)
|
||||
assert.Equal(t, "title\n\nTest-tailer: TestValue", AddCommitMessageTailer("title\n\nTest-tailer: TestValue", "Test-tailer", "TestValue"))
|
||||
assert.Equal(t, "title\n\nTest-tailer: TestValue\n", AddCommitMessageTailer("title\n\nTest-tailer: TestValue\n", "Test-tailer", "TestValue"))
|
||||
|
||||
// add tailer for message with existing tailer and different value (will append)
|
||||
assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1", "Test-tailer", "v2"))
|
||||
assert.Equal(t, "title\n\nTest-tailer: v1\nTest-tailer: v2", AddCommitMessageTailer("title\n\nTest-tailer: v1\n", "Test-tailer", "v2"))
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// checkConflictsMergeTree uses git merge-tree to check for conflicts and if none are found checks if the patch is empty
|
||||
// return true if there are conflicts otherwise return false
|
||||
// pr.Status and pr.ConflictedFiles will be updated as necessary
|
||||
func checkConflictsMergeTree(ctx context.Context, pr *issues_model.PullRequest, baseCommitID string) (bool, error) {
|
||||
treeHash, conflict, conflictFiles, err := gitrepo.MergeTree(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID, pr.MergeBase)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MergeTree: %w", err)
|
||||
}
|
||||
if conflict {
|
||||
pr.Status = issues_model.PullRequestStatusConflict
|
||||
// sometimes git merge-tree will detect conflicts but not list any conflicted files
|
||||
// so that pr.ConflictedFiles will be empty
|
||||
pr.ConflictedFiles = conflictFiles
|
||||
|
||||
log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Detect whether the pull request introduces changes by comparing the merged tree (treeHash)
|
||||
// against the current base commit (baseCommitID) using `git diff-tree`. The command returns exit code 0
|
||||
// if there is no diff between these trees (empty patch) and exit code 1 if there is a diff.
|
||||
gitErr := gitrepo.RunCmd(ctx, pr.BaseRepo, gitcmd.NewCommand("diff-tree", "-r", "--quiet").
|
||||
AddDynamicArguments(treeHash, baseCommitID))
|
||||
switch {
|
||||
case gitErr == nil:
|
||||
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
|
||||
pr.Status = issues_model.PullRequestStatusEmpty
|
||||
case gitcmd.IsErrorExitCode(gitErr, 1):
|
||||
pr.Status = issues_model.PullRequestStatusMergeable
|
||||
default:
|
||||
return false, fmt.Errorf("run diff-tree exit abnormally: %w", gitErr)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func checkPullRequestMergeableByMergeTree(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||
// 1. Get head commit
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
headGitRepo, err := gitrepo.OpenRepository(ctx, pr.HeadRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer headGitRepo.Close()
|
||||
|
||||
// 2. Get/open base repository
|
||||
var baseGitRepo *git.Repository
|
||||
if pr.IsSameRepo() {
|
||||
baseGitRepo = headGitRepo
|
||||
} else {
|
||||
baseGitRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer baseGitRepo.Close()
|
||||
}
|
||||
|
||||
// 3. Get head commit id
|
||||
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||
pr.HeadCommitID, err = headGitRepo.GetRefCommitID(git.BranchPrefix + pr.HeadBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err)
|
||||
}
|
||||
} else {
|
||||
if pr.ID > 0 {
|
||||
pr.HeadCommitID, err = baseGitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetRefCommitID: can't find commit ID for head: %w", err)
|
||||
}
|
||||
} else if pr.HeadCommitID == "" { // for new pull request with agit, the head commit id must be provided
|
||||
return errors.New("head commit ID is empty for pull request Agit flow")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. fetch head commit id into the current repository
|
||||
// it will be checked in 2 weeks by default from git if the pull request created failure.
|
||||
if !pr.IsSameRepo() {
|
||||
if !baseGitRepo.IsReferenceExist(pr.HeadCommitID) {
|
||||
if err := gitrepo.FetchRemoteCommit(ctx, pr.BaseRepo, pr.HeadRepo, pr.HeadCommitID); err != nil {
|
||||
return fmt.Errorf("FetchRemoteCommit: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. update merge base
|
||||
baseCommitID, err := baseGitRepo.GetRefCommitID(git.BranchPrefix + pr.BaseBranch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetBranchCommitID: can't find commit ID for base: %w", err)
|
||||
}
|
||||
|
||||
pr.MergeBase, err = gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommitID, pr.HeadCommitID)
|
||||
if err != nil {
|
||||
// if there is no merge base, then it's empty, still need to allow the pull request to be created
|
||||
// not quite right (e.g.: why not reset the fields like below), but no interest to do more investigation at the moment
|
||||
log.Error("MergeBase: unable to find merge base between %s and %s: %v", baseCommitID, pr.HeadCommitID, err)
|
||||
pr.Status = issues_model.PullRequestStatusEmpty
|
||||
return nil
|
||||
}
|
||||
|
||||
// reset conflicted files and changed protected files
|
||||
pr.ConflictedFiles = nil
|
||||
pr.ChangedProtectedFiles = nil
|
||||
|
||||
// 6. if base == head, then it's an ancestor
|
||||
if pr.HeadCommitID == pr.MergeBase {
|
||||
pr.Status = issues_model.PullRequestStatusAncestor
|
||||
return nil
|
||||
}
|
||||
|
||||
// 7. Check for conflicts
|
||||
conflicted, err := checkConflictsMergeTree(ctx, pr, baseCommitID)
|
||||
if err != nil {
|
||||
log.Error("checkConflictsMergeTree: %v", err)
|
||||
pr.Status = issues_model.PullRequestStatusError
|
||||
return fmt.Errorf("checkConflictsMergeTree: %w", err)
|
||||
}
|
||||
if conflicted || pr.Status == issues_model.PullRequestStatusEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 8. Check for protected files changes
|
||||
if err = checkPullFilesProtection(ctx, pr, baseGitRepo, pr.HeadCommitID); err != nil {
|
||||
return fmt.Errorf("checkPullFilesProtection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func testPullRequestMergeCheck(t *testing.T,
|
||||
targetFunc func(ctx context.Context, pr *issues_model.PullRequest) error,
|
||||
pr *issues_model.PullRequest,
|
||||
expectedStatus issues_model.PullRequestStatus,
|
||||
expectedConflictedFiles []string,
|
||||
expectedChangedProtectedFiles []string,
|
||||
) {
|
||||
assert.NoError(t, pr.LoadIssue(t.Context()))
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
assert.NoError(t, pr.LoadHeadRepo(t.Context()))
|
||||
pr.Status = issues_model.PullRequestStatusChecking
|
||||
pr.ConflictedFiles = []string{"unrelated-conflicted-file"}
|
||||
pr.ChangedProtectedFiles = []string{"unrelated-protected-file"}
|
||||
pr.MergeBase = ""
|
||||
pr.HeadCommitID = ""
|
||||
err := targetFunc(t.Context(), pr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedStatus, pr.Status)
|
||||
assert.Equal(t, expectedConflictedFiles, pr.ConflictedFiles)
|
||||
assert.Equal(t, expectedChangedProtectedFiles, pr.ChangedProtectedFiles)
|
||||
assert.NotEmpty(t, pr.MergeBase)
|
||||
assert.NotEmpty(t, pr.HeadCommitID)
|
||||
}
|
||||
|
||||
func TestPullRequestMergeable(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
t.Run("NoConflict-MergeTree", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusMergeable, nil, nil)
|
||||
})
|
||||
t.Run("NoConflict-TmpRepo", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusMergeable, nil, nil)
|
||||
})
|
||||
|
||||
pr.BaseBranch, pr.HeadBranch = "test-merge-tree-conflict-base", "test-merge-tree-conflict-head"
|
||||
conflictFiles := createConflictBranches(t, pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.HeadBranch)
|
||||
t.Run("Conflict-MergeTree", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusConflict, conflictFiles, nil)
|
||||
})
|
||||
t.Run("Conflict-TmpRepo", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusConflict, conflictFiles, nil)
|
||||
})
|
||||
|
||||
pr.BaseBranch, pr.HeadBranch = "test-merge-tree-empty-base", "test-merge-tree-empty-head"
|
||||
createEmptyBranches(t, pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.HeadBranch)
|
||||
t.Run("Empty-MergeTree", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByMergeTree, pr, issues_model.PullRequestStatusEmpty, nil, nil)
|
||||
})
|
||||
t.Run("Empty-TmpRepo", func(t *testing.T) {
|
||||
testPullRequestMergeCheck(t, checkPullRequestMergeableByTmpRepo, pr, issues_model.PullRequestStatusEmpty, nil, nil)
|
||||
})
|
||||
}
|
||||
|
||||
func createConflictBranches(t *testing.T, repoPath, baseBranch, headBranch string) []string {
|
||||
conflictFile := "conflict.txt"
|
||||
stdin := fmt.Sprintf(
|
||||
`reset refs/heads/%[1]s
|
||||
from refs/heads/master
|
||||
|
||||
commit refs/heads/%[1]s
|
||||
mark :1
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 17
|
||||
add conflict file
|
||||
M 100644 inline %[3]s
|
||||
data 4
|
||||
base
|
||||
|
||||
commit refs/heads/%[1]s
|
||||
mark :2
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 11
|
||||
base change
|
||||
from :1
|
||||
M 100644 inline %[3]s
|
||||
data 11
|
||||
base change
|
||||
|
||||
reset refs/heads/%[2]s
|
||||
from :1
|
||||
|
||||
commit refs/heads/%[2]s
|
||||
mark :3
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 11
|
||||
head change
|
||||
from :1
|
||||
M 100644 inline %[3]s
|
||||
data 11
|
||||
head change
|
||||
`, baseBranch, headBranch, conflictFile)
|
||||
err := gitcmd.NewCommand("fast-import").WithDir(repoPath).WithStdinBytes([]byte(stdin)).RunWithStderr(t.Context())
|
||||
require.NoError(t, err)
|
||||
return []string{conflictFile}
|
||||
}
|
||||
|
||||
func createEmptyBranches(t *testing.T, repoPath, baseBranch, headBranch string) {
|
||||
emptyFile := "empty.txt"
|
||||
stdin := fmt.Sprintf(`reset refs/heads/%[1]s
|
||||
from refs/heads/master
|
||||
|
||||
commit refs/heads/%[1]s
|
||||
mark :1
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 14
|
||||
add empty file
|
||||
M 100644 inline %[3]s
|
||||
data 4
|
||||
base
|
||||
|
||||
reset refs/heads/%[2]s
|
||||
from :1
|
||||
|
||||
commit refs/heads/%[2]s
|
||||
mark :2
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 17
|
||||
change empty file
|
||||
from :1
|
||||
M 100644 inline %[3]s
|
||||
data 6
|
||||
change
|
||||
|
||||
commit refs/heads/%[2]s
|
||||
mark :3
|
||||
committer Test <test@example.com> 0 +0000
|
||||
data 17
|
||||
revert empty file
|
||||
from :2
|
||||
M 100644 inline %[3]s
|
||||
data 4
|
||||
base
|
||||
`, baseBranch, headBranch, emptyFile)
|
||||
err := gitcmd.NewCommand("fast-import").WithDir(repoPath).WithStdinBytes([]byte(stdin)).RunWithStderr(t.Context())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// DownloadDiffOrPatch will write the patch for the pr to the writer
|
||||
func DownloadDiffOrPatch(ctx context.Context, pr *issues_model.PullRequest, w io.Writer, patch, binary bool) error {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("Unable to load base repository ID %d for pr #%d [%d]", pr.BaseRepoID, pr.Index, pr.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
compareArg := pr.MergeBase + "..." + pr.GetGitHeadRefName()
|
||||
switch {
|
||||
case patch:
|
||||
err = gitRepo.GetPatch(compareArg, w)
|
||||
case binary:
|
||||
err = gitRepo.GetDiffBinary(compareArg, w)
|
||||
default:
|
||||
err = gitRepo.GetDiff(compareArg, w)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("unable to get patch file from %s to %s in %s Error: %v", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err)
|
||||
return fmt.Errorf("unable to get patch file from %s to %s in %s Error: %w", pr.MergeBase, pr.HeadBranch, pr.BaseRepo.FullName(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPullRequestBranchMergeable(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||
ctx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("checkPullRequestBranchMergeable: %s", pr))
|
||||
defer finished()
|
||||
|
||||
if git.DefaultFeatures().SupportGitMergeTree {
|
||||
return checkPullRequestMergeableByMergeTree(ctx, pr)
|
||||
}
|
||||
|
||||
return checkPullRequestMergeableByTmpRepo(ctx, pr)
|
||||
}
|
||||
|
||||
func checkPullRequestMergeableByTmpRepo(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||
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()
|
||||
|
||||
gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
// 1. update merge base
|
||||
pr.MergeBase, _, err = gitcmd.NewCommand("merge-base", "--", tmpRepoBaseBranch, tmpRepoTrackingBranch).WithDir(prCtx.tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
var err2 error
|
||||
pr.MergeBase, err2 = gitRepo.GetRefCommitID(git.BranchPrefix + tmpRepoBaseBranch)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("GetMergeBase: %v and can't find commit ID for base: %w", err, err2)
|
||||
}
|
||||
}
|
||||
pr.MergeBase = strings.TrimSpace(pr.MergeBase)
|
||||
if pr.HeadCommitID, err = gitRepo.GetRefCommitID(git.BranchPrefix + tmpRepoTrackingBranch); err != nil {
|
||||
return fmt.Errorf("GetBranchCommitID: can't find commit ID for head: %w", err)
|
||||
}
|
||||
|
||||
if pr.HeadCommitID == pr.MergeBase {
|
||||
pr.Status = issues_model.PullRequestStatusAncestor
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2. Check for conflicts
|
||||
conflicts, err := checkConflictsByTmpRepo(ctx, pr, gitRepo, prCtx.tmpBasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pr.ChangedProtectedFiles = nil
|
||||
if conflicts || pr.Status == issues_model.PullRequestStatusEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3. Check for protected files changes
|
||||
if err = checkPullFilesProtection(ctx, pr, gitRepo, tmpRepoTrackingBranch); err != nil {
|
||||
return fmt.Errorf("pr.CheckPullFilesProtection(): %w", err)
|
||||
}
|
||||
|
||||
pr.Status = issues_model.PullRequestStatusMergeable
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type errMergeConflict struct {
|
||||
filename string
|
||||
}
|
||||
|
||||
func (e *errMergeConflict) Error() string {
|
||||
return "conflict detected at: " + e.filename
|
||||
}
|
||||
|
||||
func attemptMerge(ctx context.Context, file *unmergedFile, tmpBasePath string, filesToRemove *[]string, filesToAdd *[]git.IndexObjectInfo) error {
|
||||
log.Trace("Attempt to merge:\n%v", file)
|
||||
|
||||
switch {
|
||||
case file.stage1 != nil && (file.stage2 == nil || file.stage3 == nil):
|
||||
// 1. Deleted in one or both:
|
||||
//
|
||||
// Conflict <==> the stage1 !SameAs to the undeleted one
|
||||
if (file.stage2 != nil && !file.stage1.SameAs(file.stage2)) || (file.stage3 != nil && !file.stage1.SameAs(file.stage3)) {
|
||||
// Conflict!
|
||||
return &errMergeConflict{file.stage1.path}
|
||||
}
|
||||
|
||||
// Not a genuine conflict and we can simply remove the file from the index
|
||||
*filesToRemove = append(*filesToRemove, file.stage1.path)
|
||||
return nil
|
||||
case file.stage1 == nil && file.stage2 != nil && (file.stage3 == nil || file.stage2.SameAs(file.stage3)):
|
||||
// 2. Added in ours but not in theirs or identical in both
|
||||
//
|
||||
// Not a genuine conflict just add to the index
|
||||
*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage2.mode, Object: git.MustIDFromString(file.stage2.sha), Filename: file.stage2.path})
|
||||
return nil
|
||||
case file.stage1 == nil && file.stage2 != nil && file.stage3 != nil && file.stage2.sha == file.stage3.sha && file.stage2.mode != file.stage3.mode:
|
||||
// 3. Added in both with the same sha but the modes are different
|
||||
//
|
||||
// Conflict! (Not sure that this can actually happen but we should handle)
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
case file.stage1 == nil && file.stage2 == nil && file.stage3 != nil:
|
||||
// 4. Added in theirs but not ours:
|
||||
//
|
||||
// Not a genuine conflict just add to the index
|
||||
*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage3.mode, Object: git.MustIDFromString(file.stage3.sha), Filename: file.stage3.path})
|
||||
return nil
|
||||
case file.stage1 == nil:
|
||||
// 5. Created by new in both
|
||||
//
|
||||
// Conflict!
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
case file.stage2 != nil && file.stage3 != nil:
|
||||
// 5. Modified in both - we should try to merge in the changes but first:
|
||||
//
|
||||
if file.stage2.mode == "120000" || file.stage3.mode == "120000" {
|
||||
// 5a. Conflicting symbolic link change
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
}
|
||||
if file.stage2.mode == "160000" || file.stage3.mode == "160000" {
|
||||
// 5b. Conflicting submodule change
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
}
|
||||
if file.stage2.mode != file.stage3.mode {
|
||||
// 5c. Conflicting mode change
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
}
|
||||
|
||||
// Need to get the objects from the object db to attempt to merge
|
||||
root, _, err := gitcmd.NewCommand("unpack-file").AddDynamicArguments(file.stage1.sha).WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get root object: %s at path: %s for merging. Error: %w", file.stage1.sha, file.stage1.path, err)
|
||||
}
|
||||
root = strings.TrimSpace(root)
|
||||
defer func() {
|
||||
_ = util.Remove(filepath.Join(tmpBasePath, root))
|
||||
}()
|
||||
|
||||
base, _, err := gitcmd.NewCommand("unpack-file").AddDynamicArguments(file.stage2.sha).WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get base object: %s at path: %s for merging. Error: %w", file.stage2.sha, file.stage2.path, err)
|
||||
}
|
||||
base = strings.TrimSpace(filepath.Join(tmpBasePath, base))
|
||||
defer func() {
|
||||
_ = util.Remove(base)
|
||||
}()
|
||||
head, _, err := gitcmd.NewCommand("unpack-file").AddDynamicArguments(file.stage3.sha).WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get head object:%s at path: %s for merging. Error: %w", file.stage3.sha, file.stage3.path, err)
|
||||
}
|
||||
head = strings.TrimSpace(head)
|
||||
defer func() {
|
||||
_ = util.Remove(filepath.Join(tmpBasePath, head))
|
||||
}()
|
||||
|
||||
// now git merge-file annoyingly takes a different order to the merge-tree ...
|
||||
_, _, conflictErr := gitcmd.NewCommand("merge-file").AddDynamicArguments(base, root, head).WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if conflictErr != nil {
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
}
|
||||
|
||||
// base now contains the merged data
|
||||
hash, _, err := gitcmd.NewCommand("hash-object", "-w", "--path").AddDynamicArguments(file.stage2.path, base).WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash = strings.TrimSpace(hash)
|
||||
*filesToAdd = append(*filesToAdd, git.IndexObjectInfo{Mode: file.stage2.mode, Object: git.MustIDFromString(hash), Filename: file.stage2.path})
|
||||
return nil
|
||||
default:
|
||||
if file.stage1 != nil {
|
||||
return &errMergeConflict{file.stage1.path}
|
||||
} else if file.stage2 != nil {
|
||||
return &errMergeConflict{file.stage2.path}
|
||||
} else if file.stage3 != nil {
|
||||
return &errMergeConflict{file.stage3.path}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttemptThreeWayMerge will attempt to three way merge using git read-tree and then follow the git merge-one-file algorithm to attempt to resolve basic conflicts
|
||||
func AttemptThreeWayMerge(ctx context.Context, gitPath string, gitRepo *git.Repository, base, ours, theirs, description string) (bool, []string, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// First we use read-tree to do a simple three-way merge
|
||||
if err := gitcmd.NewCommand("read-tree", "-m").AddDynamicArguments(base, ours, theirs).WithDir(gitPath).RunWithStderr(ctx); err != nil {
|
||||
log.Error("Unable to run read-tree -m! Error: %v", err)
|
||||
return false, nil, fmt.Errorf("unable to run read-tree -m! Error: %w", err)
|
||||
}
|
||||
|
||||
var filesToRemove []string
|
||||
var filesToAdd []git.IndexObjectInfo
|
||||
|
||||
// Then we use git ls-files -u to list the unmerged files and collate the triples in unmergedfiles
|
||||
unmerged := make(chan *unmergedFile)
|
||||
go unmergedFiles(ctx, gitPath, unmerged)
|
||||
|
||||
defer func() {
|
||||
cancel()
|
||||
for range unmerged {
|
||||
// empty the unmerged channel
|
||||
}
|
||||
}()
|
||||
|
||||
numberOfConflicts := 0
|
||||
conflict := false
|
||||
conflictedFiles := make([]string, 0, 5)
|
||||
|
||||
for file := range unmerged {
|
||||
if file == nil {
|
||||
break
|
||||
}
|
||||
if file.err != nil {
|
||||
cancel()
|
||||
return false, nil, file.err
|
||||
}
|
||||
|
||||
// OK now we have the unmerged file triplet attempt to merge it
|
||||
if err := attemptMerge(ctx, file, gitPath, &filesToRemove, &filesToAdd); err != nil {
|
||||
if conflictErr, ok := err.(*errMergeConflict); ok {
|
||||
log.Trace("Conflict: %s in %s", conflictErr.filename, description)
|
||||
conflict = true
|
||||
if numberOfConflicts < 10 {
|
||||
conflictedFiles = append(conflictedFiles, conflictErr.filename)
|
||||
}
|
||||
numberOfConflicts++
|
||||
continue
|
||||
}
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Add and remove files in one command, as this is slow with many files otherwise
|
||||
if err := gitRepo.RemoveFilesFromIndex(filesToRemove...); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if err := gitRepo.AddObjectsToIndex(filesToAdd...); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
return conflict, conflictedFiles, nil
|
||||
}
|
||||
|
||||
func checkConflictsByTmpRepo(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, tmpBasePath string) (bool, error) {
|
||||
// 1. checkConflictsByTmpRepo resets the conflict status - therefore - reset the conflict status
|
||||
pr.ConflictedFiles = nil
|
||||
|
||||
// 2. AttemptThreeWayMerge first - this is much quicker than plain patch to base
|
||||
description := fmt.Sprintf("PR[%d] %s/%s#%d", pr.ID, pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Index)
|
||||
conflict, conflictFiles, err := AttemptThreeWayMerge(ctx,
|
||||
tmpBasePath, gitRepo, pr.MergeBase, tmpRepoBaseBranch, tmpRepoTrackingBranch, description)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !conflict {
|
||||
// No conflicts detected so we need to check if the patch is empty...
|
||||
// a. Write the newly merged tree and check the new tree-hash
|
||||
var treeHash string
|
||||
treeHash, _, err = gitcmd.NewCommand("write-tree").WithDir(tmpBasePath).RunStdString(ctx)
|
||||
if err != nil {
|
||||
lsfiles, _, _ := gitcmd.NewCommand("ls-files", "-u").WithDir(tmpBasePath).RunStdString(ctx)
|
||||
return false, fmt.Errorf("unable to write unconflicted tree: %w\n`git ls-files -u`:\n%s", err, lsfiles)
|
||||
}
|
||||
treeHash = strings.TrimSpace(treeHash)
|
||||
baseTree, err := gitRepo.GetTree(tmpRepoBaseBranch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// b. compare the new tree-hash with the base tree hash
|
||||
if treeHash == baseTree.ID.String() {
|
||||
log.Debug("PullRequest[%d]: Patch is empty - ignoring", pr.ID)
|
||||
pr.Status = issues_model.PullRequestStatusEmpty
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 3. OK the three-way merge method has detected conflicts
|
||||
pr.Status = issues_model.PullRequestStatusConflict
|
||||
pr.ConflictedFiles = conflictFiles
|
||||
log.Trace("Found %d files conflicted: %v", len(pr.ConflictedFiles), pr.ConflictedFiles)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ErrFilePathProtected represents a "FilePathProtected" kind of error.
|
||||
type ErrFilePathProtected struct {
|
||||
Message string
|
||||
Path string
|
||||
}
|
||||
|
||||
// IsErrFilePathProtected checks if an error is an ErrFilePathProtected.
|
||||
func IsErrFilePathProtected(err error) bool {
|
||||
_, ok := err.(ErrFilePathProtected)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrFilePathProtected) Error() string {
|
||||
if err.Message != "" {
|
||||
return err.Message
|
||||
}
|
||||
return fmt.Sprintf("path is protected and can not be changed [path: %s]", err.Path)
|
||||
}
|
||||
|
||||
func (err ErrFilePathProtected) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// CheckFileProtection check file Protection
|
||||
func CheckFileProtection(repo *git.Repository, branchName, oldCommitID, newCommitID string, patterns []glob.Glob, limit int, env []string) ([]string, error) {
|
||||
if len(patterns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
affectedFiles, err := git.GetAffectedFiles(repo, branchName, oldCommitID, newCommitID, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changedProtectedFiles := make([]string, 0, limit)
|
||||
for _, affectedFile := range affectedFiles {
|
||||
lpath := strings.ToLower(affectedFile)
|
||||
for _, pat := range patterns {
|
||||
if pat.Match(lpath) {
|
||||
changedProtectedFiles = append(changedProtectedFiles, lpath)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(changedProtectedFiles) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(changedProtectedFiles) > 0 {
|
||||
err = ErrFilePathProtected{
|
||||
Path: changedProtectedFiles[0],
|
||||
}
|
||||
}
|
||||
return changedProtectedFiles, err
|
||||
}
|
||||
|
||||
// CheckUnprotectedFiles check if the commit only touches unprotected files
|
||||
func CheckUnprotectedFiles(repo *git.Repository, branchName, oldCommitID, newCommitID string, patterns []glob.Glob, env []string) (bool, error) {
|
||||
if len(patterns) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
affectedFiles, err := git.GetAffectedFiles(repo, branchName, oldCommitID, newCommitID, env)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, affectedFile := range affectedFiles {
|
||||
lpath := strings.ToLower(affectedFile)
|
||||
unprotected := false
|
||||
for _, pat := range patterns {
|
||||
if pat.Match(lpath) {
|
||||
unprotected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !unprotected {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// checkPullFilesProtection check if pr changed protected files and save results
|
||||
func checkPullFilesProtection(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository, headRef string) error {
|
||||
if pr.Status == issues_model.PullRequestStatusEmpty {
|
||||
pr.ChangedProtectedFiles = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pr.BaseRepoID, pr.BaseBranch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pb == nil {
|
||||
pr.ChangedProtectedFiles = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
pr.ChangedProtectedFiles, err = CheckFileProtection(gitRepo, pr.HeadBranch, pr.MergeBase, headRef, pb.GetProtectedFilePatterns(), 10, os.Environ())
|
||||
if err != nil && !IsErrFilePathProtected(err) {
|
||||
return err
|
||||
}
|
||||
if len(pr.ChangedProtectedFiles) > 0 {
|
||||
log.Trace("Found %d protected files changed in PR %s#%d", len(pr.ChangedProtectedFiles), pr.BaseRepo.FullName(), pr.Index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Copyright 2021 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
// lsFileLine is a Quadruplet struct (+error) representing a partially parsed line from ls-files
|
||||
type lsFileLine struct {
|
||||
mode string
|
||||
sha string
|
||||
stage int
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
// SameAs checks if two lsFileLines are referring to the same path, sha and mode (ignoring stage)
|
||||
func (line *lsFileLine) SameAs(other *lsFileLine) bool {
|
||||
if line == nil || other == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if line.err != nil || other.err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return line.mode == other.mode &&
|
||||
line.sha == other.sha &&
|
||||
line.path == other.path
|
||||
}
|
||||
|
||||
// String provides a string representation for logging
|
||||
func (line *lsFileLine) String() string {
|
||||
if line == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if line.err != nil {
|
||||
return fmt.Sprintf("%d %s %s %s %v", line.stage, line.mode, line.path, line.sha, line.err)
|
||||
}
|
||||
return fmt.Sprintf("%d %s %s %s", line.stage, line.mode, line.path, line.sha)
|
||||
}
|
||||
|
||||
// readUnmergedLsFileLines calls git ls-files -u -z and parses the lines into mode-sha-stage-path quadruplets
|
||||
// it will push these to the provided channel closing it at the end
|
||||
func readUnmergedLsFileLines(ctx context.Context, tmpBasePath string, outputChan chan *lsFileLine) {
|
||||
defer func() {
|
||||
// Always close the outputChan at the end of this function
|
||||
close(outputChan)
|
||||
}()
|
||||
|
||||
cmd := gitcmd.NewCommand("ls-files", "-u", "-z")
|
||||
lsFilesReader, lsFilesReaderClose := cmd.MakeStdoutPipe()
|
||||
defer lsFilesReaderClose()
|
||||
err := cmd.WithDir(tmpBasePath).
|
||||
WithPipelineFunc(func(gitcmd.Context) error {
|
||||
bufferedReader := bufio.NewReader(lsFilesReader)
|
||||
|
||||
for {
|
||||
line, err := bufferedReader.ReadString('\000')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
toemit := &lsFileLine{}
|
||||
|
||||
split := strings.SplitN(line, " ", 3)
|
||||
if len(split) < 3 {
|
||||
return fmt.Errorf("malformed line: %s", line)
|
||||
}
|
||||
toemit.mode = split[0]
|
||||
toemit.sha = split[1]
|
||||
|
||||
if len(split[2]) < 4 {
|
||||
return fmt.Errorf("malformed line: %s", line)
|
||||
}
|
||||
|
||||
toemit.stage, err = strconv.Atoi(split[2][0:1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("malformed line: %s", line)
|
||||
}
|
||||
|
||||
toemit.path = split[2][2 : len(split[2])-1]
|
||||
outputChan <- toemit
|
||||
}
|
||||
}).
|
||||
RunWithStderr(ctx)
|
||||
if err != nil {
|
||||
outputChan <- &lsFileLine{err: fmt.Errorf("git ls-files -u -z: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
// unmergedFile is triple (+error) of lsFileLines split into stages 1,2 & 3.
|
||||
type unmergedFile struct {
|
||||
stage1 *lsFileLine
|
||||
stage2 *lsFileLine
|
||||
stage3 *lsFileLine
|
||||
err error
|
||||
}
|
||||
|
||||
// String provides a string representation of the an unmerged file for logging
|
||||
func (u *unmergedFile) String() string {
|
||||
if u == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if u.err != nil {
|
||||
return fmt.Sprintf("error: %v\n%v\n%v\n%v", u.err, u.stage1, u.stage2, u.stage3)
|
||||
}
|
||||
return fmt.Sprintf("%v\n%v\n%v", u.stage1, u.stage2, u.stage3)
|
||||
}
|
||||
|
||||
// unmergedFiles will collate the output from readUnstagedLsFileLines in to file triplets and send them
|
||||
// to the provided channel, closing at the end.
|
||||
func unmergedFiles(ctx context.Context, tmpBasePath string, unmerged chan *unmergedFile) {
|
||||
defer func() {
|
||||
// Always close the channel
|
||||
close(unmerged)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
lsFileLineChan := make(chan *lsFileLine, 10) // give lsFileLineChan a buffer
|
||||
go readUnmergedLsFileLines(ctx, tmpBasePath, lsFileLineChan)
|
||||
defer func() {
|
||||
cancel()
|
||||
for range lsFileLineChan {
|
||||
// empty channel
|
||||
}
|
||||
}()
|
||||
|
||||
next := &unmergedFile{}
|
||||
for line := range lsFileLineChan {
|
||||
log.Trace("Got line: %v Current State:\n%v", line, next)
|
||||
if line.err != nil {
|
||||
log.Error("Unable to run ls-files -u -z! Error: %v", line.err)
|
||||
unmerged <- &unmergedFile{err: fmt.Errorf("unable to run ls-files -u -z! Error: %w", line.err)}
|
||||
return
|
||||
}
|
||||
|
||||
// stages are always emitted 1,2,3 but sometimes 1, 2 or 3 are dropped
|
||||
switch line.stage {
|
||||
case 0:
|
||||
// Should not happen as this represents successfully merged file - we will tolerate and ignore though
|
||||
case 1:
|
||||
if next.stage1 != nil || next.stage2 != nil || next.stage3 != nil {
|
||||
// We need to handle the unstaged file stage1,stage2,stage3
|
||||
unmerged <- next
|
||||
}
|
||||
next = &unmergedFile{stage1: line}
|
||||
case 2:
|
||||
if next.stage3 != nil || next.stage2 != nil || (next.stage1 != nil && next.stage1.path != line.path) {
|
||||
// We need to handle the unstaged file stage1,stage2,stage3
|
||||
unmerged <- next
|
||||
next = &unmergedFile{}
|
||||
}
|
||||
next.stage2 = line
|
||||
case 3:
|
||||
if next.stage3 != nil || (next.stage1 != nil && next.stage1.path != line.path) || (next.stage2 != nil && next.stage2.path != line.path) {
|
||||
// We need to handle the unstaged file stage1,stage2,stage3
|
||||
unmerged <- next
|
||||
next = &unmergedFile{}
|
||||
}
|
||||
next.stage3 = line
|
||||
default:
|
||||
log.Error("Unexpected stage %d for path %s in run ls-files -u -z!", line.stage, line.path)
|
||||
unmerged <- &unmergedFile{err: fmt.Errorf("unexpected stage %d for path %s in git ls-files -u -z", line.stage, line.path)}
|
||||
return
|
||||
}
|
||||
}
|
||||
// We need to handle the unstaged file stage1,stage2,stage3
|
||||
if next.stage1 != nil || next.stage2 != nil || next.stage3 != nil {
|
||||
unmerged <- next
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
)
|
||||
|
||||
func CreateOrUpdateProtectedBranch(ctx context.Context, repo *repo_model.Repository,
|
||||
protectBranch *git_model.ProtectedBranch, whitelistOptions git_model.WhitelistOptions,
|
||||
) error {
|
||||
err := git_model.UpdateProtectBranch(ctx, repo, protectBranch, whitelistOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isPlainRule := !git_model.IsRuleNameSpecial(protectBranch.RuleName)
|
||||
var isBranchExist bool
|
||||
if isPlainRule {
|
||||
isBranchExist, _ = git_model.IsBranchExist(ctx, repo.ID, protectBranch.RuleName)
|
||||
}
|
||||
|
||||
if isBranchExist {
|
||||
if err := CheckPRsForBaseBranch(ctx, repo, protectBranch.RuleName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if !isPlainRule {
|
||||
// FIXME: since we only need to recheck files protected rules, we could improve this
|
||||
matchedBranches, err := git_model.FindAllMatchedBranches(ctx, repo.ID, protectBranch.RuleName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, branchName := range matchedBranches {
|
||||
if err = CheckPRsForBaseBranch(ctx, repo, branchName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TODO TestPullRequest_PushToBaseRepo
|
||||
|
||||
func TestPullRequest_CommitMessageTrailersPattern(t *testing.T) {
|
||||
// Not a valid trailer section
|
||||
assert.False(t, commitMessageTrailersPattern.MatchString(""))
|
||||
assert.False(t, commitMessageTrailersPattern.MatchString("No trailer."))
|
||||
assert.False(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>\nNot a trailer due to following text."))
|
||||
assert.False(t, commitMessageTrailersPattern.MatchString("Message body not correctly separated from trailer section by empty line.\nSigned-off-by: Bob <bob@example.com>"))
|
||||
// Valid trailer section
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Signed-off-by: Bob <bob@example.com>\nOther-Trailer: Value"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Message body correctly separated from trailer section by empty line.\n\nSigned-off-by: Bob <bob@example.com>"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Multiple trailers.\n\nSigned-off-by: Bob <bob@example.com>\nOther-Trailer: Value"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Newline after trailer section.\n\nSigned-off-by: Bob <bob@example.com>\n"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("No space after colon is accepted.\n\nSigned-off-by:Bob <bob@example.com>"))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Additional whitespace is accepted.\n\nSigned-off-by \t : \tBob <bob@example.com> "))
|
||||
assert.True(t, commitMessageTrailersPattern.MatchString("Folded value.\n\nFolded-trailer: This is\n a folded\n trailer value\nOther-Trailer: Value"))
|
||||
}
|
||||
|
||||
func TestPullRequest_GetDefaultMergeMessage_InternalTracker(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo)
|
||||
assert.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
mergeMessage, _, err := GetDefaultMergeMessage(t.Context(), gitRepo, pr, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Merge pull request 'issue3' (#3) from branch2 into master", mergeMessage)
|
||||
|
||||
pr.BaseRepoID = 1
|
||||
pr.HeadRepoID = 2
|
||||
mergeMessage, _, err = GetDefaultMergeMessage(t.Context(), gitRepo, pr, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Merge pull request 'issue3' (#3) from user2/repo1:branch2 into master", mergeMessage)
|
||||
}
|
||||
|
||||
func TestPullRequest_GetDefaultMergeMessage_ExternalTracker(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
externalTracker := repo_model.RepoUnit{
|
||||
Type: unit.TypeExternalTracker,
|
||||
Config: &repo_model.ExternalTrackerConfig{
|
||||
ExternalTrackerFormat: "https://someurl.com/{user}/{repo}/{issue}",
|
||||
},
|
||||
}
|
||||
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
baseRepo.Units = []*repo_model.RepoUnit{&externalTracker}
|
||||
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2, BaseRepo: baseRepo})
|
||||
|
||||
assert.NoError(t, pr.LoadBaseRepo(t.Context()))
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), pr.BaseRepo)
|
||||
assert.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
mergeMessage, _, err := GetDefaultMergeMessage(t.Context(), gitRepo, pr, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Merge pull request 'issue3' (!3) from branch2 into master", mergeMessage)
|
||||
|
||||
pr.BaseRepoID = 1
|
||||
pr.HeadRepoID = 2
|
||||
pr.BaseRepo = nil
|
||||
pr.HeadRepo = nil
|
||||
mergeMessage, _, err = GetDefaultMergeMessage(t.Context(), gitRepo, pr, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Merge pull request 'issue3' (#3) from user2/repo2:branch2 into master", mergeMessage)
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/gitdiff"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
func isErrBlameNotFoundOrNotEnoughLines(err error) bool {
|
||||
stdErr, ok := gitcmd.ErrorAsStderr(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
notFound := strings.HasPrefix(stdErr, "fatal: no such path")
|
||||
notEnoughLines := strings.HasPrefix(stdErr, "fatal: file ") && strings.Contains(stdErr, " has only ") && strings.Contains(stdErr, " lines?")
|
||||
return notFound || notEnoughLines
|
||||
}
|
||||
|
||||
// ErrDismissRequestOnClosedPR represents an error when a user tries to dismiss a review associated to a closed or merged PR.
|
||||
type ErrDismissRequestOnClosedPR struct{}
|
||||
|
||||
// IsErrDismissRequestOnClosedPR checks if an error is an ErrDismissRequestOnClosedPR.
|
||||
func IsErrDismissRequestOnClosedPR(err error) bool {
|
||||
_, ok := err.(ErrDismissRequestOnClosedPR)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrDismissRequestOnClosedPR) Error() string {
|
||||
return "can't dismiss a review associated to a closed or merged PR"
|
||||
}
|
||||
|
||||
func (err ErrDismissRequestOnClosedPR) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ErrSubmitReviewOnClosedPR represents an error when a user tries to submit an approve or reject review associated to a closed or merged PR.
|
||||
var ErrSubmitReviewOnClosedPR = errors.New("can't submit review for a closed or merged PR")
|
||||
|
||||
// LineBlame returns the latest commit at the given line
|
||||
func lineBlame(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, branch, file string, line uint) (*git.Commit, error) {
|
||||
sha, err := gitrepo.LineBlame(ctx, repo, branch, file, line)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sha) < 40 {
|
||||
return nil, fmt.Errorf("invalid result of blame: %s", sha)
|
||||
}
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(repo.ObjectFormatName)
|
||||
return gitRepo.GetCommit(sha[:objectFormat.FullLength()])
|
||||
}
|
||||
|
||||
// checkInvalidation checks if the line of code comment got changed by another commit.
|
||||
// If the line got changed the comment is going to be invalidated.
|
||||
func checkInvalidation(ctx context.Context, c *issues_model.Comment, repo *repo_model.Repository, gitRepo *git.Repository, branch string) error {
|
||||
// FIXME differentiate between previous and proposed line
|
||||
commit, err := lineBlame(ctx, repo, gitRepo, branch, c.TreePath, uint(c.UnsignedLine()))
|
||||
if isErrBlameNotFoundOrNotEnoughLines(err) {
|
||||
c.Invalidated = true
|
||||
return issues_model.UpdateCommentInvalidate(ctx, c)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() {
|
||||
c.Invalidated = true
|
||||
return issues_model.UpdateCommentInvalidate(ctx, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
|
||||
func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, branch string) error {
|
||||
if len(prs) == 0 {
|
||||
return nil
|
||||
}
|
||||
issueIDs := prs.GetIssueIDs()
|
||||
|
||||
codeComments, err := db.Find[issues_model.Comment](ctx, issues_model.FindCommentsOptions{
|
||||
ListOptions: db.ListOptionsAll,
|
||||
Type: issues_model.CommentTypeCode,
|
||||
Invalidated: optional.Some(false),
|
||||
IssueIDs: issueIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("find code comments: %v", err)
|
||||
}
|
||||
for _, comment := range codeComments {
|
||||
if err := checkInvalidation(ctx, comment, repo, gitRepo, branch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCodeComment creates a comment on the code line
|
||||
func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) {
|
||||
var (
|
||||
existsReview bool
|
||||
err error
|
||||
)
|
||||
|
||||
// CreateCodeComment() is used for:
|
||||
// - Single comments
|
||||
// - Comments that are part of a review
|
||||
// - Comments that reply to an existing review
|
||||
|
||||
if !pendingReview && replyReviewID != 0 {
|
||||
// It's not part of a review; maybe a reply to a review comment or a single comment.
|
||||
// Check if there are reviews for that line already; if there are, this is a reply
|
||||
if existsReview, err = issues_model.ReviewExists(ctx, issue, treePath, line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Comments that are replies don't require a review header to show up in the issue view
|
||||
if !pendingReview && existsReview {
|
||||
if err = issue.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment, err := createCodeComment(ctx,
|
||||
doer,
|
||||
issue.Repo,
|
||||
issue,
|
||||
content,
|
||||
treePath,
|
||||
line,
|
||||
replyReviewID,
|
||||
attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notify_service.CreateIssueComment(ctx, doer, issue.Repo, issue, comment, mentions)
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
review, err := issues_model.GetCurrentReview(ctx, doer, issue)
|
||||
if err != nil {
|
||||
if !issues_model.IsErrReviewNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if review, err = issues_model.CreateReview(ctx, issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypePending,
|
||||
Reviewer: doer,
|
||||
Issue: issue,
|
||||
Official: false,
|
||||
CommitID: latestCommitID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
comment, err := createCodeComment(ctx,
|
||||
doer,
|
||||
issue.Repo,
|
||||
issue,
|
||||
content,
|
||||
treePath,
|
||||
line,
|
||||
review.ID,
|
||||
attachments,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !pendingReview && !existsReview {
|
||||
// Submit the review we've just created so the comment shows up in the issue view
|
||||
if _, _, err = SubmitReview(ctx, doer, gitRepo, issue, issues_model.ReviewTypeComment, "", latestCommitID, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// NOTICE: if it's a pending review the notifications will not be fired until user submit review.
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// createCodeComment creates a plain code comment at the specified line / path
|
||||
func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) {
|
||||
var commitID, patch string
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadPullRequest: %w", err)
|
||||
}
|
||||
pr := issue.PullRequest
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return nil, fmt.Errorf("LoadBaseRepo: %w", err)
|
||||
}
|
||||
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
invalidated := false
|
||||
head := pr.GetGitHeadRefName()
|
||||
if line > 0 {
|
||||
if reviewID != 0 {
|
||||
first, err := issues_model.FindComments(ctx, &issues_model.FindCommentsOptions{
|
||||
ReviewID: reviewID,
|
||||
Line: line,
|
||||
TreePath: treePath,
|
||||
Type: issues_model.CommentTypeCode,
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: 1,
|
||||
Page: 1,
|
||||
},
|
||||
})
|
||||
if err == nil && len(first) > 0 {
|
||||
commitID = first[0].CommitSHA
|
||||
invalidated = first[0].Invalidated
|
||||
patch = first[0].Patch
|
||||
} else if err != nil && !issues_model.IsErrCommentNotExist(err) {
|
||||
return nil, fmt.Errorf("Find first comment for %d line %d path %s. Error: %w", reviewID, line, treePath, err)
|
||||
} else {
|
||||
review, err := issues_model.GetReviewByID(ctx, reviewID)
|
||||
if err == nil && len(review.CommitID) > 0 {
|
||||
head = review.CommitID
|
||||
} else if err != nil && !issues_model.IsErrReviewNotExist(err) {
|
||||
return nil, fmt.Errorf("GetReviewByID %d. Error: %w", reviewID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(commitID) == 0 {
|
||||
// FIXME validate treePath
|
||||
// Get latest commit referencing the commented line
|
||||
// No need for get commit for base branch changes
|
||||
commit, err := lineBlame(ctx, pr.BaseRepo, gitRepo, head, treePath, uint(line))
|
||||
if err == nil {
|
||||
commitID = commit.ID.String()
|
||||
} else if !isErrBlameNotFoundOrNotEnoughLines(err) {
|
||||
return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitHeadRefName(), gitRepo.Path, treePath, line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch diff if comment is review comment
|
||||
if len(patch) == 0 && reviewID != 0 {
|
||||
headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetRefCommitID[%s]: %w", pr.GetGitHeadRefName(), err)
|
||||
}
|
||||
if len(commitID) == 0 {
|
||||
commitID = headCommitID
|
||||
}
|
||||
|
||||
patch, err = git.GetFileDiffCutAroundLine(
|
||||
gitRepo, pr.MergeBase, headCommitID, treePath,
|
||||
int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If patch is still empty (unchanged line), generate code context
|
||||
if patch == "" && commitID != "" {
|
||||
patch, err = gitdiff.GeneratePatchForUnchangedLine(gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines)
|
||||
if err != nil {
|
||||
// Log the error but don't fail comment creation
|
||||
log.Debug("Unable to generate patch for unchanged line (file=%s, line=%d, commit=%s): %v", treePath, line, commitID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Type: issues_model.CommentTypeCode,
|
||||
Doer: doer,
|
||||
Repo: repo,
|
||||
Issue: issue,
|
||||
Content: content,
|
||||
LineNum: line,
|
||||
TreePath: treePath,
|
||||
CommitSHA: commitID,
|
||||
ReviewID: reviewID,
|
||||
Patch: patch,
|
||||
Invalidated: invalidated,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitReview creates a review out of the existing pending review or creates a new one if no pending review exist
|
||||
func SubmitReview(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, reviewType issues_model.ReviewType, content, commitID string, attachmentUUIDs []string) (*issues_model.Review, *issues_model.Comment, error) {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pr := issue.PullRequest
|
||||
var stale bool
|
||||
if reviewType != issues_model.ReviewTypeApprove && reviewType != issues_model.ReviewTypeReject {
|
||||
stale = false
|
||||
} else {
|
||||
if issue.IsClosed {
|
||||
return nil, nil, ErrSubmitReviewOnClosedPR
|
||||
}
|
||||
|
||||
headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if headCommitID == commitID {
|
||||
stale = false
|
||||
} else {
|
||||
stale, _, err = checkIfPRContentChanged(ctx, pr, commitID, headCommitID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
review, comm, err := issues_model.SubmitReview(ctx, doer, issue, reviewType, content, commitID, stale, attachmentUUIDs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comm.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
notify_service.PullRequestReview(ctx, pr, review, comm, mentions)
|
||||
|
||||
for _, lines := range review.CodeComments {
|
||||
for _, comments := range lines {
|
||||
for _, codeComment := range comments {
|
||||
mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, codeComment.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
notify_service.PullRequestCodeComment(ctx, pr, codeComment, mentions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return review, comm, nil
|
||||
}
|
||||
|
||||
// DismissApprovalReviews dismiss all approval reviews because of new commits
|
||||
func DismissApprovalReviews(ctx context.Context, doer *user_model.User, pull *issues_model.PullRequest) error {
|
||||
reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
|
||||
ListOptions: db.ListOptionsAll,
|
||||
IssueID: pull.IssueID,
|
||||
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove},
|
||||
Dismissed: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := reviews.LoadIssues(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
for _, review := range reviews {
|
||||
if err := issues_model.DismissReview(ctx, review, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comment, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Doer: doer,
|
||||
Content: "New commits pushed, approval review dismissed automatically according to repository settings",
|
||||
Type: issues_model.CommentTypeDismissReview,
|
||||
ReviewID: review.ID,
|
||||
Issue: review.Issue,
|
||||
Repo: review.Issue.Repo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comment.Review = review
|
||||
comment.Poster = doer
|
||||
comment.Issue = review.Issue
|
||||
|
||||
notify_service.PullReviewDismiss(ctx, doer, review, comment)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DismissReview dismissing stale review by repo admin
|
||||
func DismissReview(ctx context.Context, reviewID, repoID int64, message string, doer *user_model.User, isDismiss, dismissPriors bool) (comment *issues_model.Comment, err error) {
|
||||
review, err := issues_model.GetReviewByID(ctx, reviewID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject {
|
||||
return nil, errors.New("not need to dismiss this review because it's type is not Approve or change request")
|
||||
}
|
||||
|
||||
// load data for notify
|
||||
if err := review.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if the review's repoID is the one we're currently expecting.
|
||||
if review.Issue.RepoID != repoID {
|
||||
return nil, errors.New("reviews's repository is not the same as the one we expect")
|
||||
}
|
||||
|
||||
issue := review.Issue
|
||||
|
||||
if issue.IsClosed {
|
||||
return nil, ErrDismissRequestOnClosedPR{}
|
||||
}
|
||||
|
||||
if issue.IsPull {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if issue.PullRequest.HasMerged {
|
||||
return nil, ErrDismissRequestOnClosedPR{}
|
||||
}
|
||||
}
|
||||
|
||||
if err := issues_model.DismissReview(ctx, review, isDismiss); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dismissPriors {
|
||||
reviews, err := issues_model.FindReviews(ctx, issues_model.FindReviewOptions{
|
||||
IssueID: review.IssueID,
|
||||
ReviewerID: review.ReviewerID,
|
||||
Dismissed: optional.Some(false),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, oldReview := range reviews {
|
||||
if err = issues_model.DismissReview(ctx, oldReview, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isDismiss {
|
||||
return nil, nil //nolint:nilnil // return nil because this is not a dismiss action
|
||||
}
|
||||
|
||||
if err := review.Issue.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
|
||||
Doer: doer,
|
||||
Content: message,
|
||||
Type: issues_model.CommentTypeDismissReview,
|
||||
ReviewID: review.ID,
|
||||
Issue: review.Issue,
|
||||
Repo: review.Issue.Repo,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment.Review = review
|
||||
comment.Poster = doer
|
||||
comment.Issue = review.Issue
|
||||
|
||||
notify_service.PullReviewDismiss(ctx, doer, review, comment)
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDismissReview(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{})
|
||||
assert.NoError(t, pull.LoadIssue(t.Context()))
|
||||
issue := pull.Issue
|
||||
assert.NoError(t, issue.LoadRepo(t.Context()))
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
Type: issues_model.ReviewTypeReject,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
issue.IsClosed = true
|
||||
pull.HasMerged = false
|
||||
assert.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "is_closed"))
|
||||
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
|
||||
_, err = pull_service.DismissReview(t.Context(), review.ID, issue.RepoID, "", &user_model.User{}, false, false)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err))
|
||||
|
||||
pull.HasMerged = true
|
||||
pull.Issue.IsClosed = false
|
||||
assert.NoError(t, issues_model.UpdateIssueCols(t.Context(), issue, "is_closed"))
|
||||
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
|
||||
_, err = pull_service.DismissReview(t.Context(), review.ID, issue.RepoID, "", &user_model.User{}, false, false)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, pull_service.IsErrDismissRequestOnClosedPR(err))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// GetReviewers get all users can be requested to review:
|
||||
// - Poster should not be listed
|
||||
// - For collaborator, all users that have read access or higher to the repository.
|
||||
// - For repository under organization, users under the teams which have read permission or higher of pull request unit
|
||||
// - Owner will be listed if it's not an organization, not the poster and not in the list of reviewers
|
||||
func GetReviewers(ctx context.Context, repo *repo_model.Repository, doerID, posterID int64) ([]*user_model.User, error) {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e := db.GetEngine(ctx)
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
|
||||
collaboratorIDs := make([]int64, 0, 10)
|
||||
if err := e.Table("collaboration").Where("repo_id=?", repo.ID).
|
||||
And("mode >= ?", perm.AccessModeRead).
|
||||
Select("user_id").
|
||||
Find(&collaboratorIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uniqueUserIDs.AddMultiple(collaboratorIDs...)
|
||||
|
||||
if repo.Owner.IsOrganization() {
|
||||
additionalUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uniqueUserIDs.AddMultiple(additionalUserIDs...)
|
||||
}
|
||||
|
||||
uniqueUserIDs.Remove(posterID) // posterID should not be in the list of reviewers
|
||||
|
||||
// Leave a seat for owner itself to append later, but if owner is an organization
|
||||
// and just waste 1 unit is cheaper than re-allocate memory once.
|
||||
users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
|
||||
if len(uniqueUserIDs) > 0 {
|
||||
if err := e.In("id", uniqueUserIDs.Values()).
|
||||
Where(builder.Eq{"`user`.is_active": true}).
|
||||
OrderBy(user_model.GetOrderByName()).
|
||||
Find(&users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// add owner after all users are loaded because we can avoid load owner twice
|
||||
if repo.OwnerID != posterID && !repo.Owner.IsOrganization() && !uniqueUserIDs.Contains(repo.OwnerID) {
|
||||
users = append(users, repo.Owner)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetReviewerTeams get all teams can be requested to review
|
||||
func GetReviewerTeams(ctx context.Context, repo *repo_model.Repository) ([]*organization.Team, error) {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !repo.Owner.IsOrganization() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypePullRequests)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
pull_service "gitea.dev/services/pull"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRepoGetReviewers(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// test public repo
|
||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
ctx := t.Context()
|
||||
reviewers, err := pull_service.GetReviewers(ctx, repo1, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
if assert.Len(t, reviewers, 1) {
|
||||
assert.ElementsMatch(t, []int64{2}, []int64{reviewers[0].ID})
|
||||
}
|
||||
|
||||
// should not include doer and remove the poster
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo1, 11, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, reviewers)
|
||||
|
||||
// should not include PR poster, if PR poster would be otherwise eligible
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo1, 11, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
|
||||
// test private user repo
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo2, 2, 4)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
assert.EqualValues(t, 2, reviewers[0].ID)
|
||||
|
||||
// test private org repo
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo3, 2, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 2)
|
||||
|
||||
reviewers, err = pull_service.GetReviewers(ctx, repo3, 2, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, reviewers, 1)
|
||||
}
|
||||
|
||||
func TestRepoGetReviewerTeams(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
|
||||
teams, err := pull_service.GetReviewerTeams(t.Context(), repo2)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, teams)
|
||||
|
||||
repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
|
||||
teams, err = pull_service.GetReviewerTeams(t.Context(), repo3)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, teams, 2)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2019 The Gitea Authors.
|
||||
// All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
)
|
||||
|
||||
// Temporary repos created here use standard branch names to help simplify
|
||||
// merging code
|
||||
const (
|
||||
tmpRepoBaseBranch = "base" // equivalent to pr.BaseBranch
|
||||
tmpRepoTrackingBranch = "tracking" // equivalent to pr.HeadBranch
|
||||
tmpRepoStagingBranch = "staging" // this is used for a working branch
|
||||
)
|
||||
|
||||
type prTmpRepoContext struct {
|
||||
context.Context
|
||||
tmpBasePath string
|
||||
pr *issues_model.PullRequest
|
||||
outbuf *bytes.Buffer // we keep these around to help reduce needless buffer recreation, any use should be preceded by a Reset and preferably after use
|
||||
}
|
||||
|
||||
// PrepareGitCmd prepares a git command with the correct directory, environment, and output buffers
|
||||
// This function can only be called with gitcmd.Run()
|
||||
// Do NOT use it with gitcmd.RunStd*() functions, otherwise it will panic
|
||||
func (ctx *prTmpRepoContext) PrepareGitCmd(cmd *gitcmd.Command) *gitcmd.Command {
|
||||
ctx.outbuf.Reset()
|
||||
return cmd.WithDir(ctx.tmpBasePath).WithStdoutBuffer(ctx.outbuf)
|
||||
}
|
||||
|
||||
// createTemporaryRepoForPR creates a temporary repo with "base" for pr.BaseBranch and "tracking" for pr.HeadBranch
|
||||
// it also create a second base branch called "original_base"
|
||||
func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest) (prCtx *prTmpRepoContext, cancel context.CancelFunc, err error) {
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
log.Error("%-v LoadHeadRepo: %v", pr, err)
|
||||
return nil, nil, fmt.Errorf("%v LoadHeadRepo: %w", pr, err)
|
||||
} else if pr.HeadRepo == nil {
|
||||
log.Error("%-v HeadRepo %d does not exist", pr, pr.HeadRepoID)
|
||||
return nil, nil, &repo_model.ErrRepoNotExist{
|
||||
ID: pr.HeadRepoID,
|
||||
}
|
||||
} else if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("%-v LoadBaseRepo: %v", pr, err)
|
||||
return nil, nil, fmt.Errorf("%v LoadBaseRepo: %w", pr, err)
|
||||
} else if pr.BaseRepo == nil {
|
||||
log.Error("%-v BaseRepo %d does not exist", pr, pr.BaseRepoID)
|
||||
return nil, nil, &repo_model.ErrRepoNotExist{
|
||||
ID: pr.BaseRepoID,
|
||||
}
|
||||
} else if err := pr.HeadRepo.LoadOwner(ctx); err != nil {
|
||||
log.Error("%-v HeadRepo.LoadOwner: %v", pr, err)
|
||||
return nil, nil, fmt.Errorf("%v HeadRepo.LoadOwner: %w", pr, err)
|
||||
} else if err := pr.BaseRepo.LoadOwner(ctx); err != nil {
|
||||
log.Error("%-v BaseRepo.LoadOwner: %v", pr, err)
|
||||
return nil, nil, fmt.Errorf("%v BaseRepo.LoadOwner: %w", pr, err)
|
||||
}
|
||||
|
||||
// Clone base repo.
|
||||
tmpBasePath, cleanup, err := repo_module.CreateTemporaryPath("pull")
|
||||
if err != nil {
|
||||
log.Error("CreateTemporaryPath[%-v]: %v", pr, err)
|
||||
return nil, nil, err
|
||||
}
|
||||
cancel = cleanup
|
||||
|
||||
prCtx = &prTmpRepoContext{
|
||||
Context: ctx,
|
||||
tmpBasePath: tmpBasePath,
|
||||
pr: pr,
|
||||
outbuf: &bytes.Buffer{},
|
||||
}
|
||||
|
||||
baseRepoPath := pr.BaseRepo.RepoPath()
|
||||
headRepoPath := pr.HeadRepo.RepoPath()
|
||||
|
||||
if err := git.InitRepository(ctx, tmpBasePath, false, pr.BaseRepo.ObjectFormatName); err != nil {
|
||||
log.Error("Unable to init tmpBasePath for %-v: %v", pr, err)
|
||||
cancel()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
remoteRepoName := "head_repo"
|
||||
|
||||
fetchArgs := gitcmd.TrustedCmdArgs{"--no-tags"}
|
||||
if git.DefaultFeatures().CheckVersionAtLeast("2.25.0") {
|
||||
// Writing the commit graph can be slow and is not needed here
|
||||
fetchArgs = append(fetchArgs, "--no-write-commit-graph")
|
||||
}
|
||||
|
||||
// addCacheRepo adds git alternatives for the cacheRepoPath in the repoPath
|
||||
addCacheRepo := func(repoPath, cacheRepoPath string) error {
|
||||
p := filepath.Join(repoPath, ".git", "objects", "info", "alternates")
|
||||
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
log.Error("Could not create .git/objects/info/alternates file in %s: %v", repoPath, err)
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
data := filepath.Join(cacheRepoPath, "objects")
|
||||
if _, err := fmt.Fprintln(f, data); err != nil {
|
||||
log.Error("Could not write to .git/objects/info/alternates file in %s: %v", repoPath, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add head repo remote.
|
||||
if err := addCacheRepo(tmpBasePath, baseRepoPath); err != nil {
|
||||
log.Error("%-v Unable to add base repository to temporary repo [%s -> %s]: %v", pr, pr.BaseRepo.FullName(), tmpBasePath, err)
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to add base repository to temporary repo [%s -> tmpBasePath]: %w", pr.BaseRepo.FullName(), err)
|
||||
}
|
||||
|
||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("remote", "add", "-t").AddDynamicArguments(pr.BaseBranch).AddArguments("-m").AddDynamicArguments(pr.BaseBranch).AddDynamicArguments("origin", baseRepoPath)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("%-v Unable to add base repository as origin [%s -> %s]: %v\n%s\n%s", pr, pr.BaseRepo.FullName(), tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to add base repository as origin [%s -> tmpBasePath]: %w\n%s\n%s", pr.BaseRepo.FullName(), err, prCtx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
|
||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch", "origin").AddArguments(fetchArgs...).
|
||||
AddDashesAndList(git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+tmpRepoBaseBranch, git.BranchPrefix+pr.BaseBranch+":"+git.BranchPrefix+"original_"+tmpRepoBaseBranch)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("%-v Unable to fetch origin base branch [%s:%s -> base, original_base in %s]: %v:\n%s\n%s", pr, pr.BaseRepo.FullName(), pr.BaseBranch, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to fetch origin base branch [%s:%s -> base, original_base in tmpBasePath]: %w\n%s\n%s", pr.BaseRepo.FullName(), pr.BaseBranch, err, prCtx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
|
||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("symbolic-ref").AddDynamicArguments("HEAD", git.BranchPrefix+tmpRepoBaseBranch)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("%-v Unable to set HEAD as base branch in [%s]: %v\n%s\n%s", pr, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to set HEAD as base branch in tmpBasePath: %w\n%s\n%s", err, prCtx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
|
||||
if err := addCacheRepo(tmpBasePath, headRepoPath); err != nil {
|
||||
log.Error("%-v Unable to add head repository to temporary repo [%s -> %s]: %v", pr, pr.HeadRepo.FullName(), tmpBasePath, err)
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to add head base repository to temporary repo [%s -> tmpBasePath]: %w", pr.HeadRepo.FullName(), err)
|
||||
}
|
||||
|
||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("remote", "add").AddDynamicArguments(remoteRepoName, headRepoPath)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
log.Error("%-v Unable to add head repository as head_repo [%s -> %s]: %v\n%s\n%s", pr, pr.HeadRepo.FullName(), tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||
cancel()
|
||||
return nil, nil, fmt.Errorf("Unable to add head repository as head_repo [%s -> tmpBasePath]: %w\n%s\n%s", pr.HeadRepo.FullName(), err, prCtx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
|
||||
objectFormat := git.ObjectFormatFromName(pr.BaseRepo.ObjectFormatName)
|
||||
// Fetch head branch
|
||||
var headBranch string
|
||||
if pr.Flow == issues_model.PullRequestFlowGithub {
|
||||
headBranch = git.BranchPrefix + pr.HeadBranch
|
||||
} else if len(pr.HeadCommitID) == objectFormat.FullLength() { // for not created pull request
|
||||
headBranch = pr.HeadCommitID
|
||||
} else {
|
||||
headBranch = pr.GetGitHeadRefName()
|
||||
}
|
||||
if err := prCtx.PrepareGitCmd(gitcmd.NewCommand("fetch").AddArguments(fetchArgs...).AddDynamicArguments(remoteRepoName, headBranch+":"+tmpRepoTrackingBranch)).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
cancel()
|
||||
if exist, _ := git_model.IsBranchExist(ctx, pr.HeadRepo.ID, pr.HeadBranch); !exist {
|
||||
return nil, nil, git_model.ErrBranchNotExist{
|
||||
BranchName: pr.HeadBranch,
|
||||
}
|
||||
}
|
||||
log.Error("%-v Unable to fetch head_repo head branch [%s:%s -> tracking in %s]: %v:\n%s\n%s", pr, pr.HeadRepo.FullName(), pr.HeadBranch, tmpBasePath, err, prCtx.outbuf.String(), err.Stderr())
|
||||
return nil, nil, fmt.Errorf("Unable to fetch head_repo head branch [%s:%s -> tracking in tmpBasePath]: %w\n%s\n%s", pr.HeadRepo.FullName(), headBranch, err, prCtx.outbuf.String(), err.Stderr())
|
||||
}
|
||||
prCtx.outbuf.Reset()
|
||||
return prCtx, cancel, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/repository"
|
||||
)
|
||||
|
||||
// Update updates pull request with base branch.
|
||||
func Update(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User, message string, rebase bool) error {
|
||||
if pr.Flow == issues_model.PullRequestFlowAGit {
|
||||
// TODO: update of agit flow pull request's head branch is unsupported
|
||||
return errors.New("update of agit flow pull request's head branch is unsupported")
|
||||
}
|
||||
|
||||
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(pr.ID))
|
||||
if err != nil {
|
||||
log.Error("lock.Lock(): %v", err)
|
||||
return fmt.Errorf("lock.Lock: %w", err)
|
||||
}
|
||||
defer releaser()
|
||||
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
log.Error("unable to load BaseRepo for %-v during update-by-merge: %v", pr, err)
|
||||
return fmt.Errorf("unable to load BaseRepo for PR[%d] during update-by-merge: %w", pr.ID, err)
|
||||
}
|
||||
|
||||
// TODO: FakePR: if the PR is a fake PR (for example: from Merge Upstream), then no need to check diverging
|
||||
if pr.ID > 0 {
|
||||
diffCount, err := gitrepo.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
return err
|
||||
} else if diffCount.Behind == 0 {
|
||||
return fmt.Errorf("HeadBranch of PR %d is up to date", pr.Index)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pr.LoadHeadRepo(ctx); err != nil {
|
||||
log.Error("unable to load HeadRepo for PR %-v during update-by-merge: %v", pr, err)
|
||||
return fmt.Errorf("unable to load HeadRepo for PR[%d] during update-by-merge: %w", pr.ID, err)
|
||||
}
|
||||
if pr.HeadRepo == nil {
|
||||
// LoadHeadRepo will swallow ErrRepoNotExist so if pr.HeadRepo is still nil recreate the error
|
||||
err := repo_model.ErrRepoNotExist{
|
||||
ID: pr.HeadRepoID,
|
||||
}
|
||||
log.Error("unable to load HeadRepo for PR %-v during update-by-merge: %v", pr, err)
|
||||
return fmt.Errorf("unable to load HeadRepo for PR[%d] during update-by-merge: %w", pr.ID, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// The code is from https://github.com/go-gitea/gitea/pull/9784,
|
||||
// it seems a simple copy-paste from https://github.com/go-gitea/gitea/pull/7082 without a real reason.
|
||||
// TODO: DUPLICATE-PR-TASK: search and see another TODO comment for more details
|
||||
go AddTestPullRequestTask(TestPullRequestOptions{
|
||||
RepoID: pr.BaseRepo.ID,
|
||||
Doer: doer,
|
||||
Branch: pr.BaseBranch,
|
||||
IsSync: false,
|
||||
IsForcePush: false,
|
||||
OldCommitID: "",
|
||||
NewCommitID: "",
|
||||
})
|
||||
}()
|
||||
|
||||
if rebase {
|
||||
return updateHeadByRebaseOnToBase(ctx, pr, doer)
|
||||
}
|
||||
|
||||
// TODO: FakePR: it is somewhat hacky, but it is the only way to "merge" at the moment
|
||||
// ideally in the future the "merge" functions should be refactored to decouple from the PullRequest
|
||||
// now use a fake reverse PR to switch head&base repos/branches
|
||||
reversePR := &issues_model.PullRequest{
|
||||
ID: pr.ID,
|
||||
|
||||
HeadRepoID: pr.BaseRepoID,
|
||||
HeadRepo: pr.BaseRepo,
|
||||
HeadBranch: pr.BaseBranch,
|
||||
|
||||
BaseRepoID: pr.HeadRepoID,
|
||||
BaseRepo: pr.HeadRepo,
|
||||
BaseBranch: pr.HeadBranch,
|
||||
}
|
||||
|
||||
_, err = doMergeAndPush(ctx, reversePR, doer, repo_model.MergeStyleMerge, "", message, repository.PushTriggerPRUpdateWithBase)
|
||||
return err
|
||||
}
|
||||
|
||||
// isUserAllowedToPushOrForcePushInRepoBranch checks whether user is allowed to push or force push in the given repo and branch
|
||||
// it will check both user permission and branch protection rules
|
||||
func isUserAllowedToPushOrForcePushInRepoBranch(ctx context.Context, user *user_model.User, repo *repo_model.Repository, branch string) (pushAllowed, forcePushAllowed bool, err error) {
|
||||
if user == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
// 1. check user push permission on the given repository
|
||||
repoPerm, err := access_model.GetDoerRepoPermission(ctx, repo, user)
|
||||
if err != nil {
|
||||
if repo_model.IsErrUnitTypeNotExist(err) {
|
||||
return false, false, nil
|
||||
}
|
||||
return false, false, err
|
||||
}
|
||||
pushAllowed = repoPerm.CanWrite(unit.TypeCode)
|
||||
forcePushAllowed = pushAllowed
|
||||
|
||||
// 2. check branch protection whether user can push or force push
|
||||
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, branch)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if pb != nil { // override previous results if there is a branch protection rule
|
||||
pb.Repo = repo
|
||||
pushAllowed = pb.CanUserPush(ctx, user)
|
||||
forcePushAllowed = pb.CanUserForcePush(ctx, user)
|
||||
}
|
||||
return pushAllowed, forcePushAllowed, nil
|
||||
}
|
||||
|
||||
// CheckUserAllowedToUpdate check if user is allowed to update PR with given permissions and branch protections
|
||||
// update PR means send new commits to PR head branch from base branch
|
||||
func CheckUserAllowedToUpdate(ctx context.Context, pull *issues_model.PullRequest, user *user_model.User) (ret struct {
|
||||
MergeAllowed, RebaseAllowed bool
|
||||
DefaultUpdateStyle repo_model.UpdateStyle
|
||||
}, err error,
|
||||
) {
|
||||
if user == nil {
|
||||
return ret, nil
|
||||
}
|
||||
if err := pull.LoadBaseRepo(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if err := pull.LoadHeadRepo(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// 1. check whether pull request enabled.
|
||||
prBaseUnit, err := pull.BaseRepo.GetUnit(ctx, unit.TypePullRequests)
|
||||
if repo_model.IsErrUnitTypeNotExist(err) {
|
||||
return ret, nil // the PR unit is disabled in base repo means no update allowed
|
||||
} else if err != nil {
|
||||
return ret, fmt.Errorf("get base repo unit: %v", err)
|
||||
}
|
||||
|
||||
// 2. only support Github style pull request
|
||||
if pull.Flow == issues_model.PullRequestFlowAGit {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// 3. check user push permission on head repository
|
||||
ret.MergeAllowed, ret.RebaseAllowed, err = isUserAllowedToPushOrForcePushInRepoBranch(ctx, user, pull.HeadRepo, pull.HeadBranch)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// 4. if the pull creator allows maintainer to edit, we need to check whether
|
||||
// user is a maintainer (has permission to merge into base branch) and inherit pull request poster's permission
|
||||
if pull.AllowMaintainerEdit && (!ret.MergeAllowed || !ret.RebaseAllowed) {
|
||||
baseRepoPerm, err := access_model.GetDoerRepoPermission(ctx, pull.BaseRepo, user)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
userAllowedToMergePR, err := isUserAllowedToMergeInRepoBranch(ctx, pull.BaseRepoID, pull.BaseBranch, baseRepoPerm, user)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if userAllowedToMergePR {
|
||||
// the user is maintainer (can merge PR), and this PR is allowed to be edited by maintainers,
|
||||
// then the user should inherit the PR poster's push/rebase permission for the head branch
|
||||
if err := pull.LoadIssue(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if err := pull.Issue.LoadPoster(ctx); err != nil {
|
||||
return ret, err
|
||||
}
|
||||
posterPushAllowed, posterRebaseAllowed, err := isUserAllowedToPushOrForcePushInRepoBranch(ctx, pull.Issue.Poster, pull.HeadRepo, pull.HeadBranch)
|
||||
if err != nil {
|
||||
return ret, err
|
||||
}
|
||||
if !ret.MergeAllowed {
|
||||
ret.MergeAllowed = posterPushAllowed
|
||||
}
|
||||
if !ret.RebaseAllowed {
|
||||
ret.RebaseAllowed = posterRebaseAllowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. apply base repository's update configuration; it is a config on the base repo,
|
||||
// but it controls the head (fork) repo's "Update" behavior.
|
||||
prConfig := prBaseUnit.PullRequestsConfig()
|
||||
ret.MergeAllowed = ret.MergeAllowed && prConfig.AllowMergeUpdate
|
||||
ret.RebaseAllowed = ret.RebaseAllowed && prConfig.AllowRebaseUpdate
|
||||
ret.DefaultUpdateStyle = prConfig.DefaultUpdateStyle
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func syncCommitDivergence(ctx context.Context, pr *issues_model.PullRequest) error {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
divergence, err := gitrepo.GetDivergingCommits(ctx, pr.BaseRepo, pr.BaseBranch, pr.GetGitHeadRefName())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pr.UpdateCommitDivergence(ctx, divergence.Ahead, divergence.Behind)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// updateHeadByRebaseOnToBase handles updating a PR's head branch by rebasing it on the PR current base branch
|
||||
func updateHeadByRebaseOnToBase(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.User) error {
|
||||
// "Clone" base repo and add the cache headers for the head repo and branch
|
||||
mergeCtx, cancel, err := createTemporaryRepoForMerge(ctx, pr, doer, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// Determine the old merge-base before the rebase - we use this for LFS push later on
|
||||
oldMergeBase, _, _ := gitcmd.NewCommand("merge-base").AddDashesAndList(tmpRepoBaseBranch, tmpRepoTrackingBranch).
|
||||
WithDir(mergeCtx.tmpBasePath).RunStdString(ctx)
|
||||
oldMergeBase = strings.TrimSpace(oldMergeBase)
|
||||
|
||||
// Rebase the tracking branch on to the base as the staging branch
|
||||
if err := rebaseTrackingOnToBase(mergeCtx, repo_model.MergeStyleRebaseUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if setting.LFS.StartServer {
|
||||
// Now we need to ensure that the head repository contains any LFS objects between the new base and the old mergebase
|
||||
// It's questionable about where this should go - either after or before the push
|
||||
// I think in the interests of data safety - failures to push to the lfs should prevent
|
||||
// the push as you can always re-rebase.
|
||||
if err := LFSPush(ctx, mergeCtx.tmpBasePath, tmpRepoBaseBranch, oldMergeBase, &issues_model.PullRequest{
|
||||
HeadRepoID: pr.BaseRepoID,
|
||||
BaseRepoID: pr.HeadRepoID,
|
||||
}); err != nil {
|
||||
log.Error("Unable to push lfs objects between %s and %s up to head branch in %-v: %v", tmpRepoBaseBranch, oldMergeBase, pr, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Now determine who the pushing author should be
|
||||
var headUser *user_model.User
|
||||
if err := pr.HeadRepo.LoadOwner(ctx); err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
log.Error("Can't find user: %d for head repository in %-v - %v", pr.HeadRepo.OwnerID, pr, err)
|
||||
return err
|
||||
}
|
||||
log.Error("Can't find user: %d for head repository in %-v - defaulting to doer: %-v - %v", pr.HeadRepo.OwnerID, pr, doer, err)
|
||||
headUser = doer
|
||||
} else {
|
||||
headUser = pr.HeadRepo.Owner
|
||||
}
|
||||
|
||||
pushCmd := gitcmd.NewCommand("push", "-f", "head_repo").
|
||||
AddDynamicArguments(tmpRepoStagingBranch + ":" + git.BranchPrefix + pr.HeadBranch)
|
||||
|
||||
// Push back to the head repository.
|
||||
// TODO: this cause an api call to "/api/internal/hook/post-receive/...",
|
||||
// that prevents us from doint the whole merge in one db transaction
|
||||
mergeCtx.outbuf.Reset()
|
||||
|
||||
if err := pushCmd.
|
||||
WithEnv(repo_module.FullPushingEnvironment(
|
||||
headUser,
|
||||
doer,
|
||||
pr.HeadRepo,
|
||||
pr.HeadRepo.Name,
|
||||
pr.ID,
|
||||
pr.Index,
|
||||
)).
|
||||
WithDir(mergeCtx.tmpBasePath).
|
||||
WithStdoutBuffer(mergeCtx.outbuf).
|
||||
RunWithStderr(ctx); err != nil {
|
||||
if strings.Contains(err.Stderr(), "non-fast-forward") {
|
||||
return &git.ErrPushOutOfDate{
|
||||
StdOut: mergeCtx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
} else if strings.Contains(err.Stderr(), "! [remote rejected]") {
|
||||
err := &git.ErrPushRejected{
|
||||
StdOut: mergeCtx.outbuf.String(),
|
||||
StdErr: err.Stderr(),
|
||||
Err: err,
|
||||
}
|
||||
err.GenerateMessage()
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("git push: %s", err.Stderr())
|
||||
}
|
||||
mergeCtx.outbuf.Reset()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package pull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsUserAllowedToUpdate(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
updatePRConfig := func(t *testing.T, repoID int64, update func(*repo_model.PullRequestsConfig)) {
|
||||
repoUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: repoID, Type: unit.TypePullRequests})
|
||||
update(repoUnit.PullRequestsConfig())
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(t.Context(), repoUnit))
|
||||
}
|
||||
setRepoAllowRebaseUpdate := func(t *testing.T, repoID int64, allow bool) {
|
||||
updatePRConfig(t, repoID, func(c *repo_model.PullRequestsConfig) { c.AllowRebaseUpdate = allow })
|
||||
}
|
||||
setRepoAllowMergeUpdate := func(t *testing.T, repoID int64, allow bool) {
|
||||
updatePRConfig(t, repoID, func(c *repo_model.PullRequestsConfig) { c.AllowMergeUpdate = allow })
|
||||
}
|
||||
checkUserAllowedToUpdate := func(ctx context.Context, pull *issues_model.PullRequest, user *user_model.User) (bool, bool, repo_model.UpdateStyle, error) {
|
||||
ret, err := CheckUserAllowedToUpdate(ctx, pull, user)
|
||||
return ret.MergeAllowed, ret.RebaseAllowed, ret.DefaultUpdateStyle, err
|
||||
}
|
||||
|
||||
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
t.Run("RespectsProtectedBranch", func(t *testing.T) {
|
||||
pr2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
protectedBranch := &git_model.ProtectedBranch{
|
||||
RepoID: pr2.HeadRepoID,
|
||||
RuleName: pr2.HeadBranch,
|
||||
CanPush: false,
|
||||
CanForcePush: false,
|
||||
}
|
||||
_, err := db.GetEngine(t.Context()).Insert(protectedBranch)
|
||||
require.NoError(t, err)
|
||||
defer db.DeleteByBean(t.Context(), protectedBranch)
|
||||
|
||||
pushAllowed, rebaseAllowed, defaultMergeStyle, err := checkUserAllowedToUpdate(t.Context(), pr2, user2)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
assert.Equal(t, repo_model.UpdateStyleMerge, defaultMergeStyle)
|
||||
})
|
||||
|
||||
t.Run("DisallowRebaseWhenConfigDisabled", func(t *testing.T) {
|
||||
pr2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
setRepoAllowRebaseUpdate(t, pr2.BaseRepoID, false)
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr2, user2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
})
|
||||
|
||||
t.Run("DisallowMergeWhenConfigDisabled", func(t *testing.T) {
|
||||
pr2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
setRepoAllowRebaseUpdate(t, pr2.BaseRepoID, true)
|
||||
setRepoAllowMergeUpdate(t, pr2.BaseRepoID, false)
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr2, user2)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pushAllowed)
|
||||
assert.True(t, rebaseAllowed)
|
||||
setRepoAllowMergeUpdate(t, pr2.BaseRepoID, true)
|
||||
})
|
||||
|
||||
t.Run("ReadOnlyAccessDenied", func(t *testing.T) {
|
||||
pr2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
|
||||
collaboration := &repo_model.Collaboration{
|
||||
RepoID: pr2.HeadRepoID,
|
||||
UserID: user4.ID,
|
||||
Mode: perm.AccessModeRead,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), collaboration))
|
||||
defer db.DeleteByBean(t.Context(), collaboration)
|
||||
|
||||
require.NoError(t, pr2.LoadHeadRepo(t.Context()))
|
||||
assert.NoError(t, access_model.RecalculateUserAccess(t.Context(), pr2.HeadRepo, user4.ID))
|
||||
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr2, user4)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
})
|
||||
|
||||
t.Run("ProtectedBranchAllowsPushWithoutRebase", func(t *testing.T) {
|
||||
pr2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
protectedBranch := &git_model.ProtectedBranch{
|
||||
RepoID: pr2.HeadRepoID,
|
||||
RuleName: pr2.HeadBranch,
|
||||
CanPush: true,
|
||||
CanForcePush: false,
|
||||
}
|
||||
_, err := db.GetEngine(t.Context()).Insert(protectedBranch)
|
||||
require.NoError(t, err)
|
||||
defer db.DeleteByBean(t.Context(), protectedBranch)
|
||||
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr2, user2)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
})
|
||||
|
||||
pr3Poster := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 12})
|
||||
|
||||
t.Run("MaintainerEditRespectsPosterPermissions", func(t *testing.T) {
|
||||
pr3 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||
pr3.AllowMaintainerEdit = true
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr3, pr3Poster)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
})
|
||||
|
||||
t.Run("MaintainerEditInheritsPosterPermissions", func(t *testing.T) {
|
||||
pr3 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||
pr3.AllowMaintainerEdit = true
|
||||
protectedBranch := &git_model.ProtectedBranch{
|
||||
RepoID: pr3.HeadRepoID,
|
||||
RuleName: pr3.HeadBranch,
|
||||
CanPush: true,
|
||||
CanForcePush: true,
|
||||
}
|
||||
_, err := db.GetEngine(t.Context()).Insert(protectedBranch)
|
||||
require.NoError(t, err)
|
||||
defer db.DeleteByBean(t.Context(), protectedBranch)
|
||||
|
||||
collaboration := &repo_model.Collaboration{
|
||||
RepoID: pr3.HeadRepoID,
|
||||
UserID: pr3Poster.ID,
|
||||
Mode: perm.AccessModeWrite,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), collaboration))
|
||||
defer db.DeleteByBean(t.Context(), collaboration)
|
||||
|
||||
require.NoError(t, pr3.LoadHeadRepo(t.Context()))
|
||||
assert.NoError(t, access_model.RecalculateUserAccess(t.Context(), pr3.HeadRepo, pr3Poster.ID))
|
||||
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr3, pr3Poster)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pushAllowed)
|
||||
assert.True(t, rebaseAllowed)
|
||||
})
|
||||
|
||||
t.Run("MaintainerEditInheritsPosterPermissionsRebaseDisabled", func(t *testing.T) {
|
||||
pr3 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 3})
|
||||
pr3.AllowMaintainerEdit = true
|
||||
protectedBranch := &git_model.ProtectedBranch{
|
||||
RepoID: pr3.HeadRepoID,
|
||||
RuleName: pr3.HeadBranch,
|
||||
CanPush: true,
|
||||
CanForcePush: true,
|
||||
}
|
||||
_, err := db.GetEngine(t.Context()).Insert(protectedBranch)
|
||||
require.NoError(t, err)
|
||||
defer db.DeleteByBean(t.Context(), protectedBranch)
|
||||
|
||||
collaboration := &repo_model.Collaboration{
|
||||
RepoID: pr3.HeadRepoID,
|
||||
UserID: pr3Poster.ID,
|
||||
Mode: perm.AccessModeWrite,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), collaboration))
|
||||
defer db.DeleteByBean(t.Context(), collaboration)
|
||||
|
||||
require.NoError(t, pr3.LoadHeadRepo(t.Context()))
|
||||
assert.NoError(t, access_model.RecalculateUserAccess(t.Context(), pr3.HeadRepo, pr3Poster.ID))
|
||||
|
||||
setRepoAllowRebaseUpdate(t, pr3.BaseRepoID, false)
|
||||
|
||||
pushAllowed, rebaseAllowed, _, err := checkUserAllowedToUpdate(t.Context(), pr3, pr3Poster)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, pushAllowed)
|
||||
assert.False(t, rebaseAllowed)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user