feat: Add bypass allowlist for branch protection (#36514)

- Introduce a “Bypass Protection Allowlist” on branch rules
(users/teams) alongside admins, with BlockAdminMergeOverride
  still respected.
- Surface the allowlist in API (create/edit options, structs) and
settings UI; merge box now shows the red button +
  message for bypass-capable users.
- Apply bypass logic to merge checks and pre-receive so allowlisted
users can override unmet approvals/status checks/
  protected files when force-merging.
- Add migration for new columns, locale strings, and unit tests (bypass
helper; queue test tweak).

<img width="1069" height="218" alt="image"
src="https://github.com/user-attachments/assets/0b61bc2a-a27f-47f3-a923-613688008e65"
/>


Fixes #36476

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Codex GPT-5.3 <codex@openai.com>
Co-authored-by: GPT-5.2 <noreply@openai.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Nicolas
2026-05-16 16:23:42 +02:00
committed by GitHub
parent 54ff68b0a9
commit eb93981d45
23 changed files with 572 additions and 40 deletions

View File

@@ -43,6 +43,9 @@ type ProtectedBranch struct {
WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"` WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"` MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
MergeWhitelistTeamIDs []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"` CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"` EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"` ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT"`
@@ -204,6 +207,29 @@ func IsUserMergeWhitelisted(ctx context.Context, protectBranch *ProtectedBranch,
return in 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) bool {
if isRepoAdmin && !protectBranch.BlockAdminMergeOverride {
return true
}
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) // 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) { func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) {
repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID) repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
@@ -347,6 +373,9 @@ type WhitelistOptions struct {
ApprovalsUserIDs []int64 ApprovalsUserIDs []int64
ApprovalsTeamIDs []int64 ApprovalsTeamIDs []int64
BypassUserIDs []int64
BypassTeamIDs []int64
} }
// UpdateProtectBranch saves branch protection options of repository. // UpdateProtectBranch saves branch protection options of repository.
@@ -387,6 +416,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
} }
protectBranch.ApprovalsWhitelistUserIDs = whitelist 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 // if the repo is in an organization
whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs) whitelist, err = updateTeamWhitelist(ctx, repo, protectBranch.WhitelistTeamIDs, opts.TeamIDs)
if err != nil { if err != nil {
@@ -412,6 +447,12 @@ func UpdateProtectBranch(ctx context.Context, repo *repo_model.Repository, prote
} }
protectBranch.ApprovalsWhitelistTeamIDs = whitelist 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 // Looks like it's a new rule
if protectBranch.ID == 0 { if protectBranch.ID == 0 {
// as it's a new rule and if priority was not set, we need to calc it. // as it's a new rule and if priority was not set, we need to calc it.

View File

@@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -153,3 +154,51 @@ func TestNewProtectBranchPriority(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(2), savedPB2.Priority) 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}) // not in team 1
teamMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
pb := &ProtectedBranch{
EnableBypassAllowlist: true,
BypassAllowlistUserIDs: []int64{user.ID},
}
testBypass := func(t *testing.T, expected bool, pb *ProtectedBranch, doer *user_model.User, isAdmin bool) {
assert.Equal(t, expected, CanBypassBranchProtection(t.Context(), pb, doer, isAdmin))
}
// User bypasses via explicit allowlist.
testBypass(t, true, pb, user, false)
// Non-admin cannot bypass when allowlist is disabled.
pb.EnableBypassAllowlist = false
testBypass(t, false, pb, user, false)
// Repo admin can bypass independently of allowlist when not blocked.
testBypass(t, true, pb, user, true)
// Admin override block still allows bypass for allowlisted users.
pb.EnableBypassAllowlist = true
pb.BlockAdminMergeOverride = true
testBypass(t, true, pb, user, false)
// admin cannot bypass without allowlist membership.
pb.BypassAllowlistUserIDs = nil
testBypass(t, false, pb, user, true)
// admin can bypass when allowlisted.
pb.BypassAllowlistUserIDs = []int64{user.ID}
testBypass(t, true, pb, user, true)
// 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, true, pb, teamMember, false)
// User does not bypass when not in allowlisted teams.
testBypass(t, false, pb, user, false)
}

View File

@@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration {
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
newMigration(332, "Add last_sync_unix to mirror", v1_27.AddLastSyncUnixToMirror), newMigration(332, "Add last_sync_unix to mirror", v1_27.AddLastSyncUnixToMirror),
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
} }
return preparedMigrations return preparedMigrations
} }

View File

@@ -0,0 +1,20 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
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
}

