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:
Brecht Van Lommel 2025-04-24 21:26:57 +02:00 committed by GitHub
parent d1ad8e1e80
commit a9343896f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 437 additions and 244 deletions

View File

@ -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 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 ;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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"io" "io"
"regexp" "regexp"
"strconv"
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -104,27 +103,6 @@ const (
PullRequestStatusAncestor 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 // PullRequestFlow the flow of pull request
type PullRequestFlow int type PullRequestFlow int

View File

@ -82,6 +82,7 @@ var (
AddCoCommitterTrailers bool AddCoCommitterTrailers bool
TestConflictingPatchesWithGitApply bool TestConflictingPatchesWithGitApply bool
RetargetChildrenOnMerge bool RetargetChildrenOnMerge bool
DelayCheckForInactiveDays int
} `ini:"repository.pull-request"` } `ini:"repository.pull-request"`
// Issue Setting // Issue Setting
@ -200,6 +201,7 @@ var (
AddCoCommitterTrailers bool AddCoCommitterTrailers bool
TestConflictingPatchesWithGitApply bool TestConflictingPatchesWithGitApply bool
RetargetChildrenOnMerge bool RetargetChildrenOnMerge bool
DelayCheckForInactiveDays int
}{ }{
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"}, WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
// Same as GitHub. See // Same as GitHub. See
@ -215,6 +217,7 @@ var (
PopulateSquashCommentWithCommitMessages: false, PopulateSquashCommentWithCommitMessages: false,
AddCoCommitterTrailers: true, AddCoCommitterTrailers: true,
RetargetChildrenOnMerge: true, RetargetChildrenOnMerge: true,
DelayCheckForInactiveDays: 7,
}, },
// Issue settings // Issue settings

View File

@ -1880,7 +1880,7 @@ pulls.add_prefix = Add <strong>%s</strong> prefix
pulls.remove_prefix = Remove <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.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.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_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.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. pulls.required_status_check_failed = Some required checks were not successful.

View File

