From bf49b39a46ac87f17f1bdaffaca2e25a4f1543df Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 11 Jul 2026 00:21:17 +0200 Subject: [PATCH 1/5] feat(pull): add bypass-rules opt-in and expose scheduled auto-merge via API --- models/pull/automerge.go | 17 +++ modules/structs/pull.go | 14 ++ options/locale/locale_en-US.json | 13 +- routers/web/repo/issue_view.go | 2 - routers/web/repo/pull.go | 9 +- routers/web/repo/pull_merge_box.go | 23 +-- routers/web/repo/pull_merge_form.go | 39 ++++-- services/convert/pull.go | 48 +++++++ templates/swagger/v1_json.tmpl | 28 ++++ templates/swagger/v1_openapi3_json.tmpl | 28 ++++ tests/integration/api_pull_test.go | 52 +++++++ tests/integration/pull_merge_test.go | 3 +- .../js/components/PullRequestMergeForm.vue | 131 ++++++------------ 13 files changed, 283 insertions(+), 124 deletions(-) diff --git a/models/pull/automerge.go b/models/pull/automerge.go index 5415009fdea4c..2fd30c256411c 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 +} + // DeleteScheduledAutoMerge delete a scheduled pull request func DeleteScheduledAutoMerge(ctx context.Context, pullID int64) error { exist, scheduledPRM, err := GetScheduledMergeByPullID(ctx, pullID) 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 9373aa83a90dd..fd702436402d4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1820,8 +1820,7 @@ "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.merge_bypass_protection": "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.", @@ -1898,10 +1897,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 5774903c19ab5..a866646da4b0c 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -961,10 +961,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 62b956d06cd4d..988907a4e2062 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -281,11 +281,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..72e3aafd4db4f 100644 --- a/routers/web/repo/pull_merge_box.go +++ b/routers/web/repo/pull_merge_box.go @@ -164,22 +164,13 @@ 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 shows an explicit "bypass rules" checkbox, + // 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 { diff --git a/routers/web/repo/pull_merge_form.go b/routers/web/repo/pull_merge_form.go index 668717604f7c1..15cb9408fea2a 100644 --- a/routers/web/repo/pull_merge_form.go +++ b/routers/web/repo/pull_merge_form.go @@ -17,6 +17,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 { @@ -59,7 +69,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) } defaultMergeTitle, defaultMergeBody, err := pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pull, mergeStyle) @@ -80,18 +94,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, + "textBypassProtection": ctx.Locale.Tr("repo.pulls.merge_bypass_protection"), "emptyCommit": pull.IsEmpty(), "pullHeadCommitID": prInfo.CompareInfo.HeadCommitID, "isPullBranchDeletable": prInfo.MergeBoxData.IsPullBranchDeletable, @@ -114,6 +128,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, @@ -122,6 +137,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, }, @@ -129,6 +145,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, @@ -137,6 +154,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": defaultSquashMergeCommitMessages + defaultSquashMergeBody, "hideAutoMerge": generalHideAutoMerge, @@ -145,6 +163,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 cdc79eb88ff0c..baf96056658d0 100644 --- a/services/convert/pull.go +++ b/services/convert/pull.go @@ -6,11 +6,13 @@ package convert import ( "context" "fmt" + "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" @@ -24,6 +26,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 @@ -268,6 +281,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 @@ -342,6 +359,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 { @@ -473,6 +515,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/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 7d19ba86754bb..51331c3a5764b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -28670,6 +28670,9 @@ }, "x-go-name": "Assignees" }, + "auto_merge": { + "$ref": "#/definitions/PullRequestAutoMerge" + }, "base": { "$ref": "#/definitions/PRBranchInfo" }, @@ -28855,6 +28858,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/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 1f9539527ef9b..c9f5513abaace 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -8397,6 +8397,9 @@ "type": "array", "x-go-name": "Assignees" }, + "auto_merge": { + "$ref": "#/components/schemas/PullRequestAutoMerge" + }, "base": { "$ref": "#/components/schemas/PRBranchInfo" }, @@ -8585,6 +8588,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/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go index e9ccbe458311d..86c1ae5563129 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(*issues_model.PullRequest, string) {} + 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 ae1f8a34909d6..b0d98b9632f35 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -1132,13 +1132,14 @@ 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.") 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 95fe0f1beb425..73c1fb0949323 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -14,36 +14,47 @@ const mergeForm = props.mergeFormProps; const mergeTitleFieldValue = shallowRef(''); const mergeMessageFieldValue = shallowRef(''); const deleteBranchAfterMerge = shallowRef(false); -const autoMergeWhenSucceed = shallowRef(false); +const bypassProtection = 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); +// the bypass checkbox is only meaningful when the user can bypass and there are overridable blockers +const showBypassProtection = computed(() => { + return mergeForm.canBypassProtection && !mergeForm.allOverridableChecksOk; +}); + +const forceMerge = computed(() => { + return showBypassProtection.value && bypassProtection.value; +}); + +// 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 mergeButtonStyleClass = computed(() => { if (mergeStyle.value === mergeStyleManuallyMerged) return 'red'; - if (mergeForm.allOverridableChecksOk) return 'primary'; - return autoMergeWhenSucceed.value ? 'primary' : 'red'; + return forceMerge.value ? 'red' : 'primary'; }); const mergeSelectStyleClass = computed(() => { if (mergeForm.emptyCommit) return ''; if (mergeStyle.value === mergeStyleManuallyMerged) return 'red'; - return 'primary'; -}); - -const forceMerge = computed(() => { - return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk; + return forceMerge.value ? 'red' : 'primary'; }); watch(mergeStyle, (val) => { @@ -54,11 +65,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); }); @@ -79,9 +88,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 (now / auto / bypass) is derived mergeStyle.value = name; - autoMergeWhenSucceed.value = autoMerge; + showMergeStyleMenu.value = false; } function clearMergeMessage() { @@ -91,8 +101,11 @@ function clearMergeMessage() { From 2b55b165ffb14a41563e4368c5f918634b2b281d Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 13 Aug 2026 10:55:39 +0200 Subject: [PATCH 4/5] fixes --- web_src/js/components/PullRequestMergeForm.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index b11267ff83e7f..d1793602e44ea 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -195,7 +195,7 @@ function clearMergeMessage() { - {{ mergeForm.textCmdHint }} + {{ mergeForm.textCmdHint }} From fe8077ab02b532d9e632e5c83827419f67c89d98 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 29 Aug 2026 10:48:34 +0200 Subject: [PATCH 5/5] restyle --- options/locale/locale_en-US.json | 4 +-- routers/web/repo/pull_merge_box.go | 19 +++++++++++--- routers/web/repo/pull_merge_form.go | 3 +-- .../issue/view_content/pull_merge_box.tmpl | 2 +- tests/integration/pull_merge_test.go | 1 + .../js/components/PullRequestMergeForm.vue | 26 +++++++------------ 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index d81a7c4f9d5e7..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.merge_switch_to_force_merge": "Switch to force merge", - "repo.pulls.merge_switch_to_auto_merge": "Switch to auto 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.", diff --git a/routers/web/repo/pull_merge_box.go b/routers/web/repo/pull_merge_box.go index 2b1951d0fca47..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() { @@ -176,7 +181,15 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context 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 9b840d56fc0cc..28028205aa644 100644 --- a/routers/web/repo/pull_merge_form.go +++ b/routers/web/repo/pull_merge_form.go @@ -110,8 +110,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context "canMergeNow": prInfo.MergeBoxData.canMergeNow, "allOverridableChecksOk": allOverridableChecksOk, "canBypassProtection": prInfo.MergeBoxData.canBypassProtection, - "textSwitchToForceMerge": ctx.Locale.Tr("repo.pulls.merge_switch_to_force_merge"), - "textSwitchToAutoMerge": ctx.Locale.Tr("repo.pulls.merge_switch_to_auto_merge"), + "textBypassRules": ctx.Locale.Tr("repo.pulls.merge_bypass_rules"), "emptyCommit": pull.IsEmpty(), "pullHeadCommitID": prInfo.CompareInfo.HeadCommitID, "isPullBranchDeletable": prInfo.MergeBoxData.IsPullBranchDeletable, diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index c13cfa6f94519..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}}
    {{/* align with the info icon and text */}} {{range $listItem := $infoItem.ListItems}} diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 203aab51661e5..3c3c3f5d9aa21 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -1128,6 +1128,7 @@ 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(), "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 diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index d1793602e44ea..a7ccdfb07c63a 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -42,17 +42,13 @@ const autoMergeWhenSucceed = computed(() => { return !mergeForm.allOverridableChecksOk && !forceMerge.value && !mergeStyleDetail.value.hideAutoMerge; }); -// uncolored: schedule auto-merge; primary: merge now; red: bypass or mark as merged +// primary: the merge happens on submit; uncolored: it is only scheduled, or is a bookkeeping action const mergeButtonStyleClass = computed(() => { - if (forceMerge.value || mergeStyle.value === mergeStyleManuallyMerged) return 'red'; + if (mergeStyle.value === mergeStyleManuallyMerged) return ''; return autoMergeWhenSucceed.value ? '' : 'primary'; }); -const mergeSelectStyleClass = computed(() => { - if (mergeForm.emptyCommit) return ''; - if (forceMerge.value || mergeStyle.value === mergeStyleManuallyMerged) return 'red'; - return autoMergeWhenSucceed.value ? '' : 'primary'; -}); +const mergeSelectStyleClass = computed(() => mergeForm.emptyCommit ? '' : mergeButtonStyleClass.value); watch(mergeStyle, (val) => { mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val); @@ -91,11 +87,6 @@ function selectMergeStyle(name: string) { showMergeStyleMenu.value = false; } -function toggleForceMerge() { - // switch between scheduling an auto merge and bypassing the blockers to merge now - forceMerge.value = !forceMerge.value; -} - function clearMergeMessage() { mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } @@ -107,7 +98,7 @@ function clearMergeMessage() { the dropdown only chooses the merge style; the merge mode is derived: - no overridable blockers => merge now - overridable blockers, no bypass => enable auto merge (merge when checks succeed) - - overridable blockers + "switch to force merge" link (only offered when the user can bypass) => merge now, skipping the blockers + - overridable blockers + "bypass rules" checkbox (only offered when the user can bypass) => merge now, skipping the blockers How to test the UI manually: * Method 1: manually set some variables in pull.tmpl, eg: {{$notAllOverridableChecksOk = true}} {{$canMergeNow = false}} * Method 2: make a protected branch, then set state=pending/success : @@ -119,10 +110,11 @@ function clearMergeMessage() {