View File

@@ -0,0 +1,60 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"testing"
"code.gitea.io/gitea/models/migrations/migrationtest"
"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 := migrationtest.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)
}

View File

@@ -50,6 +50,9 @@ type BranchProtection struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` 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"` EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"` StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"` RequiredApprovals int64 `json:"required_approvals"`
@@ -90,6 +93,9 @@ type CreateBranchProtectionOption struct {
EnableMergeWhitelist bool `json:"enable_merge_whitelist"` EnableMergeWhitelist bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` 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"` EnableStatusCheck bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"` StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals int64 `json:"required_approvals"` RequiredApprovals int64 `json:"required_approvals"`
@@ -123,6 +129,9 @@ type EditBranchProtectionOption struct {
EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` EnableMergeWhitelist *bool `json:"enable_merge_whitelist"`
MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"` MergeWhitelistUsernames []string `json:"merge_whitelist_usernames"`
MergeWhitelistTeams []string `json:"merge_whitelist_teams"` 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"` EnableStatusCheck *bool `json:"enable_status_check"`
StatusCheckContexts []string `json:"status_check_contexts"` StatusCheckContexts []string `json:"status_check_contexts"`
RequiredApprovals *int64 `json:"required_approvals"` RequiredApprovals *int64 `json:"required_approvals"`

View File

@@ -1819,6 +1819,7 @@
"repo.pulls.required_status_check_failed": "Some required checks were not successful.", "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_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_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": "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_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.", "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_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_users": "Allowlisted users for merging:",
"repo.settings.protect_merge_whitelist_teams": "Allowlisted teams 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_check_status_contexts": "Enable Status Check",
"repo.settings.protect_status_check_patterns": "Status check patterns:", "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.", "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": "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_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": "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_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.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", "repo.settings.merge_style_desc": "Merge Styles",

View File