@ -202,6 +202,10 @@ func GetPullRequest(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
// Consider API access a view for delayed checking.
pull_service.StartPullRequestCheckOnView(ctx, pr)
ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer))
} }
@ -287,6 +291,10 @@ func GetPullRequestByBaseHead(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
} }
// Consider API access a view for delayed checking.
pull_service.StartPullRequestCheckOnView(ctx, pr)
ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) 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 err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil {
if errors.Is(err, pull_service.ErrIsClosed) { if errors.Is(err, pull_service.ErrIsClosed) {
ctx.APIErrorNotFound() 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") ctx.APIError(http.StatusMethodNotAllowed, "User not allowed to merge PR")
} else if errors.Is(err, pull_service.ErrHasMerged) { } else if errors.Is(err, pull_service.ErrHasMerged) {
ctx.APIError(http.StatusMethodNotAllowed, "") ctx.APIError(http.StatusMethodNotAllowed, "")
@ -929,7 +937,7 @@ func MergePullRequest(ctx *context.APIContext) {
ctx.APIError(http.StatusMethodNotAllowed, "Work in progress PRs cannot be merged") ctx.APIError(http.StatusMethodNotAllowed, "Work in progress PRs cannot be merged")
} else if errors.Is(err, pull_service.ErrNotMergeableState) { } else if errors.Is(err, pull_service.ErrNotMergeableState) {
ctx.APIError(http.StatusMethodNotAllowed, "Please try again later") 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) ctx.APIError(http.StatusMethodNotAllowed, err)
} else if asymkey_service.IsErrWontSign(err) { } else if asymkey_service.IsErrWontSign(err) {
ctx.APIError(http.StatusMethodNotAllowed, err) ctx.APIError(http.StatusMethodNotAllowed, err)

View File

@ -4,6 +4,7 @@
package private package private
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -374,7 +375,7 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
// Check all status checks and reviews are ok // Check all status checks and reviews are ok
if err := pull_service.CheckPullBranchProtections(ctx, pr, true); err != nil { 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()) 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{ 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()), 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()),

View File

@ -43,7 +43,8 @@ const (
tplIssueChoose templates.TplName = "repo/issue/choose" tplIssueChoose templates.TplName = "repo/issue/choose"
tplIssueView templates.TplName = "repo/issue/view" 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" issueTemplateKey = "IssueTemplate"
issueTemplateTitleKey = "IssueTemplateTitle" issueTemplateTitleKey = "IssueTemplateTitle"

View File

@ -96,7 +96,7 @@ func NewComment(ctx *context.Context) {
// Regenerate patch and test conflict. // Regenerate patch and test conflict.
if pr == nil { if pr == nil {
issue.PullRequest.HeadCommitID = "" 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 // 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

View File

@ -31,6 +31,7 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/templates/vars" "code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/util"
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/context/upload"
@ -271,8 +272,23 @@ func combineLabelComments(issue *issues_model.Issue) {
} }
} }
// ViewIssue render issue view page func prepareIssueViewLoad(ctx *context.Context) *issues_model.Issue {
func ViewIssue(ctx *context.Context) { 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 ctx.PathParam("type") == "issues" {
// If issue was requested we check if repo has external tracker and redirect // If issue was requested we check if repo has external tracker and redirect
extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) extIssueUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker)
@ -294,18 +310,18 @@ func ViewIssue(ctx *context.Context) {
return return
} }
} }
}
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) // ViewIssue render issue view page
if err != nil { func ViewIssue(ctx *context.Context) {
if issues_model.IsErrIssueNotExist(err) { handleViewIssueRedirectExternal(ctx)
ctx.NotFound(err) if ctx.Written() {
} else {
ctx.ServerError("GetIssueByIndex", err)
}
return return
} }
if issue.Repo == nil {
issue.Repo = ctx.Repo.Repository issue := prepareIssueViewLoad(ctx)
if ctx.Written() {
return
} }
// Make sure type and URL matches. // Make sure type and URL matches.
@ -337,12 +353,12 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment") upload.AddUploadContext(ctx, "comment")
if err = issue.LoadAttributes(ctx); err != nil { if err := issue.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err) ctx.ServerError("LoadAttributes", err)
return return
} }
if err = filterXRefComments(ctx, issue); err != nil { if err := filterXRefComments(ctx, issue); err != nil {
ctx.ServerError("filterXRefComments", err) ctx.ServerError("filterXRefComments", err)
return return
} }
@ -351,7 +367,7 @@ func ViewIssue(ctx *context.Context) {
if ctx.IsSigned { if ctx.IsSigned {
// Update issue-user. // 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) ctx.ServerError("ReadBy", err)
return return
} }
@ -365,15 +381,13 @@ func ViewIssue(ctx *context.Context) {
prepareFuncs := []func(*context.Context, *issues_model.Issue){ prepareFuncs := []func(*context.Context, *issues_model.Issue){
prepareIssueViewContent, prepareIssueViewContent,
func(ctx *context.Context, issue *issues_model.Issue) {
preparePullViewPullInfo(ctx, issue)
},
prepareIssueViewCommentsAndSidebarParticipants, prepareIssueViewCommentsAndSidebarParticipants,
preparePullViewReviewAndMerge,
prepareIssueViewSidebarWatch, prepareIssueViewSidebarWatch,
prepareIssueViewSidebarTimeTracker, prepareIssueViewSidebarTimeTracker,
prepareIssueViewSidebarDependency, prepareIssueViewSidebarDependency,
prepareIssueViewSidebarPin, prepareIssueViewSidebarPin,
func(ctx *context.Context, issue *issues_model.Issue) { preparePullViewPullInfo(ctx, issue) },
preparePullViewReviewAndMerge,
} }
for _, prepareFunc := range prepareFuncs { for _, prepareFunc := range prepareFuncs {
@ -412,9 +426,25 @@ func ViewIssue(ctx *context.Context) {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) 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) 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) { func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model.Issue) {
if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) {
ctx.Data["IssueDependencySearchType"] = "pulls" ctx.Data["IssueDependencySearchType"] = "pulls"
@ -792,6 +822,8 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss
allowMerge := false allowMerge := false
canWriteToHeadRepo := false canWriteToHeadRepo := false
pull_service.StartPullRequestCheckOnView(ctx, pull)
if ctx.IsSigned { if ctx.IsSigned {
if err := pull.LoadHeadRepo(ctx); err != nil { if err := pull.LoadHeadRepo(ctx); err != nil {
log.Error("LoadHeadRepo: %v", err) 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["CanWriteToHeadRepo"] = canWriteToHeadRepo
ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo
ctx.Data["AllowMerge"] = allowMerge ctx.Data["AllowMerge"] = allowMerge
@ -958,5 +991,4 @@ func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) {
ctx.ServerError("roleDescriptor", err) ctx.ServerError("roleDescriptor", err)
return return
} }
ctx.Data["Issue"] = issue
} }

View File

@ -1052,7 +1052,7 @@ func MergePullRequest(ctx *context.Context) {
} else { } else {
ctx.JSONError(ctx.Tr("repo.issues.closed_title")) 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")) ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed"))
case errors.Is(err, pull_service.ErrHasMerged): case errors.Is(err, pull_service.ErrHasMerged):
ctx.JSONError(ctx.Tr("repo.pulls.has_merged")) 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")) ctx.JSONError(ctx.Tr("repo.pulls.no_merge_wip"))
case errors.Is(err, pull_service.ErrNotMergeableState): case errors.Is(err, pull_service.ErrNotMergeableState):
ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) 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")) ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready"))
case asymkey_service.IsErrWontSign(err): case asymkey_service.IsErrWontSign(err):
ctx.JSONError(err.Error()) // has no translation ... ctx.JSONError(err.Error()) // has no translation ...

