diff --git a/models/pull/automerge.go b/models/pull/automerge.go index 3a6514f6cf0d5..ab6fb398ba96f 100644 --- a/models/pull/automerge.go +++ b/models/pull/automerge.go @@ -80,6 +80,23 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe return true, scheduledPRM, err } +// GetScheduledMergeByPullIDs returns the scheduled auto merges for the given pull request IDs, keyed by pull ID. +// The returned entries do not have their Doer loaded. +func GetScheduledMergeByPullIDs(ctx context.Context, pullIDs []int64) (map[int64]*AutoMerge, error) { + if len(pullIDs) == 0 { + return map[int64]*AutoMerge{}, nil + } + merges := make([]*AutoMerge, 0, len(pullIDs)) + if err := db.GetEngine(ctx).In("pull_id", pullIDs).Find(&merges); err != nil { + return nil, err + } + result := make(map[int64]*AutoMerge, len(merges)) + for _, m := range merges { + result[m.PullID] = m + } + return result, nil +} + func GetScheduledMergePullIDsSince(ctx context.Context, since timeutil.TimeStamp) ([]int64, error) { var pullIDs []int64 err := db.GetEngine(ctx).Table(&AutoMerge{}).Where("created_unix >= ?", since).Cols("pull_id").Find(&pullIDs) diff --git a/modules/structs/pull.go b/modules/structs/pull.go index cd2ffbe719595..14f369b19e26a 100644 --- a/modules/structs/pull.go +++ b/modules/structs/pull.go @@ -61,6 +61,8 @@ type PullRequest struct { // Whether the pull request can be merged Mergeable bool `json:"mergeable"` + // The scheduled auto merge, null if the pull request is not scheduled to auto merge + AutoMerge *PullRequestAutoMerge `json:"auto_merge"` // Whether the pull request has been merged HasMerged bool `json:"merged"` // swagger:strfmt date-time @@ -95,6 +97,18 @@ type PullRequest struct { ContentVersion int `json:"content_version"` } +// PullRequestAutoMerge represents a pull request scheduled to auto merge when all checks succeed +type PullRequestAutoMerge struct { + // The user who scheduled the auto merge + EnabledBy *User `json:"enabled_by"` + // The merge method that will be used, eg: "merge", "rebase", "rebase-merge", "squash", "fast-forward-only" + MergeMethod string `json:"merge_method"` + // The title of the resulting merge commit + CommitTitle string `json:"commit_title"` + // The message of the resulting merge commit + CommitMessage string `json:"commit_message"` +} + // PRBranchInfo information about a branch type PRBranchInfo struct { // The display name of the branch diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 7753c6d71c0bb..d933cac21aba9 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1839,8 +1839,8 @@ "repo.pulls.is_empty": "The changes on this branch are already on the target branch. This will be an empty commit.", "repo.pulls.required_status_check_failed": "Some required checks were not successful.", "repo.pulls.required_status_check_missing": "Some required checks are missing.", - "repo.pulls.required_status_check_administrator": "As an administrator, you may still merge this pull request.", - "repo.pulls.required_status_check_bypass_allowlist": "You are allowed to bypass branch protection rules for this merge.", + "repo.pulls.merging_is_blocked": "Merging is blocked", + "repo.pulls.merge_bypass_rules": "Merge without waiting for requirements to be met (bypass rules)", "repo.pulls.blocked_by_approvals": "This pull request doesn't have enough required approvals yet. %d of %d official approvals granted.", "repo.pulls.blocked_by_approvals_whitelisted": "This pull request doesn't have enough required approvals yet. %d of %d approvals granted from users or teams on the allowlist.", "repo.pulls.blocked_by_rejection": "This pull request has changes requested by an official reviewer.", @@ -1910,7 +1910,8 @@ "repo.pulls.reopen": "Reopen Pull Request", "repo.pulls.closed_at": "closed this pull request %[2]s", "repo.pulls.reopened_at": "reopened this pull request %[2]s", - "repo.pulls.cmd_instruction_hint": "View command line instructions", + "repo.pulls.cmd_instruction_hint": "Show command line instructions", + "repo.pulls.cmd_instruction_modal_title": "Checkout via the command line", "repo.pulls.cmd_instruction_checkout_title": "Checkout", "repo.pulls.cmd_instruction_checkout_desc": "From your project repository, check out a new branch and test the changes.", "repo.pulls.cmd_instruction_merge_title": "Merge", @@ -1918,10 +1919,14 @@ "repo.pulls.cmd_instruction_merge_warning": "Warning: This operation cannot merge pull request because \"autodetect manual merge\" is not enabled.", "repo.pulls.clear_merge_message": "Clear merge message", "repo.pulls.clear_merge_message_hint": "Clearing the merge message will only remove the commit message content and keep generated git trailers such as \"Co-Authored-By…\".", - "repo.pulls.auto_merge_button_when_succeed": "(When checks succeed)", - "repo.pulls.auto_merge_when_succeed": "Auto merge when all checks succeed", + "repo.pulls.enable_auto_merge": "Enable auto merge (%s)", + "repo.pulls.merge_style_short_merge": "merge commit", + "repo.pulls.merge_style_short_rebase": "rebase", + "repo.pulls.merge_style_short_rebase_merge": "rebase and merge", + "repo.pulls.merge_style_short_squash": "squash", + "repo.pulls.merge_style_short_fast_forward_only": "fast-forward", "repo.pulls.auto_merge_newly_scheduled": "The pull request was scheduled to merge when all checks succeed.", - "repo.pulls.auto_merge_has_pending_schedule": "%[1]s scheduled this pull request to auto merge when all checks succeed %[2]s.", + "repo.pulls.auto_merge_has_pending_schedule": "%[1]s scheduled this pull request to auto merge (%[3]s) when all checks succeed %[2]s.", "repo.pulls.auto_merge_cancel_schedule": "Cancel auto merge", "repo.pulls.auto_merge_not_scheduled": "This pull request is not scheduled to auto merge.", "repo.pulls.auto_merge_canceled_schedule": "The auto merge was canceled for this pull request.", diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index f5b99003aaad1..53c4f69f413ce 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -951,10 +951,8 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue * data.hasStatusCheckBlocker data.canBypassProtection = isRepoAdmin - data.canBypassProtectionAsAdmin = isRepoAdmin if ctx.IsSigned && prInfo.ProtectedBranchRule != nil { data.canBypassProtection = git_model.CanBypassBranchProtection(ctx, prInfo.ProtectedBranchRule, ctx.Doer, isRepoAdmin) - data.canBypassProtectionAsAdmin = isRepoAdmin && !prInfo.ProtectedBranchRule.BlockAdminMergeOverride } // CanMergeNow means: if the doer has write permission, whether the PR can be merged now diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 9caba3ae3a476..a16e31baa1e5a 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -274,11 +274,10 @@ type pullMergeBoxData struct { // The latter gate the merge even when the rule's own status check is disabled. hasRequiredStatusContexts bool - hasOverridableBlockers bool - canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers - hasPermToMerge bool // doer has permission to merge - canBypassProtection bool - canBypassProtectionAsAdmin bool + hasOverridableBlockers bool + canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers + hasPermToMerge bool // doer has permission to merge + canBypassProtection bool ShowUpdatePullInfo bool UpdatePrimaryAction *pullUpdateAction diff --git a/routers/web/repo/pull_merge_box.go b/routers/web/repo/pull_merge_box.go index d76aacde2f771..8baf751bbb2a4 100644 --- a/routers/web/repo/pull_merge_box.go +++ b/routers/web/repo/pull_merge_box.go @@ -16,10 +16,12 @@ type pullMergeBoxInfoItem struct { SvgIconHTML template.HTML InfoHTML template.HTML ListItems []template.HTML + ExtraClass string } type pullMergeBoxInfoItemCollection struct { - items []*pullMergeBoxInfoItem + items []*pullMergeBoxInfoItem + errorCount int } type pullInfoSection struct { @@ -41,12 +43,15 @@ func (c *pullMergeBoxInfoItemCollection) AddInfoItem(svg, info template.HTML, op }) } +// AddErrorItem adds a blocking reason, rendered as a muted sub-row of the "merging is blocked" heading func (c *pullMergeBoxInfoItemCollection) AddErrorItem(info template.HTML, optItems ...[]template.HTML) { c.items = append(c.items, &pullMergeBoxInfoItem{ - SvgIconHTML: svg.RenderHTML("octicon-x", 16, "tw-text-red"), + SvgIconHTML: svg.RenderHTML("octicon-dot-fill", 16, "tw-text-text-light"), InfoHTML: info, ListItems: util.OptionalArg(optItems), + ExtraClass: "tw-pl-6 tw-text-text-light", }) + c.errorCount++ } func (prInfo *pullRequestViewInfo) prepareMergeBoxIconColor() { @@ -164,28 +169,27 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context ) } - if data.canMergeNow { - if data.hasOverridableBlockers { - prompt := ctx.Locale.Tr("repo.pulls.required_status_check_bypass_allowlist") - if data.canBypassProtectionAsAdmin { - prompt = ctx.Locale.Tr("repo.pulls.required_status_check_administrator") - } - prInfo.MergeBoxData.infoMergePrompts.AddInfoItem( - svg.RenderHTML("octicon-dot-fill"), - prompt, - ) - } else if pull.IsStatusMergeable() || pull.IsEmpty() { - prInfo.MergeBoxData.infoMergePrompts.AddInfoItem( - svg.RenderHTML("octicon-check"), - ctx.Locale.Tr("repo.pulls.can_auto_merge_desc"), - ) - } + // when the doer can bypass overridable blockers, the merge form offers switch-to-force-merge, + // so no separate admin/allowlist prompt is needed here; only show the positive "can be merged" hint when clear + if data.canMergeNow && !data.hasOverridableBlockers && (pull.IsStatusMergeable() || pull.IsEmpty()) { + prInfo.MergeBoxData.infoMergePrompts.AddInfoItem( + svg.RenderHTML("octicon-check"), + ctx.Locale.Tr("repo.pulls.can_auto_merge_desc"), + ) } if len(data.infoCommitBlockers.items) > 0 { data.InfoSections = append(data.InfoSections, &pullInfoSection{data.infoCommitBlockers.items}) } else { - data.InfoSections = append(data.InfoSections, &pullInfoSection{data.infoProtectionBlockers.items}) + items := data.infoProtectionBlockers.items + if data.infoProtectionBlockers.errorCount > 0 { + heading := &pullMergeBoxInfoItem{ + SvgIconHTML: svg.RenderHTML("octicon-x", 16, "tw-text-red"), + InfoHTML: htmlutil.HTMLFormat("%s", ctx.Locale.Tr("repo.pulls.merging_is_blocked")), + } + items = append([]*pullMergeBoxInfoItem{heading}, items...) + } + data.InfoSections = append(data.InfoSections, &pullInfoSection{items}) } data.InfoSections = append(data.InfoSections, &pullInfoSection{data.infoMergePrompts.items}) } diff --git a/routers/web/repo/pull_merge_form.go b/routers/web/repo/pull_merge_form.go index fe12aa6105beb..28028205aa644 100644 --- a/routers/web/repo/pull_merge_form.go +++ b/routers/web/repo/pull_merge_form.go @@ -19,6 +19,16 @@ import ( pull_service "gitea.dev/services/pull" ) +// mergeStyleShortLocaleKeys maps a merge style to the locale key of its short, human-readable label. +// Only auto-merge-capable styles have an entry; callers fall back to the raw style for anything else. +var mergeStyleShortLocaleKeys = map[repo_model.MergeStyle]string{ + repo_model.MergeStyleMerge: "repo.pulls.merge_style_short_merge", + repo_model.MergeStyleRebase: "repo.pulls.merge_style_short_rebase", + repo_model.MergeStyleRebaseMerge: "repo.pulls.merge_style_short_rebase_merge", + repo_model.MergeStyleSquash: "repo.pulls.merge_style_short_squash", + repo_model.MergeStyleFastForwardOnly: "repo.pulls.merge_style_short_fast_forward_only", +} + func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context) { pull := prInfo.issue.PullRequest if pull.HasMerged || prInfo.issue.IsClosed { @@ -61,7 +71,11 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context var hasPendingPullRequestMergeTip template.HTML if hasPendingPullRequestMerge { createdPRMergeStr := templates.TimeSince(pendingPullRequestMerge.CreatedUnix) - hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr) + styleShort := any(string(pendingPullRequestMerge.MergeStyle)) + if key, ok := mergeStyleShortLocaleKeys[pendingPullRequestMerge.MergeStyle]; ok { + styleShort = ctx.Locale.Tr(key) + } + hasPendingPullRequestMergeTip = ctx.Locale.Tr("repo.pulls.auto_merge_has_pending_schedule", pendingPullRequestMerge.Doer.Name, createdPRMergeStr, styleShort) } var defaultMergeTitle, defaultMergeBody string @@ -85,18 +99,18 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers mergeFormProps := map[string]any{ - "baseLink": prInfo.issue.Link(), - "textCancel": ctx.Locale.Tr("cancel"), - "textDeleteBranch": ctx.Locale.Tr("repo.branch.delete", prInfo.headTarget), - "textAutoMergeButtonWhenSucceed": ctx.Locale.Tr("repo.pulls.auto_merge_button_when_succeed"), - "textAutoMergeWhenSucceed": ctx.Locale.Tr("repo.pulls.auto_merge_when_succeed"), - "textAutoMergeCancelSchedule": ctx.Locale.Tr("repo.pulls.auto_merge_cancel_schedule"), - "textClearMergeMessage": ctx.Locale.Tr("repo.pulls.clear_merge_message"), - "textClearMergeMessageHint": ctx.Locale.Tr("repo.pulls.clear_merge_message_hint"), - "textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"), + "baseLink": prInfo.issue.Link(), + "textCancel": ctx.Locale.Tr("cancel"), + "textDeleteBranch": ctx.Locale.Tr("repo.branch.delete", prInfo.headTarget), + "textAutoMergeCancelSchedule": ctx.Locale.Tr("repo.pulls.auto_merge_cancel_schedule"), + "textClearMergeMessage": ctx.Locale.Tr("repo.pulls.clear_merge_message"), + "textClearMergeMessageHint": ctx.Locale.Tr("repo.pulls.clear_merge_message_hint"), + "textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"), "canMergeNow": prInfo.MergeBoxData.canMergeNow, "allOverridableChecksOk": allOverridableChecksOk, + "canBypassProtection": prInfo.MergeBoxData.canBypassProtection, + "textBypassRules": ctx.Locale.Tr("repo.pulls.merge_bypass_rules"), "emptyCommit": pull.IsEmpty(), "pullHeadCommitID": prInfo.CompareInfo.HeadCommitID, "isPullBranchDeletable": prInfo.MergeBoxData.IsPullBranchDeletable, @@ -107,6 +121,9 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "hasPendingPullRequestMerge": hasPendingPullRequestMerge, "hasPendingPullRequestMergeTip": hasPendingPullRequestMergeTip, + + "showPullCommands": prInfo.MergeBoxData.ShowPullCommands, + "textCmdHint": ctx.Locale.Tr("repo.pulls.cmd_instruction_hint"), } // if this pr can be merged now, then hide the auto merge @@ -118,6 +135,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "name": "merge", "allowed": prConfig.AllowMerge, "textDoMerge": ctx.Locale.Tr("repo.pulls.merge_pull_request"), + "textAutoMerge": ctx.Locale.Tr("repo.pulls.enable_auto_merge", ctx.Locale.Tr("repo.pulls.merge_style_short_merge")), "mergeTitleFieldText": defaultMergeTitle, "mergeMessageFieldText": defaultMergeBody, "hideAutoMerge": generalHideAutoMerge, @@ -126,6 +144,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "name": "rebase", "allowed": prConfig.AllowRebase, "textDoMerge": ctx.Locale.Tr("repo.pulls.rebase_merge_pull_request"), + "textAutoMerge": ctx.Locale.Tr("repo.pulls.enable_auto_merge", ctx.Locale.Tr("repo.pulls.merge_style_short_rebase")), "hideMergeMessageTexts": true, "hideAutoMerge": generalHideAutoMerge, }, @@ -133,6 +152,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "name": "rebase-merge", "allowed": prConfig.AllowRebaseMerge, "textDoMerge": ctx.Locale.Tr("repo.pulls.rebase_merge_commit_pull_request"), + "textAutoMerge": ctx.Locale.Tr("repo.pulls.enable_auto_merge", ctx.Locale.Tr("repo.pulls.merge_style_short_rebase_merge")), "mergeTitleFieldText": defaultMergeTitle, "mergeMessageFieldText": defaultMergeBody, "hideAutoMerge": generalHideAutoMerge, @@ -141,6 +161,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "name": "squash", "allowed": prConfig.AllowSquash, "textDoMerge": ctx.Locale.Tr("repo.pulls.squash_merge_pull_request"), + "textAutoMerge": ctx.Locale.Tr("repo.pulls.enable_auto_merge", ctx.Locale.Tr("repo.pulls.merge_style_short_squash")), "mergeTitleFieldText": defaultSquashMergeTitle, "mergeMessageFieldText": git.CommitMessageMerge(defaultSquashMergeCommitMessages, defaultSquashMergeBody), "hideAutoMerge": generalHideAutoMerge, @@ -149,6 +170,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "name": "fast-forward-only", "allowed": prConfig.AllowFastForwardOnly && pull.CommitsBehind == 0, "textDoMerge": ctx.Locale.Tr("repo.pulls.fast_forward_only_merge_pull_request"), + "textAutoMerge": ctx.Locale.Tr("repo.pulls.enable_auto_merge", ctx.Locale.Tr("repo.pulls.merge_style_short_fast_forward_only")), "hideMergeMessageTexts": true, "hideAutoMerge": generalHideAutoMerge, }, diff --git a/services/convert/pull.go b/services/convert/pull.go index ba564474f370d..04e20998a3e85 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -5,11 +5,13 @@ package convert import ( "context" + "strings" git_model "gitea.dev/models/git" issues_model "gitea.dev/models/issues" "gitea.dev/models/perm" access_model "gitea.dev/models/perm/access" + pull_model "gitea.dev/models/pull" repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/cache" @@ -22,6 +24,17 @@ import ( "gitea.dev/services/gitdiff" ) +// toAPIAutoMerge converts a scheduled auto merge into the GitHub-compatible "auto_merge" object +func toAPIAutoMerge(ctx context.Context, autoMerge *pull_model.AutoMerge, enabledBy *user_model.User) *api.PullRequestAutoMerge { + commitTitle, commitMessage, _ := strings.Cut(autoMerge.Message, "\n\n") + return &api.PullRequestAutoMerge{ + EnabledBy: ToUser(ctx, enabledBy, nil), + MergeMethod: string(autoMerge.MergeStyle), + CommitTitle: commitTitle, + CommitMessage: commitMessage, + } +} + // ToAPIPullRequest assumes following fields have been assigned with valid values: // Required - Issue // Optional - Merger @@ -261,6 +274,10 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u apiPullRequest.Merged = pr.MergedUnix.AsTimePtr() apiPullRequest.MergedCommitID = &pr.MergedCommitID apiPullRequest.MergedBy = ToUser(ctx, pr.Merger, nil) + } else if scheduled, autoMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pr.ID); err != nil { + log.Error("GetScheduledMergeByPullID[%d]: %v", pr.ID, err) + } else if scheduled { + apiPullRequest.AutoMerge = toAPIAutoMerge(ctx, autoMerge, autoMerge.Doer) } return apiPullRequest @@ -335,6 +352,31 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs } apiRepo := ToRepo(ctx, baseRepo, baseRepoPerm) + + // batch-load scheduled auto merges (and their doers) to avoid N+1 queries in the loop. + // A lookup failure only drops the optional "auto_merge" field (matching the single-PR path), + // rather than failing the whole list. + prIDs := make([]int64, 0, len(prs)) + for _, pr := range prs { + prIDs = append(prIDs, pr.ID) + } + autoMerges, err := pull_model.GetScheduledMergeByPullIDs(ctx, prIDs) + if err != nil { + log.Error("GetScheduledMergeByPullIDs: %v", err) + } + autoMergeDoerIDs := make([]int64, 0, len(autoMerges)) + for _, am := range autoMerges { + autoMergeDoerIDs = append(autoMergeDoerIDs, am.DoerID) + } + autoMergeDoers, err := user_model.GetPossibleUserByIDs(ctx, autoMergeDoerIDs) + if err != nil { + log.Error("GetPossibleUserByIDs: %v", err) + } + autoMergeDoersMap := make(map[int64]*user_model.User, len(autoMergeDoers)) + for _, u := range autoMergeDoers { + autoMergeDoersMap[u.ID] = u + } + baseBranchCache := make(map[string]*git_model.Branch) apiPullRequests := make([]*api.PullRequest, 0, len(prs)) for _, pr := range prs { @@ -466,6 +508,12 @@ func ToAPIPullRequests(ctx context.Context, baseRepo *repo_model.Repository, prs apiPullRequest.Merged = pr.MergedUnix.AsTimePtr() apiPullRequest.MergedCommitID = &pr.MergedCommitID apiPullRequest.MergedBy = ToUser(ctx, pr.Merger, nil) + } else if am := autoMerges[pr.ID]; am != nil { + doer := autoMergeDoersMap[am.DoerID] + if doer == nil { + doer = user_model.NewGhostUser() // match the single-PR path, which ghost-fills a deleted scheduler + } + apiPullRequest.AutoMerge = toAPIAutoMerge(ctx, am, doer) } // Do not provide "ChangeFiles/Additions/Deletions" for the PR list, because the "diff" is quite slow diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index 8be1079e80943..a436b5f3c59ef 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -32,7 +32,7 @@ {{if $infoSection.InfoItems}}