@@ -711,7 +711,19 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return 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() { if repo.Owner.IsOrganization() {
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
if err != nil { if err != nil {
@@ -749,6 +761,17 @@ func CreateBranchProtection(ctx *context.APIContext) {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return 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{ protectBranch = &git_model.ProtectedBranch{
@@ -762,6 +785,7 @@ func CreateBranchProtection(ctx *context.APIContext) {
EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist, EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist,
ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys, ForcePushAllowlistDeployKeys: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist && form.ForcePushAllowlistDeployKeys,
EnableMergeWhitelist: form.EnableMergeWhitelist, EnableMergeWhitelist: form.EnableMergeWhitelist,
EnableBypassAllowlist: form.EnableBypassAllowlist,
EnableStatusCheck: form.EnableStatusCheck, EnableStatusCheck: form.EnableStatusCheck,
StatusCheckContexts: form.StatusCheckContexts, StatusCheckContexts: form.StatusCheckContexts,
EnableApprovalsWhitelist: form.EnableApprovalsWhitelist, EnableApprovalsWhitelist: form.EnableApprovalsWhitelist,
@@ -786,6 +810,8 @@ func CreateBranchProtection(ctx *context.APIContext) {
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams, ApprovalsTeamIDs: approvalsWhitelistTeams,
BypassUserIDs: bypassAllowlistUsers,
BypassTeamIDs: bypassAllowlistTeams,
}); err != nil { }); err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
return return
@@ -906,6 +932,10 @@ func EditBranchProtection(ctx *context.APIContext) {
protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist
} }
if form.EnableBypassAllowlist != nil {
protectBranch.EnableBypassAllowlist = *form.EnableBypassAllowlist
}
if form.EnableStatusCheck != nil { if form.EnableStatusCheck != nil {
protectBranch.EnableStatusCheck = *form.EnableStatusCheck protectBranch.EnableStatusCheck = *form.EnableStatusCheck
} }
@@ -958,7 +988,7 @@ func EditBranchProtection(ctx *context.APIContext) {
protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride protectBranch.BlockAdminMergeOverride = *form.BlockAdminMergeOverride
} }
var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers []int64 var whitelistUsers, forcePushAllowlistUsers, mergeWhitelistUsers, approvalsWhitelistUsers, bypassAllowlistUsers []int64
if form.PushWhitelistUsernames != nil { if form.PushWhitelistUsernames != nil {
whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false)
if err != nil { if err != nil {
@@ -1011,8 +1041,21 @@ func EditBranchProtection(ctx *context.APIContext) {
} else { } else {
approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs
} }
if form.BypassAllowlistUsernames != nil {
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 {
bypassAllowlistUsers = protectBranch.BypassAllowlistUserIDs
}
var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 var whitelistTeams, forcePushAllowlistTeams, mergeWhitelistTeams, approvalsWhitelistTeams, bypassAllowlistTeams []int64
if repo.Owner.IsOrganization() { if repo.Owner.IsOrganization() {
if form.PushWhitelistTeams != nil { if form.PushWhitelistTeams != nil {
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
@@ -1066,6 +1109,23 @@ func EditBranchProtection(ctx *context.APIContext) {
} else { } else {
approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs
} }
if form.BypassAllowlistTeams != nil {
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 {
bypassAllowlistTeams = protectBranch.BypassAllowlistTeamIDs
}
}
if !protectBranch.EnableBypassAllowlist {
bypassAllowlistUsers = nil
bypassAllowlistTeams = nil
} }
err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{
@@ -1077,6 +1137,8 @@ func EditBranchProtection(ctx *context.APIContext) {
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams, ApprovalsTeamIDs: approvalsWhitelistTeams,
BypassUserIDs: bypassAllowlistUsers,
BypassTeamIDs: bypassAllowlistTeams,
}) })
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)

View File

@@ -371,8 +371,8 @@ func preReceiveBranch(ctx *preReceiveContext, oldCommitID, newCommitID string, r
return return
} }
// If we're an admin for the repository we can ignore status checks, reviews and override protected files // If we can bypass branch protection we can ignore status checks, reviews and protected files
if ctx.userPerm.IsAdmin() { if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
return return
} }

View File

@@ -907,7 +907,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
if !canWriteToHeadRepo { // maintainers maybe allowed to push to head repo even if they can't write to it if !canWriteToHeadRepo { // maintainers maybe allowed to push to head repo even if they can't write to it
canWriteToHeadRepo = pull.AllowMaintainerEdit && perm.CanWrite(unit.TypeCode) canWriteToHeadRepo = pull.AllowMaintainerEdit && perm.CanWrite(unit.TypeCode)
} }
data.allowMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer) data.hasPermToMerge, err = pull_service.IsUserAllowedToMerge(ctx, pull, perm, ctx.Doer)
if err != nil { if err != nil {
ctx.ServerError("IsUserAllowedToMerge", err) ctx.ServerError("IsUserAllowedToMerge", err)
return return
@@ -954,16 +954,19 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
// this logic is from: // this logic is from:
// {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}} // {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}}
// HINT: if a PR's status is not mergeable, then it is a non-overridable blocker, such logic is handled separately (see IsStatusMergeable) // HINT: if a PR's status is not mergeable, then it is a non-overridable blocker, such logic is handled separately (see IsStatusMergeable)
data.HasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection || data.hasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles || data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles ||
data.hasStatusCheckBlocker data.hasStatusCheckBlocker
// this logic is from: data.canBypassProtection = isRepoAdmin
// {{$canMergeNow := and (or (and (not $.ProtectedBranch.BlockAdminMergeOverride) $.IsRepoAdmin) (not $notAllOverridableChecksOk)) (or (not .AllowMerge) (not .RequireSigned) .WillSign)}} data.canBypassProtectionAsAdmin = isRepoAdmin
// HINT: legacy "(not .AllowMerge)" is not right (always false, does nothing), fixed here 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 // CanMergeNow means: if the doer has write permission, whether the PR can be merged now
adminCanOverrideBlockers := (prInfo.ProtectedBranchRule == nil || !prInfo.ProtectedBranchRule.BlockAdminMergeOverride) && isRepoAdmin data.canMergeNow = (!data.hasOverridableBlockers || data.canBypassProtection) && // status checks are satisfied
data.CanMergeNow = (!data.HasOverridableBlockers || adminCanOverrideBlockers) && // status checks are satisfied
(!data.requireSigned || data.willSign) // signing requirement is satisfied (!data.requireSigned || data.willSign) // signing requirement is satisfied
prInfo.prepareMergeBoxFormProps(ctx) prInfo.prepareMergeBoxFormProps(ctx)

View File

@@ -276,9 +276,11 @@ type pullMergeBoxData struct {
StatusCheckData *pullCommitStatusCheckData StatusCheckData *pullCommitStatusCheckData
ShowStatusCheck bool ShowStatusCheck bool
HasOverridableBlockers bool hasOverridableBlockers bool
CanMergeNow bool // PR is mergeable, either no blocker, or doer is admin and can bypass the blockers canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers
allowMerge bool // doer has permission to merge hasPermToMerge bool // doer has permission to merge
canBypassProtection bool
canBypassProtectionAsAdmin bool
ShowUpdatePullInfo bool ShowUpdatePullInfo bool
UpdatePrimaryAction *pullUpdateAction UpdatePrimaryAction *pullUpdateAction

View File

@@ -162,18 +162,22 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxInfoItems(ctx *context.Context
} }
} }
if !data.allowMerge { if !data.hasPermToMerge {
prInfo.MergeBoxData.infoProtectionBlockers.AddInfoItem( prInfo.MergeBoxData.infoProtectionBlockers.AddInfoItem(
svg.RenderHTML("octicon-info"), svg.RenderHTML("octicon-info"),
ctx.Locale.Tr("repo.pulls.no_merge_access"), ctx.Locale.Tr("repo.pulls.no_merge_access"),
) )
} }
if data.CanMergeNow { if data.canMergeNow {
if data.HasOverridableBlockers { 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( prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(
svg.RenderHTML("octicon-dot-fill"), svg.RenderHTML("octicon-dot-fill"),
ctx.Locale.Tr("repo.pulls.required_status_check_administrator"), prompt,
) )
} else if pull.IsStatusMergeable() || pull.IsEmpty() { } else if pull.IsStatusMergeable() || pull.IsEmpty() {
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem( prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(

View File

@@ -20,7 +20,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
if pull.HasMerged || prInfo.issue.IsClosed { if pull.HasMerged || prInfo.issue.IsClosed {
return return
} }
if !prInfo.MergeBoxData.allowMerge { if !prInfo.MergeBoxData.hasPermToMerge {
return return
} }
@@ -76,7 +76,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull) defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
} }
allOverridableChecksOk := !prInfo.MergeBoxData.HasOverridableBlockers allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
mergeFormProps := map[string]any{ mergeFormProps := map[string]any{
"baseLink": prInfo.issue.Link(), "baseLink": prInfo.issue.Link(),
"textCancel": ctx.Locale.Tr("cancel"), "textCancel": ctx.Locale.Tr("cancel"),
@@ -88,7 +88,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
"textClearMergeMessageHint": ctx.Locale.Tr("repo.pulls.clear_merge_message_hint"), "textClearMergeMessageHint": ctx.Locale.Tr("repo.pulls.clear_merge_message_hint"),
"textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"), "textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"),
"canMergeNow": prInfo.MergeBoxData.CanMergeNow, "canMergeNow": prInfo.MergeBoxData.canMergeNow,
"allOverridableChecksOk": allOverridableChecksOk, "allOverridableChecksOk": allOverridableChecksOk,
"emptyCommit": pull.IsEmpty(), "emptyCommit": pull.IsEmpty(),
"pullHeadCommitID": prInfo.CompareInfo.HeadCommitID, "pullHeadCommitID": prInfo.CompareInfo.HeadCommitID,
@@ -103,7 +103,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
} }
// if this pr can be merged now, then hide the auto merge // if this pr can be merged now, then hide the auto merge
generalHideAutoMerge := prInfo.MergeBoxData.CanMergeNow && allOverridableChecksOk generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
var mergeStyles []any var mergeStyles []any
if pull.IsStatusMergeable() { if pull.IsStatusMergeable() {

View File

@@ -83,6 +83,7 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",") c.Data["whitelist_users"] = strings.Join(base.Int64sToStrings(rule.WhitelistUserIDs), ",")
c.Data["force_push_allowlist_users"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistUserIDs), ",") 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["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["approvals_whitelist_users"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistUserIDs), ",")
c.Data["status_check_contexts"] = strings.Join(rule.StatusCheckContexts, "\n") 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 contexts, _ := git_model.FindRepoRecentCommitStatusContexts(c, c.Repo.Repository.ID, 7*24*time.Hour) // Find last week status check contexts
@@ -98,6 +99,7 @@ func SettingsProtectedBranch(c *context.Context) {
c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",") c.Data["whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.WhitelistTeamIDs), ",")
c.Data["force_push_allowlist_teams"] = strings.Join(base.Int64sToStrings(rule.ForcePushAllowlistTeamIDs), ",") 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["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), ",") c.Data["approvals_whitelist_teams"] = strings.Join(base.Int64sToStrings(rule.ApprovalsWhitelistTeamIDs), ",")
} }
@@ -155,7 +157,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 protectBranch.RuleName = f.RuleName
if f.RequiredApprovals < 0 { if f.RequiredApprovals < 0 {
ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min")) ctx.Flash.Error(ctx.Tr("repo.settings.protected_branch_required_approvals_min"))
@@ -215,6 +217,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 protectBranch.EnableStatusCheck = f.EnableStatusCheck
if f.EnableStatusCheck { if f.EnableStatusCheck {
patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n") patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n")
@@ -271,6 +283,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
MergeTeamIDs: mergeWhitelistTeams, MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers, ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams, ApprovalsTeamIDs: approvalsWhitelistTeams,
BypassUserIDs: bypassAllowlistUsers,
BypassTeamIDs: bypassAllowlistTeams,
}); err != nil { }); err != nil {
ctx.ServerError("CreateOrUpdateProtectedBranch", err) ctx.ServerError("CreateOrUpdateProtectedBranch", err)
return return

View File

@@ -148,6 +148,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs) forcePushAllowlistUsernames := getWhitelistEntities(readers, bp.ForcePushAllowlistUserIDs)
mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs) mergeWhitelistUsernames := getWhitelistEntities(readers, bp.MergeWhitelistUserIDs)
approvalsWhitelistUsernames := getWhitelistEntities(readers, bp.ApprovalsWhitelistUserIDs) 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) teamReaders, err := organization.GetTeamsWithAccessToAnyRepoUnit(ctx, repo.Owner.ID, repo.ID, perm.AccessModeRead, unit.TypeCode, unit.TypePullRequests)
if err != nil { if err != nil {
@@ -158,6 +159,7 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs) forcePushAllowlistTeams := getWhitelistEntities(teamReaders, bp.ForcePushAllowlistTeamIDs)
mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs) mergeWhitelistTeams := getWhitelistEntities(teamReaders, bp.MergeWhitelistTeamIDs)
approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs) approvalsWhitelistTeams := getWhitelistEntities(teamReaders, bp.ApprovalsWhitelistTeamIDs)
bypassAllowlistTeams := getWhitelistEntities(teamReaders, bp.BypassAllowlistTeamIDs)
branchName := "" branchName := ""
if !git_model.IsRuleNameSpecial(bp.RuleName) { if !git_model.IsRuleNameSpecial(bp.RuleName) {
@@ -181,6 +183,9 @@ func ToBranchProtection(ctx context.Context, bp *git_model.ProtectedBranch, repo
EnableMergeWhitelist: bp.EnableMergeWhitelist, EnableMergeWhitelist: bp.EnableMergeWhitelist,
MergeWhitelistUsernames: mergeWhitelistUsernames, MergeWhitelistUsernames: mergeWhitelistUsernames,
MergeWhitelistTeams: mergeWhitelistTeams, MergeWhitelistTeams: mergeWhitelistTeams,
EnableBypassAllowlist: bp.EnableBypassAllowlist,
BypassAllowlistUsernames: bypassAllowlistUsernames,
BypassAllowlistTeams: bypassAllowlistTeams,
EnableStatusCheck: bp.EnableStatusCheck, EnableStatusCheck: bp.EnableStatusCheck,
StatusCheckContexts: bp.StatusCheckContexts, StatusCheckContexts: bp.StatusCheckContexts,
RequiredApprovals: bp.RequiredApprovals, RequiredApprovals: bp.RequiredApprovals,

View File

@@ -181,6 +181,9 @@ type ProtectBranchForm struct {
EnableMergeWhitelist bool EnableMergeWhitelist bool
MergeWhitelistUsers string MergeWhitelistUsers string
MergeWhitelistTeams string MergeWhitelistTeams string
EnableBypassAllowlist bool
BypassAllowlistUsers string
BypassAllowlistTeams string
EnableStatusCheck bool EnableStatusCheck bool
StatusCheckContexts string StatusCheckContexts string
RequiredApprovals int64 RequiredApprovals int64

View File

@@ -139,7 +139,7 @@ const (
// - merge: both the head commits must be verified and Gitea must sign the merge commit. // - merge: both the head commits must be verified and Gitea must sign the merge commit.
// - rebase, rebase-merge, squash: Gitea rewrites the commits and signs each, so only Gitea's // - rebase, rebase-merge, squash: Gitea rewrites the commits and signs each, so only Gitea's
// signing ability is checked. // signing ability is checked.
func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, adminForceMerge bool) error { func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *access_model.Permission, pr *issues_model.PullRequest, mergeCheckType MergeCheckType, mergeStyle repo_model.MergeStyle, forceMerge bool) error {
return db.WithTx(stdCtx, func(ctx context.Context) error { return db.WithTx(stdCtx, func(ctx context.Context) error {
if pr.HasMerged { if pr.HasMerged {
return ErrHasMerged return ErrHasMerged
@@ -176,21 +176,21 @@ func CheckPullMergeable(stdCtx context.Context, doer *user_model.User, perm *acc
return ErrIsChecking return ErrIsChecking
} }
if err := CheckPullBranchProtections(ctx, pr, false); err != nil { if errProtection := CheckPullBranchProtections(ctx, pr, false); errProtection != nil {
if !errors.Is(err, ErrNotReadyToMerge) { if !errors.Is(errProtection, ErrNotReadyToMerge) {
log.Error("Error whilst checking pull branch protection for %-v: %v", pr, err) log.Error("Error whilst checking pull branch protection for %-v: %v", pr, errProtection)
return err return errProtection
} }
// Now the branch protection check failed, check whether the failure could be skipped (skip by setting err = nil) // 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 // * when doing Auto Merge (Scheduled Merge After Checks Succeed), skip the branch protection check
if mergeCheckType == MergeCheckTypeAuto { if mergeCheckType == MergeCheckTypeAuto {
err = nil errProtection = nil
} }
// * if admin tries to "Force Merge", they could sometimes skip the branch protection check // * if the doer tries to "Force Merge", check whether it is really allowed
if adminForceMerge { if forceMerge {
isRepoAdmin, errForceMerge := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer) isRepoAdmin, errForceMerge := access_model.IsUserRepoAdmin(ctx, pr.BaseRepo, doer)
if errForceMerge != nil { if errForceMerge != nil {
return fmt.Errorf("IsUserRepoAdmin failed, repo: %v, doer: %v, err: %w", pr.BaseRepoID, doer.ID, errForceMerge) return fmt.Errorf("IsUserRepoAdmin failed, repo: %v, doer: %v, err: %w", pr.BaseRepoID, doer.ID, errForceMerge)
@@ -201,16 +201,18 @@ 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) 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 canForceMerge := isRepoAdmin
blockAdminForceMerge := protectedBranchRule != nil && protectedBranchRule.BlockAdminMergeOverride if protectedBranchRule != nil {
if isRepoAdmin && !blockAdminForceMerge { canForceMerge = git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin)
err = nil }
if canForceMerge {
errProtection = nil
} }
} }
// If there is still a branch protection check error, return it // If there is still a branch protection check error, return it
if err != nil { if errProtection != nil {
return err return errProtection
} }
} }

View File

@@ -305,6 +305,49 @@
{{end}} {{end}}
</div> </div>
</div> </div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.protect_bypass_allowlist"}}</h5>
<div class="grouped fields">
<div class="field">
<div class="ui checkbox">
<input name="enable_bypass_allowlist" type="checkbox" class="toggle-target-enabled" data-target="#bypass_allowlist_box" {{if .Rule.EnableBypassAllowlist}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.protect_enable_bypass_allowlist"}}</label>
<p class="help">{{ctx.Locale.Tr "repo.settings.protect_enable_bypass_allowlist_desc"}}</p>
</div>
</div>
<div id="bypass_allowlist_box" class="grouped fields {{if not .Rule.EnableBypassAllowlist}}disabled{{end}}">
<div class="checkbox-sub-item field">
<label>{{ctx.Locale.Tr "repo.settings.protect_bypass_allowlist_users"}}</label>
<div class="ui multiple search selection dropdown">
<input type="hidden" name="bypass_allowlist_users" value="{{.bypass_allowlist_users}}">
<div class="default text">{{ctx.Locale.Tr "search.user_kind"}}</div>
<div class="menu">
{{range .Users}}
<div class="item" data-value="{{.ID}}">
{{ctx.AvatarUtils.Avatar . 28 "mini"}}{{template "repo/search_name" .}}
</div>
{{end}}
</div>
</div>
</div>
{{if .Owner.IsOrganization}}
<div class="checkbox-sub-item field">
<label>{{ctx.Locale.Tr "repo.settings.protect_bypass_allowlist_teams"}}</label>
<div class="ui multiple search selection dropdown">
<input type="hidden" name="bypass_allowlist_teams" value="{{.bypass_allowlist_teams}}">
<div class="default text">{{ctx.Locale.Tr "search.team_kind"}}</div>
<div class="menu">
{{range .Teams}}
<div class="item" data-value="{{.ID}}">
{{svg "octicon-people"}}
{{.Name}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
</div>
</div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input name="block_on_rejected_reviews" type="checkbox" {{if .Rule.BlockOnRejectedReviews}}checked{{end}}> <input name="block_on_rejected_reviews" type="checkbox" {{if .Rule.BlockOnRejectedReviews}}checked{{end}}>

View File

@@ -22666,6 +22666,20 @@
"type": "string", "type": "string",
"x-go-name": "BranchName" "x-go-name": "BranchName"
}, },
"bypass_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistUsernames"
},
"created_at": { "created_at": {
"type": "string", "type": "string",
"format": "date-time", "format": "date-time",
@@ -22679,6 +22693,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"
@@ -23522,6 +23540,20 @@
"type": "string", "type": "string",
"x-go-name": "BranchName" "x-go-name": "BranchName"
}, },
"bypass_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistUsernames"
},
"dismiss_stale_approvals": { "dismiss_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "DismissStaleApprovals" "x-go-name": "DismissStaleApprovals"
@@ -23530,6 +23562,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"
@@ -24929,6 +24965,20 @@
"type": "boolean", "type": "boolean",
"x-go-name": "BlockOnRejectedReviews" "x-go-name": "BlockOnRejectedReviews"
}, },
"bypass_allowlist_teams": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"type": "array",
"items": {
"type": "string"
},
"x-go-name": "BypassAllowlistUsernames"
},
"dismiss_stale_approvals": { "dismiss_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "DismissStaleApprovals" "x-go-name": "DismissStaleApprovals"
@@ -24937,6 +24987,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"

View File

@@ -2850,6 +2850,20 @@
"type": "string", "type": "string",
"x-go-name": "BranchName" "x-go-name": "BranchName"
}, },
"bypass_allowlist_teams": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistUsernames"
},
"created_at": { "created_at": {
"format": "date-time", "format": "date-time",
"type": "string", "type": "string",
@@ -2863,6 +2877,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"
@@ -3724,6 +3742,20 @@
"type": "string", "type": "string",
"x-go-name": "BranchName" "x-go-name": "BranchName"
}, },
"bypass_allowlist_teams": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistUsernames"
},
"dismiss_stale_approvals": { "dismiss_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "DismissStaleApprovals" "x-go-name": "DismissStaleApprovals"
@@ -3732,6 +3764,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"
@@ -5100,6 +5136,20 @@
"type": "boolean", "type": "boolean",
"x-go-name": "BlockOnRejectedReviews" "x-go-name": "BlockOnRejectedReviews"
}, },
"bypass_allowlist_teams": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistTeams"
},
"bypass_allowlist_usernames": {
"items": {
"type": "string"
},
"type": "array",
"x-go-name": "BypassAllowlistUsernames"
},
"dismiss_stale_approvals": { "dismiss_stale_approvals": {
"type": "boolean", "type": "boolean",
"x-go-name": "DismissStaleApprovals" "x-go-name": "DismissStaleApprovals"
@@ -5108,6 +5158,10 @@
"type": "boolean", "type": "boolean",
"x-go-name": "EnableApprovalsWhitelist" "x-go-name": "EnableApprovalsWhitelist"
}, },
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": { "enable_force_push": {
"type": "boolean", "type": "boolean",
"x-go-name": "EnableForcePush" "x-go-name": "EnableForcePush"