View File

@ -1505,6 +1505,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue) m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewIssue)
m.Get(".diff", repo.DownloadPullDiff) m.Get(".diff", repo.DownloadPullDiff)
m.Get(".patch", repo.DownloadPullPatch) m.Get(".patch", repo.DownloadPullPatch)
m.Get("/merge_box", repo.ViewPullMergeBox)
m.Group("/commits", func() { m.Group("/commits", func() {
m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits) m.Get("", repo.SetWhitespaceBehavior, repo.GetPullDiffStats, repo.ViewPullCommits)
m.Get("/list", repo.GetPullCommits) m.Get("/list", repo.GetPullCommits)

View File

@ -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) 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) err = pr.LoadIssue(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load pull issue. Error: %w", err) return nil, fmt.Errorf("failed to load pull issue. Error: %w", err)

View File

@ -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 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) log.Info("%-v was scheduled to automerge by an unauthorized user", pr)
return return
} }

View File

@ -556,7 +556,7 @@ func (g *GiteaLocalUploader) CreatePullRequests(ctx context.Context, prs ...*bas
} }
for _, pr := range gprs { for _, pr := range gprs {
g.issues[pr.Issue.Index] = pr.Issue g.issues[pr.Issue.Index] = pr.Issue
pull.AddToTaskQueue(ctx, pr) pull.StartPullRequestCheckImmediately(ctx, pr)
} }
return nil return nil
} }

View File

