mirror of
https://github.com/go-gitea/gitea.git
synced 2025-04-25 19:23:07 +02:00
Option to delay conflict checking of old pull requests until page view (#27779)
`[repository.pull-request] DELAY_CHECK_FOR_INACTIVE_DAYS` is a new setting to delay the mergeable check for pull requests that have been inactive for the specified number of days. This avoids potentially long delays for big repositories with many pull requests. and reduces system load overall when there are many repositories or pull requests. When viewing the PR, checking will start immediately and the PR merge box will automatically reload when complete. Accessing the PR through the API will also start checking immediately. The default value of `7` provides a balance between system load, and keeping behavior similar to what it was before both for users and API access. With `0` all conflict checking will be delayed, while `-1` always checks immediately to restore the previous behavior. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
d1ad8e1e80
commit
a9343896f4
@ -1155,6 +1155,10 @@ LEVEL = Info
|
||||
;;
|
||||
;; Retarget child pull requests to the parent pull request branch target on merge of parent pull request. It only works on merged PRs where the head and base branch target the same repo.
|
||||
;RETARGET_CHILDREN_ON_MERGE = true
|
||||
;;
|
||||
;; Delay mergeable check until page view or API access, for pull requests that have not been updated in the specified days when their base branches get updated.
|
||||
;; Use "-1" to always check all pull requests (old behavior). Use "0" to always delay the checks.
|
||||
;DELAY_CHECK_FOR_INACTIVE_DAYS = 7
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@ -104,27 +103,6 @@ const (
|
||||
PullRequestStatusAncestor
|
||||
)
|
||||
|
||||
func (status PullRequestStatus) String() string {
|
||||
switch status {
|
||||
case PullRequestStatusConflict:
|
||||
return "CONFLICT"
|
||||
case PullRequestStatusChecking:
|
||||
return "CHECKING"
|
||||
case PullRequestStatusMergeable:
|
||||
return "MERGEABLE"
|
||||
case PullRequestStatusManuallyMerged:
|
||||
return "MANUALLY_MERGED"
|
||||
case PullRequestStatusError:
|
||||
return "ERROR"
|
||||
case PullRequestStatusEmpty:
|
||||
return "EMPTY"
|
||||
case PullRequestStatusAncestor:
|
||||
return "ANCESTOR"
|
||||
default:
|
||||
return strconv.Itoa(int(status))
|
||||
}
|
||||
}
|
||||
|
||||
// PullRequestFlow the flow of pull request
|
||||
type PullRequestFlow int
|
||||
|
||||
|
@ -82,6 +82,7 @@ var (
|
||||
AddCoCommitterTrailers bool
|
||||
TestConflictingPatchesWithGitApply bool
|
||||
RetargetChildrenOnMerge bool
|
||||
DelayCheckForInactiveDays int
|
||||
} `ini:"repository.pull-request"`
|
||||
|
||||
// Issue Setting
|
||||
@ -200,6 +201,7 @@ var (
|
||||
AddCoCommitterTrailers bool
|
||||
TestConflictingPatchesWithGitApply bool
|
||||
RetargetChildrenOnMerge bool
|
||||
DelayCheckForInactiveDays int
|
||||
}{
|
||||
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
|
||||
// Same as GitHub. See
|
||||
@ -215,6 +217,7 @@ var (
|
||||
PopulateSquashCommentWithCommitMessages: false,
|
||||
AddCoCommitterTrailers: true,
|
||||
RetargetChildrenOnMerge: true,
|
||||
DelayCheckForInactiveDays: 7,
|
||||
},
|
||||
|
||||
// Issue settings
|
||||
|
@ -1880,7 +1880,7 @@ pulls.add_prefix = Add <strong>%s</strong> prefix
|
||||
pulls.remove_prefix = Remove <strong>%s</strong> prefix
|
||||
pulls.data_broken = This pull request is broken due to missing fork information.
|
||||
pulls.files_conflicted = This pull request has changes conflicting with the target branch.
|
||||
pulls.is_checking = "Merge conflict checking is in progress. Try again in few moments."
|
||||
pulls.is_checking = Checking for merge conflicts ...
|
||||
pulls.is_ancestor = "This branch is already included in the target branch. There is nothing to merge."
|
||||
pulls.is_empty = "The changes on this branch are already on the target branch. This will be an empty commit."
|
||||
pulls.required_status_check_failed = Some required checks were not successful.
|
||||
|
@ -202,6 +202,10 @@ func GetPullRequest(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Consider API access a view for delayed checking.
|
||||
pull_service.StartPullRequestCheckOnView(ctx, pr)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer))
|
||||
}
|
||||
|
||||
@ -287,6 +291,10 @@ func GetPullRequestByBaseHead(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Consider API access a view for delayed checking.
|
||||
pull_service.StartPullRequestCheckOnView(ctx, pr)
|
||||
|
||||
ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer))
|
||||
}
|
||||
|
||||
@ -921,7 +929,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil {
|
||||
if errors.Is(err, pull_service.ErrIsClosed) {
|
||||
ctx.APIErrorNotFound()
|
||||
} else if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) {
|
||||
} else if errors.Is(err, pull_service.ErrNoPermissionToMerge) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, "User not allowed to merge PR")
|
||||
} else if errors.Is(err, pull_service.ErrHasMerged) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, "")
|
||||
@ -929,7 +937,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, "Work in progress PRs cannot be merged")
|
||||
} else if errors.Is(err, pull_service.ErrNotMergeableState) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, "Please try again later")
|
||||
} else if pull_service.IsErrDisallowedToMerge(err) {
|
||||
} else if errors.Is(err, pull_service.ErrNotReadyToMerge) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, err)
|
||||
} else if asymkey_service.IsErrWontSign(err) {
|
||||
ctx.APIError(http.StatusMethodNotAllowed, err)
|
||||
|
@ -4,6 +4,7 @@
|
||||
package private
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -374,7 +375,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
|
||||
|
||||
// Check all status checks and reviews are ok
|
||||
if err := pull_service.CheckPullBranchProtections(ctx, pr, true); err != nil {
|
||||
if pull_service.IsErrDisallowedToMerge(err) {
|
||||
if errors.Is(err, pull_service.ErrNotReadyToMerge) {
|
||||
log.Warn("Forbidden: User %d is not allowed push to protected branch %s in %-v and pr #%d is not ready to be merged: %s", ctx.opts.UserID, branchName, repo, pr.Index, err.Error())
|
||||
ctx.JSON(http.StatusForbidden, private.Response{
|
||||
UserMsg: fmt.Sprintf("Not allowed to push to protected branch %s and pr #%d is not ready to be merged: %s", branchName, ctx.opts.PullRequestID, err.Error()),
|
||||
|
@ -43,7 +43,8 @@ const (
|
||||
tplIssueChoose templates.TplName = "repo/issue/choose"
|
||||
tplIssueView templates.TplName = "repo/issue/view"
|
||||
|
||||
tplReactions templates.TplName = "repo/issue/view_content/reactions"
|
||||
tplPullMergeBox templates.TplName = "repo/issue/view_content/pull_merge_box"
|
||||
tplReactions templates.TplName = "repo/issue/view_content/reactions"
|
||||
|
||||
issueTemplateKey = "IssueTemplate"
|
||||
issueTemplateTitleKey = "IssueTemplateTitle"
|
||||
|
@ -96,7 +96,7 @@ func NewComment(ctx *context.Context) {
|
||||
// Regenerate patch and test conflict.
|
||||
if pr == nil {
|
||||
issue.PullRequest.HeadCommitID = ""
|
||||
pull_service.AddToTaskQueue(ctx, issue.PullRequest)
|
||||
pull_service.StartPullRequestCheckImmediately(ctx, issue.PullRequest)
|
||||
}
|
||||
|
||||
// check whether the ref of PR <refs/pulls/pr_index/head> in base repo is consistent with the head commit of head branch in the head repo
|
||||
|
@ -31,6 +31,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/templates/vars"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/context/upload"
|
||||
@ -271,8 +272,23 @@ func combineLabelComments(issue *issues_model.Issue) {
|
||||
}
|
||||
}
|
||||
|
||||
// ViewIssue render issue view page
|
||||
func ViewIssue(ctx *context.Context) {
|
||||
func prepareIssueViewLoad(ctx *context.Context) *issues_model.Issue {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err)
|
||||
return nil
|
||||
}
|
||||
issue.Repo = ctx.Repo.Repository
|
||||
ctx.Data["Issue"] = issue
|
||||
|
||||
if err = issue.LoadPullRequest(ctx); err != nil {
|
||||
ctx.ServerError("LoadPullRequest", err)
|
||||
return nil
|
||||
}
|
||||
return issue
|
||||
}
|
||||
|
||||
func handleViewIssueRedirectExternal(ctx *context.Context) {
|
||||
if ctx.PathParam("type") == "issues" {
|
||||
// If issue was requested we check if repo has external tracker and redirect
|
||||
extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
|
||||
@ -294,18 +310,18 @@ func ViewIssue(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
ctx.ServerError("GetIssueByIndex", err)
|
||||
}
|
||||
// ViewIssue render issue view page
|
||||
func ViewIssue(ctx *context.Context) {
|
||||
handleViewIssueRedirectExternal(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
if issue.Repo == nil {
|
||||
issue.Repo = ctx.Repo.Repository
|
||||
|
||||
issue := prepareIssueViewLoad(ctx)
|
||||
if ctx.Written() {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure type and URL matches.
|
||||
@ -337,12 +353,12 @@ func ViewIssue(ctx *context.Context) {
|
||||
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
|
||||
upload.AddUploadContext(ctx, "comment")
|
||||
|
||||
if err = issue.LoadAttributes(ctx); err != nil {
|
||||
if err := issue.LoadAttributes(ctx); err != nil {
|
||||
ctx.ServerError("LoadAttributes", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = filterXRefComments(ctx, issue); err != nil {
|
||||
if err := filterXRefComments(ctx, issue); err != nil {
|
||||
ctx.ServerError("filterXRefComments", err)
|
||||
return
|
||||
}
|
||||
@ -351,7 +367,7 @@ func ViewIssue(ctx *context.Context) {
|
||||
|
||||
if ctx.IsSigned {
|
||||
// Update issue-user.
|
||||
if err = activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
if err := activities_model.SetIssueReadBy(ctx, issue.ID, ctx.Doer.ID); err != nil {
|
||||
ctx.ServerError("ReadBy", err)
|
||||
return
|
||||
}
|
||||
@ -365,15 +381,13 @@ func ViewIssue(ctx *context.Context) {
|
||||
|
||||
prepareFuncs := []func(*context.Context, *issues_model.Issue){
|
||||
prepareIssueViewContent,
|
||||
func(ctx *context.Context, issue *issues_model.Issue) {
|
||||
preparePullViewPullInfo(ctx, issue)
|
||||
},
|
||||
prepareIssueViewCommentsAndSidebarParticipants,
|
||||
preparePullViewReviewAndMerge,
|
||||
prepareIssueViewSidebarWatch,
|
||||
prepareIssueViewSidebarTimeTracker,
|
||||
prepareIssueViewSidebarDependency,
|
||||
prepareIssueViewSidebarPin,
|
||||
func(ctx *context.Context, issue *issues_model.Issue) { preparePullViewPullInfo(ctx, issue) },
|
||||
preparePullViewReviewAndMerge,
|
||||
}
|
||||
|
||||
for _, prepareFunc := range prepareFuncs {
|
||||
@ -412,9 +426,25 @@ func ViewIssue(ctx *context.Context) {
|
||||
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
|
||||
}
|
||||
|
||||
if issue.PullRequest != nil && !issue.PullRequest.IsChecking() && !setting.IsProd {
|
||||
ctx.Data["PullMergeBoxReloadingInterval"] = 1 // in dev env, force using the reloading logic to make sure it won't break
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, tplIssueView)
|
||||
}
|
||||
|
||||
func ViewPullMergeBox(ctx *context.Context) {
|
||||
issue := prepareIssueViewLoad(ctx)
|
||||
if !issue.IsPull {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
preparePullViewPullInfo(ctx, issue)
|
||||
preparePullViewReviewAndMerge(ctx, issue)
|
||||
ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking()
|
||||
ctx.HTML(http.StatusOK, tplPullMergeBox)
|
||||
}
|
||||
|
||||
func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model.Issue) {
|
||||
if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) {
|
||||
ctx.Data["IssueDependencySearchType"] = "pulls"
|
||||
@ -792,6 +822,8 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss
|
||||
allowMerge := false
|
||||
canWriteToHeadRepo := false
|
||||
|
||||
pull_service.StartPullRequestCheckOnView(ctx, pull)
|
||||
|
||||
if ctx.IsSigned {
|
||||
if err := pull.LoadHeadRepo(ctx); err != nil {
|
||||
log.Error("LoadHeadRepo: %v", err)
|
||||
@ -838,6 +870,7 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["PullMergeBoxReloadingInterval"] = util.Iif(pull != nil && pull.IsChecking(), 2000, 0)
|
||||
ctx.Data["CanWriteToHeadRepo"] = canWriteToHeadRepo
|
||||
ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo
|
||||
ctx.Data["AllowMerge"] = allowMerge
|
||||
@ -958,5 +991,4 @@ func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
|
||||
ctx.ServerError("roleDescriptor", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Issue"] = issue
|
||||
}
|
||||
|
@ -1052,7 +1052,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
} else {
|
||||
ctx.JSONError(ctx.Tr("repo.issues.closed_title"))
|
||||
}
|
||||
case errors.Is(err, pull_service.ErrUserNotAllowedToMerge):
|
||||
case errors.Is(err, pull_service.ErrNoPermissionToMerge):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed"))
|
||||
case errors.Is(err, pull_service.ErrHasMerged):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.has_merged"))
|
||||
@ -1060,7 +1060,7 @@ func MergePullRequest(ctx *context.Context) {
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip"))
|
||||
case errors.Is(err, pull_service.ErrNotMergeableState):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready"))
|
||||
case pull_service.IsErrDisallowedToMerge(err):
|
||||
case errors.Is(err, pull_service.ErrNotReadyToMerge):
|
||||
ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready"))
|
||||
case asymkey_service.IsErrWontSign(err):
|
||||
ctx.JSONError(err.Error()) // has no translation ...
|
||||
|
@ -1505,6 +1505,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
|
||||
m.Get(".diff", repo.DownloadPullDiff)
|
||||
m.Get(".patch", repo.DownloadPullPatch)
|
||||
m.Get("/merge_box", repo.ViewPullMergeBox)
|
||||
m.Group("/commits", func() {
|
||||
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits)
|
||||
m.Get("/list", repo.GetPullCommits)
|
||||
|
@ -204,7 +204,7 @@ func ProcReceive(ctx context.Context, repo *repo_model.Repository, gitRepo *git.
|
||||
return nil, fmt.Errorf("failed to update pull ref. Error: %w", err)
|
||||
}
|
||||
|
||||
pull_service.AddToTaskQueue(ctx, pr)
|
||||
pull_service.StartPullRequestCheckImmediately(ctx, pr)
|
||||
err = pr.LoadIssue(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load pull issue. Error: %w", err)
|
||||
|
@ -289,7 +289,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
|
||||
}
|
||||
|
||||
if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false); err != nil {
|
||||
if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) {
|
||||
if errors.Is(err, pull_service.ErrNotReadyToMerge) {
|
||||
log.Info("%-v was scheduled to automerge by an unauthorized user", pr)
|
||||
return
|
||||
}
|
||||
|
@ -556,7 +556,7 @@ func (g *GiteaLocalUploader) CreatePullRequests(ctx context.Context, prs ...*bas
|
||||
}
|
||||
for _, pr := range gprs {
|
||||
g.issues[pr.Issue.Index] = pr.Issue
|
||||
pull.AddToTaskQueue(ctx, pr)
|
||||
pull.StartPullRequestCheckImmediately(ctx, pr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
@ -25,6 +26,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/process"
|
||||
"code.gitea.io/gitea/modules/queue"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
asymkey_service "code.gitea.io/gitea/services/asymkey"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
@ -34,27 +36,88 @@ import (
|
||||
var prPatchCheckerQueue *queue.WorkerPoolQueue[string]
|
||||
|
||||
var (
|
||||
ErrIsClosed = errors.New("pull is closed")
|
||||
ErrUserNotAllowedToMerge = ErrDisallowedToMerge{}
|
||||
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")
|
||||
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")
|
||||
)
|
||||
|
||||
// AddToTaskQueue adds itself to pull request test task queue.
|
||||
func AddToTaskQueue(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
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("AddToTaskQueue(%-v).UpdateCols.(add to queue): %v", pr, err)
|
||||
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
|
||||
}
|
||||
log.Trace("Adding %-v to the test pull requests queue", pr)
|
||||
err = prPatchCheckerQueue.Push(strconv.FormatInt(pr.ID, 10))
|
||||
if err != nil && err != queue.ErrAlreadyInQueue {
|
||||
log.Error("Error adding %-v to the test pull requests queue: %v", pr, err)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,7 +147,7 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
|
||||
log.Error("Error whilst checking if %-v is allowed to merge %-v: %v", doer, pr, err)
|
||||
return err
|
||||
} else if !allowedMerge {
|
||||
return ErrUserNotAllowedToMerge
|
||||
return ErrNoPermissionToMerge
|
||||
}
|
||||
|
||||
if mergeCheckType == MergeCheckTypeManually {
|
||||
@ -105,7 +168,7 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
|
||||
}
|
||||
|
||||
if err := CheckPullBranchProtections(ctx, pr, false); err != nil {
|
||||
if !IsErrDisallowedToMerge(err) {
|
||||
if !errors.Is(err, ErrNotReadyToMerge) {
|
||||
log.Error("Error whilst checking pull branch protection for %-v: %v", pr, err)
|
||||
return err
|
||||
}
|
||||
@ -172,10 +235,10 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
|
||||
return sign, err
|
||||
}
|
||||
|
||||
// checkAndUpdateStatus checks if pull request is possible to leaving checking status,
|
||||
// markPullRequestAsMergeable checks if pull request is possible to leaving checking status,
|
||||
// and set to be either conflict or mergeable.
|
||||
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
// If status has not been changed to conflict by testPatch then we are mergeable
|
||||
func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
|
||||
// If status has not been changed to conflict by testPullRequestTmpRepoBranchMergeable then we are mergeable
|
||||
if pr.Status == issues_model.PullRequestStatusChecking {
|
||||
pr.Status = issues_model.PullRequestStatusMergeable
|
||||
}
|
||||
@ -310,6 +373,10 @@ func manuallyMerged(ctx context.Context, pr *issues_model.PullRequest) bool {
|
||||
|
||||
// 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)
|
||||
@ -320,24 +387,12 @@ func InitializePullRequests(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
log.Trace("Adding PR[%d] to the pull requests patch checking queue", prID)
|
||||
if err := prPatchCheckerQueue.Push(strconv.FormatInt(prID, 10)); err != nil {
|
||||
log.Error("Error adding PR[%d] to the pull requests patch checking queue %v", prID, err)
|
||||
}
|
||||
AddPullRequestToCheckQueue(prID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle passed PR IDs and test the PRs
|
||||
func handler(items ...string) []string {
|
||||
for _, s := range items {
|
||||
id, _ := strconv.ParseInt(s, 10, 64)
|
||||
testPR(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testPR(id int64) {
|
||||
func checkPullRequestMergeable(id int64) {
|
||||
ctx := graceful.GetManager().HammerContext()
|
||||
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(id))
|
||||
if err != nil {
|
||||
@ -351,7 +406,7 @@ func testPR(id int64) {
|
||||
|
||||
pr, err := issues_model.GetPullRequestByID(ctx, id)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetPullRequestByID[%d] for testPR: %v", id, err)
|
||||
log.Error("Unable to GetPullRequestByID[%d] for checkPullRequestMergeable: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -370,15 +425,15 @@ func testPR(id int64) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := TestPatch(pr); err != nil {
|
||||
log.Error("testPatch[%-v]: %v", pr, err)
|
||||
if err := testPullRequestBranchMergeable(pr); err != nil {
|
||||
log.Error("testPullRequestTmpRepoBranchMergeable[%-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
|
||||
}
|
||||
checkAndUpdateStatus(ctx, pr)
|
||||
markPullRequestAsMergeable(ctx, pr)
|
||||
}
|
||||
|
||||
// CheckPRsForBaseBranch check all pulls with baseBrannch
|
||||
@ -387,17 +442,21 @@ func CheckPRsForBaseBranch(ctx context.Context, baseRepo *repo_model.Repository,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
AddToTaskQueue(ctx, pr)
|
||||
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", handler)
|
||||
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")
|
||||
|
@ -36,7 +36,7 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
AddToTaskQueue(db.DefaultContext, pr)
|
||||
StartPullRequestCheckImmediately(db.DefaultContext, pr)
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
|
||||
|
@ -518,25 +518,6 @@ func IsUserAllowedToMerge(ctx context.Context, pr *issues_model.PullRequest, p a
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ErrDisallowedToMerge represents an error that a branch is protected and the current user is not allowed to modify it.
|
||||
type ErrDisallowedToMerge struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// IsErrDisallowedToMerge checks if an error is an ErrDisallowedToMerge.
|
||||
func IsErrDisallowedToMerge(err error) bool {
|
||||
_, ok := err.(ErrDisallowedToMerge)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrDisallowedToMerge) Error() string {
|
||||
return fmt.Sprintf("not allowed to merge [reason: %s]", err.Reason)
|
||||
}
|
||||
|
||||
func (err ErrDisallowedToMerge) Unwrap() error {
|
||||
return util.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -556,31 +537,21 @@ func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullReques
|
||||
return err
|
||||
}
|
||||
if !isPass {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "Not all required status checks successful",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Not all required status checks successful")
|
||||
}
|
||||
|
||||
if !issues_model.HasEnoughApprovals(ctx, pb, pr) {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "Does not have enough approvals",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Does not have enough approvals")
|
||||
}
|
||||
if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "There are requested changes",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "There are requested changes")
|
||||
}
|
||||
if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "There are official review requests",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "There are official review requests")
|
||||
}
|
||||
|
||||
if issues_model.MergeBlockedByOutdatedBranch(pb, pr) {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "The head branch is behind the base branch",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "The head branch is behind the base branch")
|
||||
}
|
||||
|
||||
if skipProtectedFilesCheck {
|
||||
@ -588,9 +559,7 @@ func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullReques
|
||||
}
|
||||
|
||||
if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) {
|
||||
return ErrDisallowedToMerge{
|
||||
Reason: "Changed protected files",
|
||||
}
|
||||
return util.ErrorWrap(ErrNotReadyToMerge, "Changed protected files")
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -709,7 +678,7 @@ func SetMerged(ctx context.Context, pr *issues_model.PullRequest, mergedCommitID
|
||||
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 TestPatch. FIXME: need to set some state to prevent TestPatch from running whilst we are merging.
|
||||
// We need to save all of the data used to compute this merge as it may have already been changed by testPullRequestBranchMergeable. FIXME: need to set some state to prevent testPullRequestBranchMergeable 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").
|
||||
|
@ -23,7 +23,7 @@ import (
|
||||
)
|
||||
|
||||
type mergeContext struct {
|
||||
*prContext
|
||||
*prTmpRepoContext
|
||||
doer *user_model.User
|
||||
sig *git.Signature
|
||||
committer *git.Signature
|
||||
@ -68,8 +68,8 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
|
||||
}
|
||||
|
||||
mergeCtx = &mergeContext{
|
||||
prContext: prCtx,
|
||||
doer: doer,
|
||||
prTmpRepoContext: prCtx,
|
||||
doer: doer,
|
||||
}
|
||||
|
||||
if expectedHeadCommitID != "" {
|
||||
|
@ -67,9 +67,8 @@ var patchErrorSuffices = []string{
|
||||
": does not exist in index",
|
||||
}
|
||||
|
||||
// TestPatch will test whether a simple patch will apply
|
||||
func TestPatch(pr *issues_model.PullRequest) error {
|
||||
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("TestPatch: %s", pr))
|
||||
func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error {
|
||||
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("testPullRequestBranchMergeable: %s", pr))
|
||||
defer finished()
|
||||
|
||||
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
|
||||
@ -81,10 +80,10 @@ func TestPatch(pr *issues_model.PullRequest) error {
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
return testPatch(ctx, prCtx, pr)
|
||||
return testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr)
|
||||
}
|
||||
|
||||
func testPatch(ctx context.Context, prCtx *prContext, pr *issues_model.PullRequest) error {
|
||||
func testPullRequestTmpRepoBranchMergeable(ctx context.Context, prCtx *prTmpRepoContext, pr *issues_model.PullRequest) error {
|
||||
gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OpenRepository: %w", err)
|
||||
@ -380,7 +379,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
||||
return false, nil
|
||||
}
|
||||
|
||||
log.Trace("PullRequest[%d].testPatch (patchPath): %s", pr.ID, patchPath)
|
||||
log.Trace("PullRequest[%d].testPullRequestTmpRepoBranchMergeable (patchPath): %s", pr.ID, patchPath)
|
||||
|
||||
// 4. Read the base branch in to the index of the temporary repository
|
||||
_, _, err = git.NewCommand("read-tree", "base").RunStdString(gitRepo.Ctx, &git.RunOpts{Dir: tmpBasePath})
|
||||
@ -450,7 +449,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
|
||||
scanner := bufio.NewScanner(stderrReader)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
log.Trace("PullRequest[%d].testPatch: stderr: %s", pr.ID, line)
|
||||
log.Trace("PullRequest[%d].testPullRequestTmpRepoBranchMergeable: stderr: %s", pr.ID, line)
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
conflict = true
|
||||
filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0])
|
||||
|
@ -96,7 +96,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := testPatch(ctx, prCtx, pr); err != nil {
|
||||
if err := testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -314,12 +314,12 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer
|
||||
pr.BaseBranch = targetBranch
|
||||
|
||||
// Refresh patch
|
||||
if err := TestPatch(pr); err != nil {
|
||||
if err := testPullRequestBranchMergeable(pr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update target branch, PR diff and status
|
||||
// This is the same as checkAndUpdateStatus in check service, but also updates base_branch
|
||||
// This is the same as markPullRequestAsMergeable in check service, but also updates base_branch
|
||||
if pr.Status == issues_model.PullRequestStatusChecking {
|
||||
pr.Status = issues_model.PullRequestStatusMergeable
|
||||
}
|
||||
@ -409,7 +409,7 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
|
||||
continue
|
||||
}
|
||||
|
||||
AddToTaskQueue(ctx, pr)
|
||||
StartPullRequestCheckImmediately(ctx, pr)
|
||||
comment, err := CreatePushPullComment(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID)
|
||||
if err == nil && comment != nil {
|
||||
notify_service.PullRequestPushCommits(ctx, opts.Doer, pr, comment)
|
||||
@ -502,7 +502,7 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
|
||||
log.Error("UpdateCommitDivergence: %v", err)
|
||||
}
|
||||
}
|
||||
AddToTaskQueue(ctx, pr)
|
||||
StartPullRequestCheckDelayable(ctx, pr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ const (
|
||||
stagingBranch = "staging" // this is used for a working branch
|
||||
)
|
||||
|
||||
type prContext struct {
|
||||
type prTmpRepoContext struct {
|
||||
context.Context
|
||||
tmpBasePath string
|
||||
pr *issues_model.PullRequest
|
||||
@ -36,7 +36,7 @@ type prContext struct {
|
||||
errbuf *strings.Builder // any use should be preceded by a Reset and preferably after use
|
||||
}
|
||||
|
||||
func (ctx *prContext) RunOpts() *git.RunOpts {
|
||||
func (ctx *prTmpRepoContext) RunOpts() *git.RunOpts {
|
||||
ctx.outbuf.Reset()
|
||||
ctx.errbuf.Reset()
|
||||
return &git.RunOpts{
|
||||
@ -48,7 +48,7 @@ func (ctx *prContext) RunOpts() *git.RunOpts {
|
||||
|
||||
// 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 *prContext, cancel context.CancelFunc, err error) {
|
||||
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)
|
||||
@ -81,7 +81,7 @@ func createTemporaryRepoForPR(ctx context.Context, pr *issues_model.PullRequest)
|
||||
}
|
||||
cancel = cleanup
|
||||
|
||||
prCtx = &prContext{
|
||||
prCtx = &prTmpRepoContext{
|
||||
Context: ctx,
|
||||
tmpBasePath: tmpBasePath,
|
||||
pr: pr,
|
||||
|
@ -68,7 +68,7 @@
|
||||
{{template "repo/issue/view_content/comments" .}}
|
||||
|
||||
{{if and .Issue.IsPull (not $.Repository.IsArchived)}}
|
||||
{{template "repo/issue/view_content/pull".}}
|
||||
{{template "repo/issue/view_content/pull_merge_box".}}
|
||||
{{end}}
|
||||
|
||||
{{if .IsSigned}}
|
||||
|
@ -1,7 +1,13 @@
|
||||
{{if and .Issue.PullRequest.HasMerged (not .IsPullBranchDeletable)}}
|
||||
{{/* Then the merge box will not be displayed because this page already contains enough information */}}
|
||||
{{else}}
|
||||
<div class="timeline-item comment merge box">
|
||||
<div class="timeline-item comment pull-merge-box"
|
||||
data-global-init="initRepoPullMergeBox"
|
||||
{{if .PullMergeBoxReloadingInterval}}
|
||||
data-pull-merge-box-reloading-interval="{{.PullMergeBoxReloadingInterval}}"
|
||||
data-pull-link="{{.Issue.Link}}"
|
||||
{{end}}
|
||||
>
|
||||
<div class="timeline-avatar text {{if .Issue.PullRequest.HasMerged}}purple
|
||||
{{- else if .Issue.IsClosed}}grey
|
||||
{{- else if .IsPullWorkInProgress}}grey
|
||||
@ -97,7 +103,7 @@
|
||||
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
||||
{{else if .Issue.PullRequest.IsChecking}}
|
||||
<div class="item">
|
||||
{{svg "octicon-sync"}}
|
||||
{{svg "octicon-sync" 16 "circular-spin"}}
|
||||
{{ctx.Locale.Tr "repo.pulls.is_checking"}}
|
||||
</div>
|
||||
{{else if .Issue.PullRequest.IsAncestor}}
|
||||
@ -191,10 +197,11 @@
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{template "repo/issue/view_content/update_branch_by_merge" $}}
|
||||
|
||||
{{if .Issue.PullRequest.IsEmpty}}
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="item">
|
||||
{{svg "octicon-alert"}}
|
||||
{{ctx.Locale.Tr "repo.pulls.is_empty"}}
|
||||
@ -216,7 +223,7 @@
|
||||
const defaultMergeMessage = {{.DefaultMergeBody}};
|
||||
const defaultSquashMergeMessage = {{.DefaultSquashMergeBody}};
|
||||
const mergeForm = {
|
||||
'baseLink': {{.Link}},
|
||||
'baseLink': {{.Issue.Link}},
|
||||
'textCancel': {{ctx.Locale.Tr "cancel"}},
|
||||
'textDeleteBranch': {{ctx.Locale.Tr "repo.branch.delete" .HeadTarget}},
|
||||
'textAutoMergeButtonWhenSucceed': {{ctx.Locale.Tr "repo.pulls.auto_merge_button_when_succeed"}},
|
||||
@ -318,7 +325,7 @@
|
||||
{{if .IsBlockedByApprovals}}
|
||||
<div class="item text red">
|
||||
{{svg "octicon-x"}}
|
||||
{{ctx.Locale.Tr "repo.pulls.blocked_by_approvals" .GrantedApprovals .ProtectedBranch.RequiredApprovals}}
|
||||
{{ctx.Locale.Tr "repo.pulls.blocked_by_approvals" .GrantedApprovals .ProtectedBranch.RequiredApprovals}}
|
||||
</div>
|
||||
{{else if .IsBlockedByRejection}}
|
||||
<div class="item text red">
|
||||
@ -377,7 +384,7 @@
|
||||
*/}}
|
||||
{{if and $.StillCanManualMerge (not $showGeneralMergeForm)}}
|
||||
<div class="divider"></div>
|
||||
<form class="ui form form-fetch-action" action="{{.Link}}/merge" method="post">{{/* another similar form is in PullRequestMergeForm.vue*/}}
|
||||
<form class="ui form form-fetch-action" action="{{.Issue.Link}}/merge" method="post">{{/* another similar form is in PullRequestMergeForm.vue*/}}
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
<input type="text" name="merge_commit_id" placeholder="{{ctx.Locale.Tr "repo.pulls.merge_commit_id"}}">
|
@ -9,7 +9,7 @@
|
||||
{{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
|
||||
<div class="tw-inline-block">
|
||||
<div id="update-pr-branch-with-base" class="ui buttons">
|
||||
<button class="ui button" data-do="{{$.Link}}/update" data-redirect="{{$.Link}}">
|
||||
<button class="ui button" data-do="{{$.Issue.Link}}/update" data-redirect="{{$.Issue.Link}}">
|
||||
<span class="button-text">
|
||||
{{ctx.Locale.Tr "repo.pulls.update_branch"}}
|
||||
</span>
|
||||
@ -17,15 +17,15 @@
|
||||
<div class="ui dropdown icon button">
|
||||
{{svg "octicon-triangle-down"}}
|
||||
<div class="menu">
|
||||
<a class="item active selected" data-do="{{$.Link}}/update">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</a>
|
||||
<a class="item" data-do="{{$.Link}}/update?style=rebase">{{ctx.Locale.Tr "repo.pulls.update_branch_rebase"}}</a>
|
||||
<a class="item active selected" data-do="{{$.Issue.Link}}/update">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</a>
|
||||
<a class="item" data-do="{{$.Issue.Link}}/update?style=rebase">{{ctx.Locale.Tr "repo.pulls.update_branch_rebase"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and $.UpdateAllowed (not $.UpdateByRebaseAllowed)}}
|
||||
<form action="{{$.Link}}/update" method="post" class="ui update-branch-form">
|
||||
<form action="{{$.Issue.Link}}/update" method="post" class="ui update-branch-form">
|
||||
{{$.CsrfTokenHtml}}
|
||||
<button class="ui compact button">
|
||||
<span class="ui text">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</span>
|
||||
|
@ -13,9 +13,13 @@ import (
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
"code.gitea.io/gitea/models/issues"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/services/pull"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@ -165,3 +169,75 @@ func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) {
|
||||
assert.Contains(t, text, "This branch is already included in the target branch. There is nothing to merge.")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPullStatusDelayCheck(t *testing.T) {
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
defer test.MockVariableValue(&setting.IsProd)()
|
||||
defer test.MockVariableValue(&setting.Repository.PullRequest.DelayCheckForInactiveDays, 1)()
|
||||
defer test.MockVariableValue(&pull.AddPullRequestToCheckQueue)()
|
||||
|
||||
session := loginUser(t, "user2")
|
||||
|
||||
run := func(t *testing.T, fn func(*testing.T)) (issue3 *issues.Issue, checkedPrID int64) {
|
||||
pull.AddPullRequestToCheckQueue = func(prID int64) {
|
||||
checkedPrID = prID
|
||||
}
|
||||
fn(t)
|
||||
issue3 = unittest.AssertExistsAndLoadBean(t, &issues.Issue{RepoID: 1, Index: 3})
|
||||
_ = issue3.LoadPullRequest(t.Context())
|
||||
return issue3, checkedPrID
|
||||
}
|
||||
|
||||
assertReloadingInterval := func(t *testing.T, interval string) {
|
||||
req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
|
||||
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||
attr := "data-pull-merge-box-reloading-interval"
|
||||
if interval == "" {
|
||||
assert.NotContains(t, resp.Body.String(), attr)
|
||||
} else {
|
||||
assert.Contains(t, resp.Body.String(), fmt.Sprintf(`%s="%v"`, attr, interval))
|
||||
}
|
||||
}
|
||||
|
||||
// PR issue3 is merageable at the beginning
|
||||
issue3, checkedPrID := run(t, func(t *testing.T) {})
|
||||
assert.Equal(t, issues.PullRequestStatusMergeable, issue3.PullRequest.Status)
|
||||
assert.Zero(t, checkedPrID)
|
||||
setting.IsProd = true
|
||||
assertReloadingInterval(t, "") // the PR is mergeable, so no need to reload the merge box
|
||||
setting.IsProd = false
|
||||
assertReloadingInterval(t, "1") // make sure dev mode always do merge box reloading, to make sure the UI logic won't break
|
||||
setting.IsProd = true
|
||||
|
||||
// when base branch changes, PR status should be updated, but it is inactive for long time, so no real check
|
||||
issue3, checkedPrID = run(t, func(t *testing.T) {
|
||||
testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 1")
|
||||
})
|
||||
assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status)
|
||||
assert.Zero(t, checkedPrID)
|
||||
assertReloadingInterval(t, "2000") // the PR status is "checking", so try to reload the merge box
|
||||
|
||||
// view a PR with status=checking, it starts the real check
|
||||
issue3, checkedPrID = run(t, func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
})
|
||||
assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status)
|
||||
assert.Equal(t, issue3.PullRequest.ID, checkedPrID)
|
||||
|
||||
// when base branch changes, still so no real check
|
||||
issue3, checkedPrID = run(t, func(t *testing.T) {
|
||||
testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 2")
|
||||
})
|
||||
assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status)
|
||||
assert.Zero(t, checkedPrID)
|
||||
|
||||
// then allow to check PRs without delay, when base branch changes, the PRs will be checked
|
||||
setting.Repository.PullRequest.DelayCheckForInactiveDays = -1
|
||||
issue3, checkedPrID = run(t, func(t *testing.T) {
|
||||
testEditFile(t, session, "user2", "repo1", "master", "README.md", "new content 3")
|
||||
})
|
||||
assert.Equal(t, issues.PullRequestStatusChecking, issue3.PullRequest.Status)
|
||||
assert.Equal(t, issue3.PullRequest.ID, checkedPrID)
|
||||
})
|
||||
}
|
||||
|
@ -117,6 +117,8 @@ code.language-math.is-loading::after {
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
/* FIXME: `octicon-sync` is counterclockwise, so this animation is also counterclockwise, it looks somewhat strange.
|
||||
Ideally in the future we should use a better image for clockwise animation. */
|
||||
.circular-spin {
|
||||
animation: circular-spin-keyframes 1s linear infinite;
|
||||
}
|
||||
|
@ -476,14 +476,6 @@ td .commit-summary {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.repository.view.issue .merge.box .branch-update.grid .row {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.repository.view.issue .merge.box .branch-update.grid .row .icon {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.repository.view.issue .comment-list:not(.prevent-before-timeline)::before {
|
||||
display: block;
|
||||
content: "";
|
||||
|
@ -1,10 +0,0 @@
|
||||
import {createApp} from 'vue';
|
||||
import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
|
||||
|
||||
export function initRepoPullRequestMergeForm() {
|
||||
const el = document.querySelector('#pull-request-merge-form');
|
||||
if (!el) return;
|
||||
|
||||
const view = createApp(PullRequestMergeForm);
|
||||
view.mount(el);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export function initRepoPullRequestCommitStatus() {
|
||||
for (const btn of document.querySelectorAll('.commit-status-hide-checks')) {
|
||||
const panel = btn.closest('.commit-status-panel');
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list');
|
||||
btn.addEventListener('click', () => {
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
});
|
||||
}
|
||||
}
|
133
web_src/js/features/repo-issue-pull.ts
Normal file
133
web_src/js/features/repo-issue-pull.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import {createApp} from 'vue';
|
||||
import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
|
||||
function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
|
||||
if (!prUpdateButtonContainer) return;
|
||||
|
||||
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button');
|
||||
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown');
|
||||
prUpdateButton.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const redirect = this.getAttribute('data-redirect');
|
||||
this.classList.add('is-loading');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await POST(this.getAttribute('data-do'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.classList.remove('is-loading');
|
||||
}
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = await response?.json(); // the response is probably not a JSON
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (data?.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else if (redirect) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
fomanticQuery(prUpdateDropdown).dropdown({
|
||||
onChange(_text: string, _value: string, $choice: any) {
|
||||
const choiceEl = $choice[0];
|
||||
const url = choiceEl.getAttribute('data-do');
|
||||
if (url) {
|
||||
const buttonText = prUpdateButton.querySelector('.button-text');
|
||||
if (buttonText) {
|
||||
buttonText.textContent = choiceEl.textContent;
|
||||
}
|
||||
prUpdateButton.setAttribute('data-do', url);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoPullRequestCommitStatus(el: HTMLElement) {
|
||||
for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
|
||||
const panel = btn.closest('.commit-status-panel');
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list');
|
||||
btn.addEventListener('click', () => {
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initRepoPullRequestMergeForm(box: HTMLElement) {
|
||||
const el = box.querySelector('#pull-request-merge-form');
|
||||
if (!el) return;
|
||||
|
||||
const view = createApp(PullRequestMergeForm);
|
||||
view.mount(el);
|
||||
}
|
||||
|
||||
function executeScripts(elem: HTMLElement) {
|
||||
for (const oldScript of elem.querySelectorAll('script')) {
|
||||
// TODO: that's the only way to load the data for the merge form. In the future
|
||||
// we need to completely decouple the page data and embedded script
|
||||
// eslint-disable-next-line github/no-dynamic-script-tag
|
||||
const newScript = document.createElement('script');
|
||||
for (const attr of oldScript.attributes) {
|
||||
if (attr.name === 'type' && attr.value === 'module') continue;
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
newScript.text = oldScript.text;
|
||||
document.body.append(newScript);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
initRepoPullRequestCommitStatus(el);
|
||||
initRepoPullRequestUpdate(el);
|
||||
initRepoPullRequestMergeForm(el);
|
||||
|
||||
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
|
||||
if (!reloadingIntervalValue) return;
|
||||
|
||||
const reloadingInterval = parseInt(reloadingIntervalValue);
|
||||
const pullLink = el.getAttribute('data-pull-link');
|
||||
let timerId: number;
|
||||
|
||||
let reloadMergeBox: () => Promise<void>;
|
||||
const stopReloading = () => {
|
||||
if (!timerId) return;
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
};
|
||||
const startReloading = () => {
|
||||
if (timerId) return;
|
||||
setTimeout(reloadMergeBox, reloadingInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopReloading();
|
||||
} else {
|
||||
startReloading();
|
||||
}
|
||||
};
|
||||
reloadMergeBox = async () => {
|
||||
const resp = await GET(`${pullLink}/merge_box`);
|
||||
stopReloading();
|
||||
if (!resp.ok) {
|
||||
startReloading();
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
const newElem = createElementFromHTML(await resp.text());
|
||||
executeScripts(newElem);
|
||||
el.replaceWith(newElem);
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
startReloading();
|
||||
}
|
@ -197,54 +197,6 @@ export function initRepoIssueCodeCommentCancel() {
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoPullRequestUpdate() {
|
||||
const prUpdateButtonContainer = document.querySelector('#update-pr-branch-with-base');
|
||||
if (!prUpdateButtonContainer) return;
|
||||
|
||||
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button');
|
||||
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown');
|
||||
prUpdateButton.addEventListener('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const redirect = this.getAttribute('data-redirect');
|
||||
this.classList.add('is-loading');
|
||||
let response: Response;
|
||||
try {
|
||||
response = await POST(this.getAttribute('data-do'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.classList.remove('is-loading');
|
||||
}
|
||||
let data: Record<string, any>;
|
||||
try {
|
||||
data = await response?.json(); // the response is probably not a JSON
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
if (data?.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
} else if (redirect) {
|
||||
window.location.href = redirect;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
fomanticQuery(prUpdateDropdown).dropdown({
|
||||
onChange(_text: string, _value: string, $choice: any) {
|
||||
const choiceEl = $choice[0];
|
||||
const url = choiceEl.getAttribute('data-do');
|
||||
if (url) {
|
||||
const buttonText = prUpdateButton.querySelector('.button-text');
|
||||
if (buttonText) {
|
||||
buttonText.textContent = choiceEl.textContent;
|
||||
}
|
||||
prUpdateButton.setAttribute('data-do', url);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoPullRequestAllowMaintainerEdit() {
|
||||
const wrapper = document.querySelector('#allow-edits-from-maintainers');
|
||||
if (!wrapper) return;
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
|
||||
initRepoIssueComments, initRepoIssueReferenceIssue,
|
||||
initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
|
||||
initRepoPullRequestUpdate,
|
||||
} from './repo-issue.ts';
|
||||
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
|
||||
import {initRepoCloneButtons} from './repo-common.ts';
|
||||
@ -12,14 +11,13 @@ import {initCitationFileCopyContent} from './citation.ts';
|
||||
import {initCompLabelEdit} from './comp/LabelEdit.ts';
|
||||
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
|
||||
import {initRepoSettings} from './repo-settings.ts';
|
||||
import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts';
|
||||
import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts';
|
||||
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
|
||||
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
|
||||
import {initRepoMilestone} from './repo-milestone.ts';
|
||||
import {initRepoNew} from './repo-new.ts';
|
||||
import {createApp} from 'vue';
|
||||
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
|
||||
import {initRepoPullMergeBox} from './repo-issue-pull.ts';
|
||||
|
||||
function initRepoBranchTagSelector() {
|
||||
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
|
||||
@ -69,11 +67,9 @@ export function initRepository() {
|
||||
|
||||
initRepoIssueCommentDelete();
|
||||
initRepoIssueCodeCommentCancel();
|
||||
initRepoPullRequestUpdate();
|
||||
initCompReactionSelector();
|
||||
|
||||
initRepoPullRequestMergeForm();
|
||||
initRepoPullRequestCommitStatus();
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
}
|
||||
|
||||
initUnicodeEscapeButton();
|
||||
|
Loading…
x
Reference in New Issue
Block a user