diff --git a/models/git/protected_branch.go b/models/git/protected_branch.go index f242f94f7b7f2..8d1aa97d68b84 100644 --- a/models/git/protected_branch.go +++ b/models/git/protected_branch.go @@ -43,6 +43,9 @@ type ProtectedBranch struct { WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"` MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"` MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"` + EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"` + BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"` + BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` CanForcePush bool `xorm:"NOT NULL DEFAULT false"` EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"` ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"` @@ -204,6 +207,35 @@ func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch, return in } +// CanBypassBranchProtection reports whether the user can bypass branch protection checks (status checks, approvals, protected files) +// Either a repo admin (when not blocked) or a user/team on the bypass allowlist can bypass. +func CanBypassBranchProtection(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User, isRepoAdmin bool) (byAdmin, byAllowList bool) { + if !protectBranch.BlockAdminMergeOverride && isRepoAdmin { + return true, true + } + return false, canBypassBranchProtectionByAllowList(ctx, protectBranch, user) +} + +func canBypassBranchProtectionByAllowList(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) bool { + if !protectBranch.EnableBypassAllowlist { + return false + } + if slices.Contains(protectBranch.BypassAllowlistUserIDs, user.ID) { + return true + } + + if len(protectBranch.BypassAllowlistTeamIDs) == 0 { + return false + } + + in, err := organization.IsUserInTeams(ctx, user.ID, protectBranch.BypassAllowlistTeamIDs) + if err != nil { + log.Error("IsUserInTeams failed: userID=%d, repoID=%d, allowlistTeamIDs=%v, err=%v", user.ID, protectBranch.RepoID, protectBranch.BypassAllowlistTeamIDs, err) + return false + } + return in +} + // IsUserOfficialReviewer check if user is official reviewer for the branch (counts towards required approvals) func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) { repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID) @@ -347,6 +379,9 @@ type WhitelistOptions struct { ApprovalsUserIDs []int64 ApprovalsTeamIDs []int64 + + BypassUserIDs []int64 + BypassTeamIDs []int64 } // UpdateProtectBranch saves branch protection options of repository. @@ -387,6 +422,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote } protectBranch.ApprovalsWhitelistUserIDs = whitelist + whitelist, err = updateUserWhitelist(ctx, repo, protectBranch.BypassAllowlistUserIDs, opts.BypassUserIDs) + if err != nil { + return err + } + protectBranch.BypassAllowlistUserIDs = whitelist + // if the repo is in an organization whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs) if err != nil { @@ -412,6 +453,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote } protectBranch.ApprovalsWhitelistTeamIDs = whitelist + whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.BypassAllowlistTeamIDs, opts.BypassTeamIDs) + if err != nil { + return err + } + protectBranch.BypassAllowlistTeamIDs = whitelist + // Looks like it's a new rule if protectBranch.ID == 0 { // as it's a new rule and if priority was not set, we need to calc it. diff --git a/models/git/protected_branch_test.go b/models/git/protected_branch_test.go index 3aa1d7daa8acb..124e1029fb547 100644 --- a/models/git/protected_branch_test.go +++ b/models/git/protected_branch_test.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "github.com/stretchr/testify/assert" ) @@ -153,3 +154,58 @@ func TestNewProtectBranchPriority(t *testing.T) { assert.NoError(t, err) assert.Equal(t, int64(2), savedPB2.Priority) } + +func TestCanBypassBranchProtection(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + pb := &ProtectedBranch{ + EnableBypassAllowlist: true, + BypassAllowlistUserIDs: []int64{user.ID}, + } + + type expected struct { + byAdmin, byAllowList bool + } + testBypass := func(t *testing.T, exp expected, pb *ProtectedBranch, doer *user_model.User, isAdmin bool) { + actualAdmin, actualAllowList := CanBypassBranchProtection(t.Context(), pb, doer, isAdmin) + assert.Equal(t, exp.byAdmin, actualAdmin, "admin bypass mismatch") + assert.Equal(t, exp.byAllowList, actualAllowList, "allowlist bypass mismatch") + } + // User bypasses via explicit allowlist. + testBypass(t, expected{false, true}, pb, user, false) + + // Non-admin cannot bypass when allowlist is disabled. + pb.EnableBypassAllowlist = false + testBypass(t, expected{false, false}, pb, user, false) + + // Repo admin can bypass independently of allowlist when not blocked. + testBypass(t, expected{true, true}, pb, user, true) + + // Admin override block still allows bypass for allowlisted users. + pb.EnableBypassAllowlist = true + pb.BlockAdminMergeOverride = true + testBypass(t, expected{false, true}, pb, user, false) + + // admin cannot bypass without allowlist membership. + pb.BypassAllowlistUserIDs = nil + testBypass(t, expected{false, false}, pb, user, true) + + // admin can bypass when allowlisted. + pb.BypassAllowlistUserIDs = []int64{user.ID} + testBypass(t, expected{false, true}, pb, user, true) + + teamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + nonTeamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // User bypasses via team allowlist membership. + pb.EnableBypassAllowlist = true + pb.BlockAdminMergeOverride = false + pb.BypassAllowlistUserIDs = nil + pb.BypassAllowlistTeamIDs = []int64{1} // team 1 contains user 2 in test fixtures + testBypass(t, expected{false, true}, pb, teamMember, false) + + // User does not bypass when not in allowlisted teams. + testBypass(t, expected{false, false}, pb, nonTeamMember, false) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index db74ff78d5040..b66e11cd58369 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration { newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook), + newMigration(331, "Add bypass allowlist to branch protection", v1_26.AddBranchProtectionBypassAllowlist), } return preparedMigrations } diff --git a/models/migrations/v1_26/v331.go b/models/migrations/v1_26/v331.go new file mode 100644 index 0000000000000..9cd840b2680bf --- /dev/null +++ b/models/migrations/v1_26/v331.go @@ -0,0 +1,20 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddBranchProtectionBypassAllowlist(x *xorm.Engine) error { + type ProtectedBranch struct { + EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"` + BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"` + BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreConstrains: true, + IgnoreIndices: true, + }, new(ProtectedBranch)) + return err +} diff --git a/models/migrations/v1_26/v331_test.go b/models/migrations/v1_26/v331_test.go new file mode 100644 index 0000000000000..9d8d7d55f09cc --- /dev/null +++ b/models/migrations/v1_26/v331_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/require" +) + +func Test_AddBranchProtectionBypassAllowlist(t *testing.T) { + type ProtectedBranch struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"INDEX"` + BranchName string `xorm:"INDEX"` + EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false"` + BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT"` + BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT"` + } + + x, deferable := base.PrepareTestEnv(t, 0, new(ProtectedBranch)) + defer deferable() + + // Test with default values + _, err := x.Insert(&ProtectedBranch{RepoID: 1, BranchName: "main"}) + require.NoError(t, err) + + // Test with populated allowlist + _, err = x.Insert(&ProtectedBranch{ + RepoID: 1, + BranchName: "develop", + EnableBypassAllowlist: true, + BypassAllowlistUserIDs: []int64{1, 2, 3}, + BypassAllowlistTeamIDs: []int64{10, 20}, + }) + require.NoError(t, err) + + require.NoError(t, AddBranchProtectionBypassAllowlist(x)) + + // Verify the default values record + var pb ProtectedBranch + has, err := x.Where("repo_id = ? AND branch_name = ?", 1, "main").Get(&pb) + require.NoError(t, err) + require.True(t, has) + require.False(t, pb.EnableBypassAllowlist) + require.Nil(t, pb.BypassAllowlistUserIDs) + require.Nil(t, pb.BypassAllowlistTeamIDs) + + // Verify the populated allowlist record + var pb2 ProtectedBranch + has, err = x.Where("repo_id = ? AND branch_name = ?", 1, "develop").Get(&pb2) + require.NoError(t, err) + require.True(t, has) + require.True(t, pb2.EnableBypassAllowlist) + require.Equal(t, []int64{1, 2, 3}, pb2.BypassAllowlistUserIDs) + require.Equal(t, []int64{10, 20}, pb2.BypassAllowlistTeamIDs) +} diff --git a/modules/cachegroup/cachegroup.go b/modules/cachegroup/cachegroup.go index 06085f860f788..cc45595822670 100644 --- a/modules/cachegroup/cachegroup.go +++ b/modules/cachegroup/cachegroup.go @@ -9,4 +9,5 @@ const ( UserEmailAddresses = "user_email_addresses" GPGKeyWithSubKeys = "gpg_key_with_subkeys" RepoUserPermission = "repo_user_permission" + BypassAllowlist = "bypass_allowlist" ) diff --git a/modules/structs/repo_branch.go b/modules/structs/repo_branch.go index 75f7878aa6a39..fc84ab364e7aa 100644 --- a/modules/structs/repo_branch.go +++ b/modules/structs/repo_branch.go @@ -50,6 +50,9 @@ type BranchProtection struct { EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableBypassAllowlist bool `json:"enable_bypass_allowlist"` + BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"` + BypassAllowlistTeams []string `json:"bypass_allowlist_teams"` EnableStatusCheck bool `json:"enable_status_check"` StatusCheckContexts []string `json:"status_check_contexts"` RequiredApprovals int64 `json:"required_approvals"` @@ -90,6 +93,9 @@ type CreateBranchProtectionOption struct { EnableMergeWhitelist bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableBypassAllowlist bool `json:"enable_bypass_allowlist"` + BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"` + BypassAllowlistTeams []string `json:"bypass_allowlist_teams"` EnableStatusCheck bool `json:"enable_status_check"` StatusCheckContexts []string `json:"status_check_contexts"` RequiredApprovals int64 `json:"required_approvals"` @@ -123,6 +129,9 @@ type EditBranchProtectionOption struct { EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableBypassAllowlist *bool `json:"enable_bypass_allowlist"` + BypassAllowlistUsernames []string `json:"bypass_allowlist_usernames"` + BypassAllowlistTeams []string `json:"bypass_allowlist_teams"` EnableStatusCheck *bool `json:"enable_status_check"` StatusCheckContexts []string `json:"status_check_contexts"` RequiredApprovals *int64 `json:"required_approvals"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index b9d5247b3d4bf..8fdd815079fae 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1821,6 +1821,7 @@ "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.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.", @@ -2415,6 +2416,11 @@ "repo.settings.protect_merge_whitelist_committers_desc": "Allow only allowlisted users or teams to merge pull requests into this branch.", "repo.settings.protect_merge_whitelist_users": "Allowlisted users for merging:", "repo.settings.protect_merge_whitelist_teams": "Allowlisted teams for merging:", + "repo.settings.protect_bypass_allowlist": "Bypass branch protection", + "repo.settings.protect_enable_bypass_allowlist": "Allow selected users or teams to bypass branch protection", + "repo.settings.protect_enable_bypass_allowlist_desc": "Allowlisted users or teams can merge or push even when required approvals, status checks, or protected-file rules would otherwise block them.", + "repo.settings.protect_bypass_allowlist_users": "Allowlisted users for bypassing protection:", + "repo.settings.protect_bypass_allowlist_teams": "Allowlisted teams for bypassing protection:", "repo.settings.protect_check_status_contexts": "Enable Status Check", "repo.settings.protect_status_check_patterns": "Status check patterns:", "repo.settings.protect_status_check_patterns_desc": "Enter patterns to specify which status checks must pass before branches can be merged into a branch that matches this rule. Each line specifies a pattern. Patterns cannot be empty.", @@ -2456,7 +2462,7 @@ "repo.settings.block_outdated_branch": "Block merge if pull request is outdated", "repo.settings.block_outdated_branch_desc": "Merging will not be possible when head branch is behind base branch.", "repo.settings.block_admin_merge_override": "Administrators must follow branch protection rules", - "repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it.", + "repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it. Users or teams in the bypass allowlist can still bypass these rules if bypass allowlist is enabled.", "repo.settings.default_branch_desc": "Select a default branch for code commits.", "repo.settings.default_target_branch_desc": "Pull requests can use different default target branch if it is set in the Pull Requests section of Repository Advance Settings.", "repo.settings.merge_style_desc": "Merge Styles", diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 295e4c2b5eda5..cece56350a078 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -711,7 +711,19 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } - var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + var bypassAllowlistUsers []int64 + if form.EnableBypassAllowlist { + bypassAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.BypassAllowlistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } + var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams, bypassAllowlistTeams []int64 if repo.Owner.IsOrganization() { whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) if err != nil { @@ -749,6 +761,17 @@ func CreateBranchProtection(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } + if form.EnableBypassAllowlist { + bypassAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.BypassAllowlistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } } protectBranch = &git_model.ProtectedBranch{ @@ -762,6 +785,7 @@ func CreateBranchProtection(ctx *context.APIContext) { EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist, ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys, EnableMergeWhitelist: form.EnableMergeWhitelist, + EnableBypassAllowlist: form.EnableBypassAllowlist, EnableStatusCheck: form.EnableStatusCheck, StatusCheckContexts: form.StatusCheckContexts, EnableApprovalsWhitelist: form.EnableApprovalsWhitelist, @@ -786,6 +810,8 @@ func CreateBranchProtection(ctx *context.APIContext) { MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsTeamIDs: approvalsWhitelistTeams, + BypassUserIDs: bypassAllowlistUsers, + BypassTeamIDs: bypassAllowlistTeams, }); err != nil { ctx.APIErrorInternal(err) return @@ -906,6 +932,10 @@ func EditBranchProtection(ctx *context.APIContext) { protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist } + if form.EnableBypassAllowlist != nil { + protectBranch.EnableBypassAllowlist = *form.EnableBypassAllowlist + } + if form.EnableStatusCheck != nil { protectBranch.EnableStatusCheck = *form.EnableStatusCheck } @@ -958,7 +988,7 @@ func EditBranchProtection(ctx *context.APIContext) { protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride } - var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64 + var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers, bypassAllowlistUsers []int64 if form.PushWhitelistUsernames != nil { whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) if err != nil { @@ -1011,8 +1041,23 @@ func EditBranchProtection(ctx *context.APIContext) { } else { approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs } + if form.BypassAllowlistUsernames != nil && protectBranch.EnableBypassAllowlist { + bypassAllowlistUsers, err = user_model.GetUserIDsByNames(ctx, form.BypassAllowlistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } else if !protectBranch.EnableBypassAllowlist { + bypassAllowlistUsers = nil + } else { + bypassAllowlistUsers = protectBranch.BypassAllowlistUserIDs + } - var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams, bypassAllowlistTeams []int64 if repo.Owner.IsOrganization() { if form.PushWhitelistTeams != nil { whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) @@ -1066,6 +1111,21 @@ func EditBranchProtection(ctx *context.APIContext) { } else { approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs } + if form.BypassAllowlistTeams != nil && protectBranch.EnableBypassAllowlist { + bypassAllowlistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.BypassAllowlistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + return + } + ctx.APIErrorInternal(err) + return + } + } else if !protectBranch.EnableBypassAllowlist { + bypassAllowlistTeams = nil + } else { + bypassAllowlistTeams = protectBranch.BypassAllowlistTeamIDs + } } err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ @@ -1077,6 +1137,8 @@ func EditBranchProtection(ctx *context.APIContext) { MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsTeamIDs: approvalsWhitelistTeams, + BypassUserIDs: bypassAllowlistUsers, + BypassTeamIDs: bypassAllowlistTeams, }) if err != nil { ctx.APIErrorInternal(err) diff --git a/routers/private/hook_pre_receive.go b/routers/private/hook_pre_receive.go index 3936e223d56b9..c48033fd6744b 100644 --- a/routers/private/hook_pre_receive.go +++ b/routers/private/hook_pre_receive.go @@ -371,8 +371,10 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r return } - // If we're an admin for the repository we can ignore status checks, reviews and override protected files - if ctx.userPerm.IsAdmin() { + _, canBypass := git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) + + // If we can bypass branch protection we can ignore status checks, reviews and protected files + if canBypass { return } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index f678f8387844e..b34969427ca46 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -939,6 +939,9 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss ctx.Data["DefaultSquashMergeMessage"] = defaultSquashMergeMessage ctx.Data["DefaultSquashMergeBody"] = defaultSquashMergeBody + ctx.Data["CanBypassBranchProtection"] = false + ctx.Data["CanAdminBypassBranchProtection"] = false + pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) if err != nil { ctx.ServerError("LoadProtectedBranch", err) @@ -958,6 +961,10 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss ctx.Data["IsBlockedByChangedProtectedFiles"] = len(pull.ChangedProtectedFiles) != 0 ctx.Data["ChangedProtectedFilesNum"] = len(pull.ChangedProtectedFiles) ctx.Data["RequireApprovalsWhitelist"] = pb.EnableApprovalsWhitelist + + canAdminBypass, canBypass := git_model.CanBypassBranchProtection(ctx, pb, ctx.Doer, ctx.Repo.Permission.IsAdmin()) + ctx.Data["CanAdminBypassBranchProtection"] = canAdminBypass + ctx.Data["CanBypassBranchProtection"] = canBypass } preparePullViewSigning(ctx, issue) diff --git a/routers/web/repo/setting/protected_branch.go b/routers/web/repo/setting/protected_branch.go index 4374e95340a1c..0fd054a783ba7 100644 --- a/routers/web/repo/setting/protected_branch.go +++ b/routers/web/repo/setting/protected_branch.go @@ -82,6 +82,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",") c.Data["merge_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistUserIDs), ",") + c.Data["bypass_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.BypassAllowlistUserIDs), ",") c.Data["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",") c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts @@ -97,6 +98,7 @@ func SettingsProtectedBranch(c *context.Context) { c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",") c.Data["merge_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.MergeWhitelistTeamIDs), ",") + c.Data["bypass_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.BypassAllowlistTeamIDs), ",") c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",") } @@ -154,7 +156,7 @@ func SettingsProtectedBranchPost(ctx *context.Context) { } } - var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams []int64 + var whitelistUsers, whitelistTeams, forcePushAllowlistUsers, forcePushAllowlistTeams, mergeWhitelistUsers, mergeWhitelistTeams, approvalsWhitelistUsers, approvalsWhitelistTeams, bypassAllowlistUsers, bypassAllowlistTeams []int64 protectBranch.RuleName = f.RuleName if f.RequiredApprovals < 0 { ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) @@ -214,6 +216,16 @@ func SettingsProtectedBranchPost(ctx *context.Context) { } } + protectBranch.EnableBypassAllowlist = f.EnableBypassAllowlist + if f.EnableBypassAllowlist { + if strings.TrimSpace(f.BypassAllowlistUsers) != "" { + bypassAllowlistUsers, _ = base.StringsToInt64s(strings.Split(f.BypassAllowlistUsers, ",")) + } + if strings.TrimSpace(f.BypassAllowlistTeams) != "" { + bypassAllowlistTeams, _ = base.StringsToInt64s(strings.Split(f.BypassAllowlistTeams, ",")) + } + } + protectBranch.EnableStatusCheck = f.EnableStatusCheck if f.EnableStatusCheck { patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n") @@ -270,6 +282,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) { MergeTeamIDs: mergeWhitelistTeams, ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsTeamIDs: approvalsWhitelistTeams, + BypassUserIDs: bypassAllowlistUsers, + BypassTeamIDs: bypassAllowlistTeams, }); err != nil { ctx.ServerError("CreateOrUpdateProtectedBranch", err) return diff --git a/services/convert/convert.go b/services/convert/convert.go index 71d2ecb33339f..dabef5f39103b 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -148,6 +148,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs) mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs) approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs) + bypassAllowlistUsernames := getWhitelistEntities(readers, bp.BypassAllowlistUserIDs) teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests) if err != nil { @@ -158,6 +159,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs) mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs) approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs) + bypassAllowlistTeams := getWhitelistEntities(teamReaders, bp.BypassAllowlistTeamIDs) branchName := "" if !git_model.IsRuleNameSpecial(bp.RuleName) { @@ -181,6 +183,9 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo EnableMergeWhitelist: bp.EnableMergeWhitelist, MergeWhitelistUsernames: mergeWhitelistUsernames, MergeWhitelistTeams: mergeWhitelistTeams, + EnableBypassAllowlist: bp.EnableBypassAllowlist, + BypassAllowlistUsernames: bypassAllowlistUsernames, + BypassAllowlistTeams: bypassAllowlistTeams, EnableStatusCheck: bp.EnableStatusCheck, StatusCheckContexts: bp.StatusCheckContexts, RequiredApprovals: bp.RequiredApprovals, diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 01e57a596ef2e..e6f38bec718f4 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -179,6 +179,9 @@ type ProtectBranchForm struct { EnableMergeWhitelist bool MergeWhitelistUsers string MergeWhitelistTeams string + EnableBypassAllowlist bool + BypassAllowlistUsers string + BypassAllowlistTeams string EnableStatusCheck bool StatusCheckContexts string RequiredApprovals int64 diff --git a/services/pull/check.go b/services/pull/check.go index 6486ca79df320..78ee5a7e0100d 100644 --- a/services/pull/check.go +++ b/services/pull/check.go @@ -132,7 +132,7 @@ const ( ) // CheckPullMergeable check if the pull mergeable based on all conditions (branch protection, merge options, ...) -func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, adminForceMerge bool) error { +func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, tryForceMerge bool) error { return db.WithTx(stdCtx, func(ctx context.Context) error { if pr.HasMerged { return ErrHasMerged @@ -169,21 +169,21 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc return ErrIsChecking } - if err := CheckPullBranchProtections(ctx, pr, false); err != nil { - if !errors.Is(err, ErrNotReadyToMerge) { - log.Error("Error whilst checking pull branch protection for %-v: %v", pr, err) - return err + if errProtection := CheckPullBranchProtections(ctx, pr, false); errProtection != nil { + if !errors.Is(errProtection, ErrNotReadyToMerge) { + log.Error("Error whilst checking pull branch protection for %-v: %v", pr, errProtection) + return errProtection } // Now the branch protection check failed, check whether the failure could be skipped (skip by setting err = nil) // * when doing Auto Merge (Scheduled Merge After Checks Succeed), skip the branch protection check if mergeCheckType == MergeCheckTypeAuto { - err = nil + errProtection = nil } - // * if admin tries to "Force Merge", they could sometimes skip the branch protection check - if adminForceMerge { + // * if the doer tries to "Force Merge", check whether it is really allowed + if tryForceMerge { isRepoAdmin, errForceMerge := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer) if errForceMerge != nil { return fmt.Errorf("IsUserRepoAdmin failed, repo: %v, doer: %v, err: %w", pr.BaseRepoID, doer.ID, errForceMerge) @@ -194,16 +194,19 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc return fmt.Errorf("GetFirstMatchProtectedBranchRule failed, repo: %v, base branch: %v, err: %w", pr.BaseRepoID, pr.BaseBranch, errForceMerge) } - // if doer is admin and the "Force Merge" is not blocked, then clear the branch protection check error - blockAdminForceMerge := protectedBranchRule != nil && protectedBranchRule.BlockAdminMergeOverride - if isRepoAdmin && !blockAdminForceMerge { - err = nil + canForceMerge := protectedBranchRule == nil && isRepoAdmin + if !canForceMerge { + _, canBypass := git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin) + canForceMerge = canBypass + } + if canForceMerge { + errProtection = nil } } // If there is still a branch protection check error, return it - if err != nil { - return err + if errProtection != nil { + return errProtection } } diff --git a/templates/repo/issue/view_content/pull_merge_box.tmpl b/templates/repo/issue/view_content/pull_merge_box.tmpl index 02a8db91572d2..63771ab438bbb 100644 --- a/templates/repo/issue/view_content/pull_merge_box.tmpl +++ b/templates/repo/issue/view_content/pull_merge_box.tmpl @@ -10,18 +10,19 @@ > {{$statusCheckData := .StatusCheckData}} {{$requiredStatusCheckState := $statusCheckData.RequiredChecksState}} + {{$canBypass := $.CanBypassBranchProtection}}
{{ctx.Locale.Tr "repo.settings.protect_enable_bypass_allowlist_desc"}}
+