@ -10,6 +10,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
@ -25,6 +26,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
asymkey_service "code.gitea.io/gitea/services/asymkey" asymkey_service "code.gitea.io/gitea/services/asymkey"
notify_service "code.gitea.io/gitea/services/notify" notify_service "code.gitea.io/gitea/services/notify"
@ -34,27 +36,88 @@ import (
var prPatchCheckerQueue *queue.WorkerPoolQueue[string] var prPatchCheckerQueue *queue.WorkerPoolQueue[string]
var ( var (
ErrIsClosed = errors.New("pull is closed") ErrIsClosed = errors.New("pull is closed")
ErrUserNotAllowedToMerge = ErrDisallowedToMerge{} ErrNoPermissionToMerge = errors.New("no permission to merge")
ErrHasMerged = errors.New("has already been merged") ErrNotReadyToMerge = errors.New("not ready to merge")
ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged") ErrHasMerged = errors.New("has already been merged")
ErrIsChecking = errors.New("cannot merge while conflict checking is in progress") ErrIsWorkInProgress = errors.New("work in progress PRs cannot be merged")
ErrNotMergeableState = errors.New("not in mergeable state") ErrIsChecking = errors.New("cannot merge while conflict checking is in progress")
ErrDependenciesLeft = errors.New("is blocked by an open dependency") 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 markPullRequestStatusAsChecking(ctx context.Context, pr *issues_model.PullRequest) bool {
func AddToTaskQueue(ctx context.Context, pr *issues_model.PullRequest) {
pr.Status = issues_model.PullRequestStatusChecking pr.Status = issues_model.PullRequestStatusChecking
err := pr.UpdateColsIfNotMerged(ctx, "status") err := pr.UpdateColsIfNotMerged(ctx, "status")
if err != nil { 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 return
} }
log.Trace("Adding %-v to the test pull requests queue", pr) AddPullRequestToCheckQueue(pr.ID)
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) // 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) log.Error("Error whilst checking if %-v is allowed to merge %-v: %v", doer, pr, err)
return err return err
} else if !allowedMerge { } else if !allowedMerge {
return ErrUserNotAllowedToMerge return ErrNoPermissionToMerge
} }
if mergeCheckType == MergeCheckTypeManually { 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 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) log.Error("Error whilst checking pull branch protection for %-v: %v", pr, err)
return err return err
} }
@ -172,10 +235,10 @@ func isSignedIfRequired(ctx context.Context, pr *issues_model.PullRequest, doer
return sign, err 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. // and set to be either conflict or mergeable.
func checkAndUpdateStatus(ctx context.Context, pr *issues_model.PullRequest) { func markPullRequestAsMergeable(ctx context.Context, pr *issues_model.PullRequest) {
// If status has not been changed to conflict by testPatch then we are mergeable // If status has not been changed to conflict by testPullRequestTmpRepoBranchMergeable then we are mergeable
if pr.Status == issues_model.PullRequestStatusChecking { if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable 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. // InitializePullRequests checks and tests untested patches of pull requests.
func InitializePullRequests(ctx context.Context) { 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) prs, err := issues_model.GetPullRequestIDsByCheckStatus(ctx, issues_model.PullRequestStatusChecking)
if err != nil { if err != nil {
log.Error("Find Checking PRs: %v", err) log.Error("Find Checking PRs: %v", err)
@ -320,24 +387,12 @@ func InitializePullRequests(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
default: default:
log.Trace("Adding PR[%d] to the pull requests patch checking queue", prID) AddPullRequestToCheckQueue(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)
}
} }
} }
} }
// handle passed PR IDs and test the PRs func checkPullRequestMergeable(id int64) {
func handler(items ...string) []string {
for _, s := range items {
id, _ := strconv.ParseInt(s, 10, 64)
testPR(id)
}
return nil
}
func testPR(id int64) {
ctx := graceful.GetManager().HammerContext() ctx := graceful.GetManager().HammerContext()
releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(id)) releaser, err := globallock.Lock(ctx, getPullWorkingLockKey(id))
if err != nil { if err != nil {
@ -351,7 +406,7 @@ func testPR(id int64) {
pr, err := issues_model.GetPullRequestByID(ctx, id) pr, err := issues_model.GetPullRequestByID(ctx, id)
if err != nil { 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 return
} }
@ -370,15 +425,15 @@ func testPR(id int64) {
return return
} }
if err := TestPatch(pr); err != nil { if err := testPullRequestBranchMergeable(pr); err != nil {
log.Error("testPatch[%-v]: %v", pr, err) log.Error("testPullRequestTmpRepoBranchMergeable[%-v]: %v", pr, err)
pr.Status = issues_model.PullRequestStatusError pr.Status = issues_model.PullRequestStatusError
if err := pr.UpdateCols(ctx, "status"); err != nil { if err := pr.UpdateCols(ctx, "status"); err != nil {
log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err) log.Error("update pr [%-v] status to PullRequestStatusError failed: %v", pr, err)
} }
return return
} }
checkAndUpdateStatus(ctx, pr) markPullRequestAsMergeable(ctx, pr)
} }
// CheckPRsForBaseBranch check all pulls with baseBrannch // CheckPRsForBaseBranch check all pulls with baseBrannch
@ -387,17 +442,21 @@ func CheckPRsForBaseBranch(ctx context.Context, baseRepo *repo_model.Repository,
if err != nil { if err != nil {
return err return err
} }
for _, pr := range prs { for _, pr := range prs {
AddToTaskQueue(ctx, pr) StartPullRequestCheckImmediately(ctx, pr)
} }
return nil return nil
} }
// Init runs the task queue to test all the checking status pull requests // Init runs the task queue to test all the checking status pull requests
func Init() error { 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 { if prPatchCheckerQueue == nil {
return errors.New("unable to create pr_patch_checker queue") return errors.New("unable to create pr_patch_checker queue")

View File

@ -36,7 +36,7 @@ func TestPullRequest_AddToTaskQueue(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
AddToTaskQueue(db.DefaultContext, pr) StartPullRequestCheckImmediately(db.DefaultContext, pr)
assert.Eventually(t, func() bool { assert.Eventually(t, func() bool {
pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})

View File

@ -518,25 +518,6 @@ func IsUserAllowedToMerge(ctx context.Context, pr *issues_model.PullRequest, p a
return false, nil 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) // 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) { func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullRequest, skipProtectedFilesCheck bool) (err error) {
if err = pr.LoadBaseRepo(ctx); err != nil { if err = pr.LoadBaseRepo(ctx); err != nil {
@ -556,31 +537,21 @@ func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullReques
return err return err
} }
if !isPass { if !isPass {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "Not all required status checks successful")
Reason: "Not all required status checks successful",
}
} }
if !issues_model.HasEnoughApprovals(ctx, pb, pr) { if !issues_model.HasEnoughApprovals(ctx, pb, pr) {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "Does not have enough approvals")
Reason: "Does not have enough approvals",
}
} }
if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) { if issues_model.MergeBlockedByRejectedReview(ctx, pb, pr) {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "There are requested changes")
Reason: "There are requested changes",
}
} }
if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) { if issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pr) {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "There are official review requests")
Reason: "There are official review requests",
}
} }
if issues_model.MergeBlockedByOutdatedBranch(pb, pr) { if issues_model.MergeBlockedByOutdatedBranch(pb, pr) {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "The head branch is behind the base branch")
Reason: "The head branch is behind the base branch",
}
} }
if skipProtectedFilesCheck { if skipProtectedFilesCheck {
@ -588,9 +559,7 @@ func CheckPullBranchProtections(ctx context.Context, pr *issues_model.PullReques
} }
if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) { if pb.MergeBlockedByProtectedFiles(pr.ChangedProtectedFiles) {
return ErrDisallowedToMerge{ return util.ErrorWrap(ErrNotReadyToMerge, "Changed protected files")
Reason: "Changed protected files",
}
} }
return nil return nil
@ -709,7 +678,7 @@ func SetMerged(ctx context.Context, pr *issues_model.PullRequest, mergedCommitID
return false, fmt.Errorf("ChangeIssueStatus: %w", err) 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). if cnt, err := db.GetEngine(ctx).Where("id = ?", pr.ID).
And("has_merged = ?", false). And("has_merged = ?", false).
Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files"). Cols("has_merged, status, merge_base, merged_commit_id, merger_id, merged_unix, conflicted_files").

