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}}
{{range $infoItem := $infoSection.InfoItems}} -
{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}
+
{{$infoItem.SvgIconHTML}} {{$infoItem.InfoHTML}}
{{if $infoItem.ListItems}}
{{end}} - {{if $data.MergeFormProps}} - {{/* The merge form is a Vue component. After mounted, it has a button for choosing merge style, so make it have min-height to avoid layout shifting */}} + {{if or $data.MergeFormProps $data.ShowPullCommands}}
-
-
- {{end}} - - {{if $data.ShowPullCommands}} -
- {{template "repo/issue/view_content/pull_merge_instruction" dict "PullRequest" .Issue.PullRequest "MergeBoxData" $data}} + {{if $data.MergeFormProps}} +
+ {{else}} + {{ctx.Locale.Tr "repo.pulls.cmd_instruction_hint"}} + {{end}} + {{if $data.ShowPullCommands}} + {{template "repo/issue/view_content/pull_merge_instruction" dict "PullRequest" .Issue.PullRequest "MergeBoxData" $data}} + {{end}}
{{end}} diff --git a/templates/repo/issue/view_content/pull_merge_instruction.tmpl b/templates/repo/issue/view_content/pull_merge_instruction.tmpl index 4cb20e90c6cd3..ce94eb281c518 100644 --- a/templates/repo/issue/view_content/pull_merge_instruction.tmpl +++ b/templates/repo/issue/view_content/pull_merge_instruction.tmpl @@ -1,10 +1,8 @@ {{$data := $.MergeBoxData}} {{$pull := $.PullRequest}} -
- {{/* align with other item icon & text */}} - {{ctx.Locale.Tr "repo.pulls.cmd_instruction_hint"}} - -
+
+ diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index 5de9964121315..68e2e7e6abccd 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -8766,6 +8766,9 @@ "type": "array", "x-go-name": "Assignees" }, + "auto_merge": { + "$ref": "#/components/schemas/PullRequestAutoMerge" + }, "base": { "$ref": "#/components/schemas/PRBranchInfo" }, @@ -8954,6 +8957,31 @@ "type": "object", "x-go-package": "gitea.dev/modules/structs" }, + "PullRequestAutoMerge": { + "description": "PullRequestAutoMerge represents a pull request scheduled to auto merge when all checks succeed", + "properties": { + "commit_message": { + "description": "The message of the resulting merge commit", + "type": "string", + "x-go-name": "CommitMessage" + }, + "commit_title": { + "description": "The title of the resulting merge commit", + "type": "string", + "x-go-name": "CommitTitle" + }, + "enabled_by": { + "$ref": "#/components/schemas/User" + }, + "merge_method": { + "description": "The merge method that will be used, eg: \"merge\", \"rebase\", \"rebase-merge\", \"squash\", \"fast-forward-only\"", + "type": "string", + "x-go-name": "MergeMethod" + } + }, + "type": "object", + "x-go-package": "gitea.dev/modules/structs" + }, "PullRequestMeta": { "description": "PullRequestMeta PR info if an issue is a PR", "properties": { diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index 9526e7860d3a2..f03d4f9018c85 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -31805,6 +31805,9 @@ }, "x-go-name": "Assignees" }, + "auto_merge": { + "$ref": "#/definitions/PullRequestAutoMerge" + }, "base": { "$ref": "#/definitions/PRBranchInfo" }, @@ -31990,6 +31993,31 @@ }, "x-go-package": "gitea.dev/modules/structs" }, + "PullRequestAutoMerge": { + "description": "PullRequestAutoMerge represents a pull request scheduled to auto merge when all checks succeed", + "type": "object", + "properties": { + "commit_message": { + "description": "The message of the resulting merge commit", + "type": "string", + "x-go-name": "CommitMessage" + }, + "commit_title": { + "description": "The title of the resulting merge commit", + "type": "string", + "x-go-name": "CommitTitle" + }, + "enabled_by": { + "$ref": "#/definitions/User" + }, + "merge_method": { + "description": "The merge method that will be used, eg: \"merge\", \"rebase\", \"rebase-merge\", \"squash\", \"fast-forward-only\"", + "type": "string", + "x-go-name": "MergeMethod" + } + }, + "x-go-package": "gitea.dev/modules/structs" + }, "PullRequestMeta": { "description": "PullRequestMeta PR info if an issue is a PR", "type": "object", diff --git a/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go index e9ccbe458311d..ef031de8cb476 100644 --- a/tests/integration/api_pull_test.go +++ b/tests/integration/api_pull_test.go @@ -23,6 +23,8 @@ import ( "gitea.dev/modules/setting" api "gitea.dev/modules/structs" "gitea.dev/modules/util" + "gitea.dev/services/automerge" + "gitea.dev/services/automergequeue" "gitea.dev/services/convert" "gitea.dev/services/forms" "gitea.dev/services/gitdiff" @@ -144,6 +146,56 @@ func TestAPIViewPulls(t *testing.T) { } } +func TestAPIPullAutoMergeScheduled(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, Index: 3}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + ctx := NewAPITestContext(t, owner.Name, repo.Name, auth_model.AccessTokenScopeReadRepository) + singleReq := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/%d", owner.Name, repo.Name, pr.Index).AddTokenAuth(ctx.Token) + + // not scheduled: auto_merge is null (GitHub-compatible) + resp := ctx.Session.MakeRequest(t, singleReq, http.StatusOK) + apiPull := DecodeJSON(t, resp, &api.PullRequest{}) + assert.Nil(t, apiPull.AutoMerge) + + // schedule an auto merge; stub the queue so nothing gets merged in the background + oldAddToQueue := automergequeue.AddToQueue + automergequeue.AddToQueue = func(automergequeue.AutoMergeItem) {} + defer func() { automergequeue.AddToQueue = oldAddToQueue }() + scheduled, err := automerge.ScheduleAutoMerge(t.Context(), doer, pr, repo_model.MergeStyleSquash, "the title\n\nthe body", false) + require.NoError(t, err) + require.True(t, scheduled) + + // scheduled: auto_merge is populated with method, commit texts and the scheduling user + resp = ctx.Session.MakeRequest(t, singleReq, http.StatusOK) + apiPull = DecodeJSON(t, resp, &api.PullRequest{}) + require.NotNil(t, apiPull.AutoMerge) + assert.Equal(t, "squash", apiPull.AutoMerge.MergeMethod) + assert.Equal(t, "the title", apiPull.AutoMerge.CommitTitle) + assert.Equal(t, "the body", apiPull.AutoMerge.CommitMessage) + require.NotNil(t, apiPull.AutoMerge.EnabledBy) + assert.Equal(t, doer.Name, apiPull.AutoMerge.EnabledBy.UserName) + + // the list endpoint exposes it too + listReq := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls?state=all", owner.Name, repo.Name).AddTokenAuth(ctx.Token) + resp = ctx.Session.MakeRequest(t, listReq, http.StatusOK) + pulls := DecodeJSON(t, resp, []*api.PullRequest{}) + var listed *api.PullRequest + for _, p := range pulls { + if p.Index == pr.Index { + listed = p + } + } + require.NotNil(t, listed) + require.NotNil(t, listed.AutoMerge) + assert.Equal(t, "squash", listed.AutoMerge.MergeMethod) + assert.Equal(t, doer.Name, listed.AutoMerge.EnabledBy.UserName) +} + func TestAPIViewPullsByBaseHead(t *testing.T) { defer tests.PrepareTestEnv(t)() repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 30efb48b09136..3c3c3f5d9aa21 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -1128,13 +1128,15 @@ func TestPullForceMergeForBypassAllowlistUser(t *testing.T) { resp = bypassSession.MakeRequest(t, NewRequest(t, "GET", pullURL), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - assert.Contains(t, htmlDoc.doc.Find(".merge-section").Text(), "You are allowed to bypass branch protection rules for this merge.") + assert.Contains(t, htmlDoc.doc.Find(".merge-section").Text(), "Merging is blocked") mergeFormProps, exists := htmlDoc.doc.Find("#pull-request-merge-form").Attr("data-merge-form-props") require.True(t, exists) var mergeForm map[string]any require.NoError(t, json.Unmarshal([]byte(mergeFormProps), &mergeForm)) assert.Equal(t, true, mergeForm["canMergeNow"]) assert.Equal(t, false, mergeForm["allOverridableChecksOk"]) + // the bypass-allowlist user is offered the explicit "bypass rules" opt-in checkbox + assert.Equal(t, true, mergeForm["canBypassProtection"]) mergeReq := func(forceMerge bool) *RequestWrapper { return NewRequestWithValues(t, "POST", fmt.Sprintf("/api/v1/repos/user2/repo1/pulls/%d/merge", prIndex), map[string]string{ diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index fee1923a987a4..a7ccdfb07c63a 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -14,39 +14,42 @@ const mergeForm = props.mergeFormProps; const mergeTitleFieldValue = shallowRef(''); const mergeMessageFieldValue = shallowRef(''); const deleteBranchAfterMerge = shallowRef(false); -const autoMergeWhenSucceed = shallowRef(false); +const forceMerge = shallowRef(false); const mergeStyle = shallowRef(''); const mergeStyleDetail = shallowRef({ hideMergeMessageTexts: false, textDoMerge: '', + textAutoMerge: '', mergeTitleFieldText: '', mergeMessageFieldText: '', hideAutoMerge: false, }); -const mergeStyleAllowedCount = shallowRef(0); +const mergeStyleAllowedCount = computed(() => mergeForm.mergeStyles.reduce((v: number, msd: any) => v + (msd.allowed ? 1 : 0), 0)); const showMergeStyleMenu = shallowRef(false); const showActionForm = shallowRef(false); -const mergeButtonStyleClass = computed(() => { - if (mergeStyle.value === mergeStyleManuallyMerged) return 'red'; - if (mergeForm.allOverridableChecksOk) return 'primary'; - return autoMergeWhenSucceed.value ? 'primary' : 'red'; +// the bypass action is only offered when the user can bypass and there are overridable blockers +const showBypassProtection = computed(() => { + return mergeForm.canBypassProtection && !mergeForm.allOverridableChecksOk; }); -const mergeSelectStyleClass = computed(() => { - if (mergeForm.emptyCommit) return ''; - if (mergeStyle.value === mergeStyleManuallyMerged) return 'red'; - if (!mergeForm.allOverridableChecksOk) return 'red'; - return 'primary'; +// the merge mode is derived, not hand-managed: with overridable blockers present and no explicit bypass, +// the only valid action is to schedule an auto merge (unless the selected style has no auto merge, eg manual merge) +const autoMergeWhenSucceed = computed(() => { + return !mergeForm.allOverridableChecksOk && !forceMerge.value && !mergeStyleDetail.value.hideAutoMerge; }); -const forceMerge = computed(() => { - return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk; +// primary: the merge happens on submit; uncolored: it is only scheduled, or is a bookkeeping action +const mergeButtonStyleClass = computed(() => { + if (mergeStyle.value === mergeStyleManuallyMerged) return ''; + return autoMergeWhenSucceed.value ? '' : 'primary'; }); +const mergeSelectStyleClass = computed(() => mergeForm.emptyCommit ? '' : mergeButtonStyleClass.value); + watch(mergeStyle, (val) => { mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val); for (const elem of document.querySelectorAll('[data-pull-merge-style]')) { @@ -55,11 +58,9 @@ watch(mergeStyle, (val) => { }); onMounted(() => { - mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); - - let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; - if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; - switchMergeStyle(mergeStyle, !mergeForm.canMergeNow); + let defaultStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; + if (!defaultStyle) defaultStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; + mergeStyle.value = defaultStyle; document.addEventListener('mouseup', hideMergeStyleMenu); }); @@ -80,9 +81,10 @@ function toggleActionForm(show: boolean) { mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; } -function switchMergeStyle(name: string, autoMerge = false) { +function selectMergeStyle(name: string) { + // the dropdown only chooses the merge style; the merge mode (auto / force) is chosen independently mergeStyle.value = name; - autoMergeWhenSucceed.value = autoMerge; + showMergeStyleMenu.value = false; } function clearMergeMessage() { @@ -92,8 +94,11 @@ function clearMergeMessage() { @@ -219,46 +213,7 @@ function clearMergeMessage() { right: auto; } .ui.merge-button .ui.dropdown .menu > .item { - display: flex; - align-items: stretch; - padding: 0 !important; /* polluted by semantic.css: .ui.dropdown .menu > .item { !important } */ -} - -/* merge style list item */ -.action-text { - padding: 0.8rem; - flex: 1 -} - -.auto-merge-small { - width: 40px; - display: flex; - align-items: center; - justify-content: center; - position: relative; -} -.auto-merge-small .auto-merge-tip { - display: none; - left: 38px; - top: -1px; - bottom: -1px; - position: absolute; - align-items: center; - color: var(--color-text); - background-color: var(--color-info-bg); - border: 1px solid var(--color-info-border); - border-left: none; - padding-right: 1rem; -} - -.auto-merge-small:hover { - color: var(--color-text); - background-color: var(--color-info-bg); - border: 1px solid var(--color-info-border); -} - -.auto-merge-small:hover .auto-merge-tip { - display: flex; + padding: 0.8rem !important; /* polluted by semantic.css: .ui.dropdown .menu > .item { !important } */ }