View File

@@ -356,7 +356,11 @@ func testAPIRenameBranch(t *testing.T, doerName, ownerName, repoName, from, to s
func TestAPIBranchProtection(t *testing.T) { func TestAPIBranchProtection(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
t.Run("Basic", testAPIBranchProtectionBasic)
t.Run("BypassAllowlistValidation", testAPIBranchProtectionBypassAllowlistValidation)
}
func testAPIBranchProtectionBasic(t *testing.T) {
// Can create branch protection on branch that not exist // Can create branch protection on branch that not exist
testAPICreateBranchProtection(t, "non-existing/branch", 1, http.StatusCreated) testAPICreateBranchProtection(t, "non-existing/branch", 1, http.StatusCreated)
testAPIGetBranchProtection(t, "non-existing/branch", http.StatusOK) testAPIGetBranchProtection(t, "non-existing/branch", http.StatusOK)
@@ -406,6 +410,35 @@ func TestAPIBranchProtection(t *testing.T) {
testAPIDeleteBranch(t, "no-such-branch", http.StatusNotFound) // non-existing branch, not exist in git or DB testAPIDeleteBranch(t, "no-such-branch", http.StatusNotFound) // non-existing branch, not exist in git or DB
} }
func testAPIBranchProtectionBypassAllowlistValidation(t *testing.T) {
token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
t.Run("IgnoreInvalidBypassUsernamesWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-user"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistUsernames: []string{"nonexistent-user"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
testAPIDeleteBranchProtection(t, ruleName, http.StatusNoContent)
})
t.Run("IgnoreInvalidBypassTeamsWhenDisabled", func(t *testing.T) {
ruleName := "bypass-disabled-invalid-team"
req := NewRequestWithJSON(t, "POST", "/api/v1/repos/org3/repo3/branch_protections", &api.CreateBranchProtectionOption{
RuleName: ruleName,
EnableBypassAllowlist: false,
BypassAllowlistTeams: []string{"nonexistent-team"},
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusCreated)
deleteReq := NewRequestf(t, "DELETE", "/api/v1/repos/org3/repo3/branch_protections/%s", ruleName).
AddTokenAuth(token)
MakeRequest(t, deleteReq, http.StatusNoContent)
})
}
func TestAPICreateBranchWithSyncBranches(t *testing.T) { func TestAPICreateBranchWithSyncBranches(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View File

@@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
pull_model "code.gitea.io/gitea/models/pull" pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
@@ -29,6 +30,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
@@ -1092,6 +1094,71 @@ func TestPullNonMergeForAdminWithBranchProtection(t *testing.T) {
}) })
} }
func TestPullForceMergeForBypassAllowlistUser(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
ownerSession := loginUser(t, "user2")
ownerCtx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
bypassUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user4"})
doAPIAddCollaborator(ownerCtx, bypassUser.Name, perm.AccessModeWrite)(t)
bypassSession := loginUser(t, bypassUser.Name)
forkedName := "repo1-bypass-allowlist"
testRepoFork(t, bypassSession, "user2", "repo1", bypassUser.Name, forkedName, "")
defer testDeleteRepository(t, bypassSession, bypassUser.Name, forkedName)
testEditFile(t, bypassSession, bypassUser.Name, forkedName, "master", "README.md", "Hello, World (Bypass Allowlist)\n")
resp := testPullCreate(t, bypassSession, bypassUser.Name, forkedName, false, "master", "master", "Bypass allowlist merge test pull")
pullURL := test.RedirectURL(resp)
elem := strings.Split(pullURL, "/")
assert.Equal(t, "pulls", elem[3])
prIndex, err := strconv.ParseInt(elem[4], 10, 64)
assert.NoError(t, err)
pbCreateReq := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
"rule_name": "master",
"enable_push": "all",
"enable_status_check": "true",
"status_check_contexts": "gitea/actions",
"block_admin_merge_override": "true",
"enable_bypass_allowlist": "on",
"bypass_allowlist_users": strconv.FormatInt(bypassUser.ID, 10),
})
ownerSession.MakeRequest(t, pbCreateReq, http.StatusSeeOther)
defer testAPIDeleteBranchProtection(t, "master", http.StatusNoContent)
token := getTokenForLoggedInUser(t, bypassSession, auth_model.AccessTokenScopeWriteRepository)
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"])
mergeReq := func(forceMerge bool) *RequestWrapper {
return NewRequestWithValues(t, "POST", fmt.Sprintf("/api/v1/repos/user2/repo1/pulls/%d/merge", prIndex), map[string]string{
"head_commit_id": "",
"merge_when_checks_succeed": "false",
"force_merge": strconv.FormatBool(forceMerge),
"do": "rebase",
}).AddTokenAuth(token)
}
bypassSession.MakeRequest(t, mergeReq(false), http.StatusMethodNotAllowed)
bypassSession.MakeRequest(t, mergeReq(true), http.StatusOK)
baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
pr, err := issues_model.GetPullRequestByIndex(t.Context(), baseRepo.ID, prIndex)
assert.NoError(t, err)
assert.True(t, pr.HasMerged)
})
}
func TestPullSquashMergeEmpty(t *testing.T) { func TestPullSquashMergeEmpty(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user1") // FIXME: don't use admin user for testing session := loginUser(t, "user1") // FIXME: don't use admin user for testing

View File

@@ -1050,7 +1050,7 @@ td .commit-summary {
/* if the element is for a checkbox, then it should have a padding-left to align to the checkbox's text */ /* if the element is for a checkbox, then it should have a padding-left to align to the checkbox's text */
.repository.settings.branches .branch-protection .ui.checkbox .help, .repository.settings.branches .branch-protection .ui.checkbox .help,
.repository.settings.branches .branch-protection .checkbox-sub-item { .repository.settings.branches .branch-protection .checkbox-sub-item {
padding-left: 26px; padding-left: 20px;
} }
.repository.settings.branches .branch-protection .status-check-matched-mark { .repository.settings.branches .branch-protection .status-check-matched-mark {