View File

@ -23,7 +23,7 @@ import (
) )
type mergeContext struct { type mergeContext struct {
*prContext *prTmpRepoContext
doer *user_model.User doer *user_model.User
sig *git.Signature sig *git.Signature
committer *git.Signature committer *git.Signature
@ -68,8 +68,8 @@ func createTemporaryRepoForMerge(ctx context.Context, pr *issues_model.PullReque
} }
mergeCtx = &mergeContext{ mergeCtx = &mergeContext{
prContext: prCtx, prTmpRepoContext: prCtx,
doer: doer, doer: doer,
} }
if expectedHeadCommitID != "" { if expectedHeadCommitID != "" {

View File

@ -67,9 +67,8 @@ var patchErrorSuffices = []string{
": does not exist in index", ": does not exist in index",
} }
// TestPatch will test whether a simple patch will apply func testPullRequestBranchMergeable(pr *issues_model.PullRequest) error {
func TestPatch(pr *issues_model.PullRequest) error { ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("testPullRequestBranchMergeable: %s", pr))
ctx, _, finished := process.GetManager().AddContext(graceful.GetManager().HammerContext(), fmt.Sprintf("TestPatch: %s", pr))
defer finished() defer finished()
prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr) prCtx, cancel, err := createTemporaryRepoForPR(ctx, pr)
@ -81,10 +80,10 @@ func TestPatch(pr *issues_model.PullRequest) error {
} }
defer cancel() 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) gitRepo, err := git.OpenRepository(ctx, prCtx.tmpBasePath)
if err != nil { if err != nil {
return fmt.Errorf("OpenRepository: %w", err) return fmt.Errorf("OpenRepository: %w", err)
@ -380,7 +379,7 @@ func checkConflicts(ctx context.Context, pr *issues_model.PullRequest, gitRepo *
return false, nil 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 // 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}) _, _, 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) scanner := bufio.NewScanner(stderrReader)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() 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) { if strings.HasPrefix(line, prefix) {
conflict = true conflict = true
filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0]) filepath := strings.TrimSpace(strings.Split(line[len(prefix):], ":")[0])

View File

@ -96,7 +96,7 @@ func NewPullRequest(ctx context.Context, opts *NewPullRequestOptions) error {
} }
defer cancel() defer cancel()
if err := testPatch(ctx, prCtx, pr); err != nil { if err := testPullRequestTmpRepoBranchMergeable(ctx, prCtx, pr); err != nil {
return err return err
} }
@ -314,12 +314,12 @@ func ChangeTargetBranch(ctx context.Context, pr *issues_model.PullRequest, doer
pr.BaseBranch = targetBranch pr.BaseBranch = targetBranch
// Refresh patch // Refresh patch
if err := TestPatch(pr); err != nil { if err := testPullRequestBranchMergeable(pr); err != nil {
return err return err
} }
// Update target branch, PR diff and status // 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 { if pr.Status == issues_model.PullRequestStatusChecking {
pr.Status = issues_model.PullRequestStatusMergeable pr.Status = issues_model.PullRequestStatusMergeable
} }
@ -409,7 +409,7 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
continue continue
} }
AddToTaskQueue(ctx, pr) StartPullRequestCheckImmediately(ctx, pr)
comment, err := CreatePushPullComment(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID) comment, err := CreatePushPullComment(ctx, opts.Doer, pr, opts.OldCommitID, opts.NewCommitID)
if err == nil && comment != nil { if err == nil && comment != nil {
notify_service.PullRequestPushCommits(ctx, opts.Doer, pr, comment) notify_service.PullRequestPushCommits(ctx, opts.Doer, pr, comment)
@ -502,7 +502,7 @@ func AddTestPullRequestTask(opts TestPullRequestOptions) {
log.Error("UpdateCommitDivergence: %v", err) log.Error("UpdateCommitDivergence: %v", err)
} }
} }
AddToTaskQueue(ctx, pr) StartPullRequestCheckDelayable(ctx, pr)
} }
}) })
} }

View File

@ -28,7 +28,7 @@ const (
stagingBranch = "staging" // this is used for a working branch stagingBranch = "staging" // this is used for a working branch
) )
type prContext struct { type prTmpRepoContext struct {
context.Context context.Context
tmpBasePath string tmpBasePath string
pr *issues_model.PullRequest 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 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.outbuf.Reset()
ctx.errbuf.Reset() ctx.errbuf.Reset()
return &git.RunOpts{ 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 // 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" // 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 { if err := pr.LoadHeadRepo(ctx); err != nil {
log.Error("%-v LoadHeadRepo: %v", pr, err) log.Error("%-v LoadHeadRepo: %v", pr, err)
return nil, nil, fmt.Errorf("%v LoadHeadRepo: %w", 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 cancel = cleanup
prCtx = &prContext{ prCtx = &prTmpRepoContext{
Context: ctx, Context: ctx,
tmpBasePath: tmpBasePath, tmpBasePath: tmpBasePath,
pr: pr, pr: pr,

View File

@ -68,7 +68,7 @@
{{template "repo/issue/view_content/comments" .}} {{template "repo/issue/view_content/comments" .}}
{{if and .Issue.IsPull (not $.Repository.IsArchived)}} {{if and .Issue.IsPull (not $.Repository.IsArchived)}}
{{template "repo/issue/view_content/pull".}} {{template "repo/issue/view_content/pull_merge_box".}}
{{end}} {{end}}
{{if .IsSigned}} {{if .IsSigned}}

View File

@ -1,7 +1,13 @@
{{if and .Issue.PullRequest.HasMerged (not .IsPullBranchDeletable)}} {{if and .Issue.PullRequest.HasMerged (not .IsPullBranchDeletable)}}
{{/* Then the merge box will not be displayed because this page already contains enough information */}} {{/* Then the merge box will not be displayed because this page already contains enough information */}}
{{else}} {{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 <div class="timeline-avatar text {{if .Issue.PullRequest.HasMerged}}purple
{{- else if .Issue.IsClosed}}grey {{- else if .Issue.IsClosed}}grey
{{- else if .IsPullWorkInProgress}}grey {{- else if .IsPullWorkInProgress}}grey
@ -97,7 +103,7 @@
{{template "repo/issue/view_content/update_branch_by_merge" $}} {{template "repo/issue/view_content/update_branch_by_merge" $}}
{{else if .Issue.PullRequest.IsChecking}} {{else if .Issue.PullRequest.IsChecking}}
<div class="item"> <div class="item">
{{svg "octicon-sync"}} {{svg "octicon-sync" 16 "circular-spin"}}
{{ctx.Locale.Tr "repo.pulls.is_checking"}} {{ctx.Locale.Tr "repo.pulls.is_checking"}}
</div> </div>
{{else if .Issue.PullRequest.IsAncestor}} {{else if .Issue.PullRequest.IsAncestor}}
@ -191,10 +197,11 @@
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
{{template "repo/issue/view_content/update_branch_by_merge" $}} {{template "repo/issue/view_content/update_branch_by_merge" $}}
{{if .Issue.PullRequest.IsEmpty}} {{if .Issue.PullRequest.IsEmpty}}
<div class="divider"></div> <div class="divider"></div>
<div class="item"> <div class="item">
{{svg "octicon-alert"}} {{svg "octicon-alert"}}
{{ctx.Locale.Tr "repo.pulls.is_empty"}} {{ctx.Locale.Tr "repo.pulls.is_empty"}}
@ -216,7 +223,7 @@
const defaultMergeMessage = {{.DefaultMergeBody}}; const defaultMergeMessage = {{.DefaultMergeBody}};
const defaultSquashMergeMessage = {{.DefaultSquashMergeBody}}; const defaultSquashMergeMessage = {{.DefaultSquashMergeBody}};
const mergeForm = { const mergeForm = {
'baseLink': {{.Link}}, 'baseLink': {{.Issue.Link}},
'textCancel': {{ctx.Locale.Tr "cancel"}}, 'textCancel': {{ctx.Locale.Tr "cancel"}},
'textDeleteBranch': {{ctx.Locale.Tr "repo.branch.delete" .HeadTarget}}, 'textDeleteBranch': {{ctx.Locale.Tr "repo.branch.delete" .HeadTarget}},
'textAutoMergeButtonWhenSucceed': {{ctx.Locale.Tr "repo.pulls.auto_merge_button_when_succeed"}}, 'textAutoMergeButtonWhenSucceed': {{ctx.Locale.Tr "repo.pulls.auto_merge_button_when_succeed"}},
@ -318,7 +325,7 @@
{{if .IsBlockedByApprovals}} {{if .IsBlockedByApprovals}}
<div class="item text red"> <div class="item text red">
{{svg "octicon-x"}} {{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> </div>
{{else if .IsBlockedByRejection}} {{else if .IsBlockedByRejection}}
<div class="item text red"> <div class="item text red">
@ -377,7 +384,7 @@
*/}} */}}
{{if and $.StillCanManualMerge (not $showGeneralMergeForm)}} {{if and $.StillCanManualMerge (not $showGeneralMergeForm)}}
<div class="divider"></div> <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}} {{.CsrfTokenHtml}}
<div class="field"> <div class="field">
<input type="text" name="merge_commit_id" placeholder="{{ctx.Locale.Tr "repo.pulls.merge_commit_id"}}"> <input type="text" name="merge_commit_id" placeholder="{{ctx.Locale.Tr "repo.pulls.merge_commit_id"}}">

View File

@ -9,7 +9,7 @@
{{if and $.UpdateAllowed $.UpdateByRebaseAllowed}} {{if and $.UpdateAllowed $.UpdateByRebaseAllowed}}
<div class="tw-inline-block"> <div class="tw-inline-block">
<div id="update-pr-branch-with-base" class="ui buttons"> <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"> <span class="button-text">
{{ctx.Locale.Tr "repo.pulls.update_branch"}} {{ctx.Locale.Tr "repo.pulls.update_branch"}}
</span> </span>
@ -17,15 +17,15 @@
<div class="ui dropdown icon button"> <div class="ui dropdown icon button">
{{svg "octicon-triangle-down"}} {{svg "octicon-triangle-down"}}
<div class="menu"> <div class="menu">
<a class="item active selected" data-do="{{$.Link}}/update">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</a> <a class="item active selected" data-do="{{$.Issue.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" data-do="{{$.Issue.Link}}/update?style=rebase">{{ctx.Locale.Tr "repo.pulls.update_branch_rebase"}}</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{end}} {{end}}
{{if and $.UpdateAllowed (not $.UpdateByRebaseAllowed)}} {{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}} {{$.CsrfTokenHtml}}
<button class="ui compact button"> <button class="ui compact button">
<span class="ui text">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</span> <span class="ui text">{{ctx.Locale.Tr "repo.pulls.update_branch"}}</span>

View File

@ -13,9 +13,13 @@ import (
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/services/pull"
"github.com/stretchr/testify/assert" "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.") 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)
})
}

View File

@ -117,6 +117,8 @@ code.language-math.is-loading::after {
animation-timing-function: ease-in-out; 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 { .circular-spin {
animation: circular-spin-keyframes 1s linear infinite; animation: circular-spin-keyframes 1s linear infinite;
} }

View File

@ -476,14 +476,6 @@ td .commit-summary {
margin-right: 5px; 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 { .repository.view.issue .comment-list:not(.prevent-before-timeline)::before {
display: block; display: block;
content: ""; content: "";

View File

@ -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);
}

View File

@ -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');
});
}
}

View 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();
}

View File

@ -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() { export function initRepoPullRequestAllowMaintainerEdit() {
const wrapper = document.querySelector('#allow-edits-from-maintainers'); const wrapper = document.querySelector('#allow-edits-from-maintainers');
if (!wrapper) return; if (!wrapper) return;

View File

@ -4,7 +4,6 @@ import {
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueReferenceIssue, initRepoIssueComments, initRepoIssueReferenceIssue,
initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle, initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
initRepoPullRequestUpdate,
} from './repo-issue.ts'; } from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoCloneButtons} from './repo-common.ts'; import {initRepoCloneButtons} from './repo-common.ts';
@ -12,14 +11,13 @@ import {initCitationFileCopyContent} from './citation.ts';
import {initCompLabelEdit} from './comp/LabelEdit.ts'; import {initCompLabelEdit} from './comp/LabelEdit.ts';
import {initCompReactionSelector} from './comp/ReactionSelector.ts'; import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoSettings} from './repo-settings.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 {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts'; import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts'; import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts'; import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue'; import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue'; import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
import {initRepoPullMergeBox} from './repo-issue-pull.ts';
function initRepoBranchTagSelector() { function initRepoBranchTagSelector() {
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => { registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
@ -69,11 +67,9 @@ export function initRepository() {
initRepoIssueCommentDelete(); initRepoIssueCommentDelete();
initRepoIssueCodeCommentCancel(); initRepoIssueCodeCommentCancel();
initRepoPullRequestUpdate();
initCompReactionSelector(); initCompReactionSelector();
initRepoPullRequestMergeForm(); registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
initRepoPullRequestCommitStatus();
} }
initUnicodeEscapeButton(); initUnicodeEscapeButton();