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"`
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,29 @@ 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) 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)
func IsUserOfficialReviewer(ctx context.Context, protectBranch *ProtectedBranch, user *user_model.User) (bool, error) {
repo, err := repo_model.GetRepositoryByID(ctx, protectBranch.RepoID)
@@ -347,6 +373,9 @@ type WhitelistOptions struct {
ApprovalsUserIDs []int64
ApprovalsTeamIDs []int64
BypassUserIDs []int64
BypassTeamIDs []int64
}
// UpdateProtectBranch saves branch protection options of repository.
@@ -387,6 +416,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 +447,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.

View File

@@ -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,51 @@ 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}) // 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(332, "Add last_sync_unix to mirror", v1_27.AddLastSyncUnixToMirror),
newMigration(333, "Add bypass allowlist to branch protection", v1_27.AddBranchProtectionBypassAllowlist),
}
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"`
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"`

View File

@@ -1819,6 +1819,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",

View File

@@ -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,21 @@ func EditBranchProtection(ctx *context.APIContext) {
} else {
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 form.PushWhitelistTeams != nil {
whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false)
@@ -1066,6 +1109,23 @@ func EditBranchProtection(ctx *context.APIContext) {
} else {
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{
@@ -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)

View File

@@ -371,8 +371,8 @@ 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() {
// If we can bypass branch protection we can ignore status checks, reviews and protected files
if git_model.CanBypassBranchProtection(ctx, protectBranch, ctx.user, ctx.userPerm.IsAdmin()) {
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
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 {
ctx.ServerError("IsUserAllowedToMerge", err)
return
@@ -954,16 +954,19 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
// this logic is from:
// {{$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)
data.HasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
data.hasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection ||
data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles ||
data.hasStatusCheckBlocker
// this logic is from:
// {{$canMergeNow := and (or (and (not $.ProtectedBranch.BlockAdminMergeOverride) $.IsRepoAdmin) (not $notAllOverridableChecksOk)) (or (not .AllowMerge) (not .RequireSigned) .WillSign)}}
// HINT: legacy "(not .AllowMerge)" is not right (always false, does nothing), fixed here
data.canBypassProtection = isRepoAdmin
data.canBypassProtectionAsAdmin = isRepoAdmin
if ctx.IsSigned && prInfo.ProtectedBranchRule != nil {
data.canBypassProtection = git_model.CanBypassBranchProtection(ctx, prInfo.ProtectedBranchRule, ctx.Doer, isRepoAdmin)
data.canBypassProtectionAsAdmin = isRepoAdmin && !prInfo.ProtectedBranchRule.BlockAdminMergeOverride
}
// CanMergeNow means: if the doer has write permission, whether the PR can be merged now
adminCanOverrideBlockers := (prInfo.ProtectedBranchRule == nil || !prInfo.ProtectedBranchRule.BlockAdminMergeOverride) && isRepoAdmin
data.CanMergeNow = (!data.HasOverridableBlockers || adminCanOverrideBlockers) && // status checks are satisfied
data.canMergeNow = (!data.hasOverridableBlockers || data.canBypassProtection) && // status checks are satisfied
(!data.requireSigned || data.willSign) // signing requirement is satisfied
prInfo.prepareMergeBoxFormProps(ctx)

View File

@@ -276,9 +276,11 @@ type pullMergeBoxData struct {
StatusCheckData *pullCommitStatusCheckData
ShowStatusCheck bool
HasOverridableBlockers bool
CanMergeNow bool // PR is mergeable, either no blocker, or doer is admin and can bypass the blockers
allowMerge bool // doer has permission to merge
hasOverridableBlockers bool
canMergeNow bool // PR is mergeable, either no blocker, or doer can bypass the blockers
hasPermToMerge bool // doer has permission to merge
canBypassProtection bool
canBypassProtectionAsAdmin bool
ShowUpdatePullInfo bool
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(
svg.RenderHTML("octicon-info"),
ctx.Locale.Tr("repo.pulls.no_merge_access"),
)
}
if data.CanMergeNow {
if data.HasOverridableBlockers {
if data.canMergeNow {
if data.hasOverridableBlockers {
prompt := ctx.Locale.Tr("repo.pulls.required_status_check_bypass_allowlist")
if data.canBypassProtectionAsAdmin {
prompt = ctx.Locale.Tr("repo.pulls.required_status_check_administrator")
}
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(
svg.RenderHTML("octicon-dot-fill"),
ctx.Locale.Tr("repo.pulls.required_status_check_administrator"),
prompt,
)
} else if pull.IsStatusMergeable() || pull.IsEmpty() {
prInfo.MergeBoxData.infoMergePrompts.AddInfoItem(

View File

@@ -20,7 +20,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
if pull.HasMerged || prInfo.issue.IsClosed {
return
}
if !prInfo.MergeBoxData.allowMerge {
if !prInfo.MergeBoxData.hasPermToMerge {
return
}
@@ -76,7 +76,7 @@ func (prInfo *pullRequestViewInfo) prepareMergeBoxFormProps(ctx *context.Context
defaultSquashMergeCommitMessages = pull_service.GetSquashMergeCommitMessages(ctx, pull)
}
allOverridableChecksOk := !prInfo.MergeBoxData.HasOverridableBlockers
allOverridableChecksOk := !prInfo.MergeBoxData.hasOverridableBlockers
mergeFormProps := map[string]any{
"baseLink": prInfo.issue.Link(),
"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"),
"textMergeCommitId": ctx.Locale.Tr("repo.pulls.merge_commit_id"),
"canMergeNow": prInfo.MergeBoxData.CanMergeNow,
"canMergeNow": prInfo.MergeBoxData.canMergeNow,
"allOverridableChecksOk": allOverridableChecksOk,
"emptyCommit": pull.IsEmpty(),
"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
generalHideAutoMerge := prInfo.MergeBoxData.CanMergeNow && allOverridableChecksOk
generalHideAutoMerge := prInfo.MergeBoxData.canMergeNow && allOverridableChecksOk
var mergeStyles []any
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["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
@@ -98,6 +99,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), ",")
}
@@ -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
if f.RequiredApprovals < 0 {
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
if f.EnableStatusCheck {
patterns := strings.Split(strings.ReplaceAll(f.StatusCheckContexts, "\r", "\n"), "\n")
@@ -271,6 +283,8 @@ func SettingsProtectedBranchPost(ctx *context.Context) {
MergeTeamIDs: mergeWhitelistTeams,
ApprovalsUserIDs: approvalsWhitelistUsers,
ApprovalsTeamIDs: approvalsWhitelistTeams,
BypassUserIDs: bypassAllowlistUsers,
BypassTeamIDs: bypassAllowlistTeams,
}); err != nil {
ctx.ServerError("CreateOrUpdateProtectedBranch", err)
return

View File

@@ -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,

View File

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

View File

@@ -139,7 +139,7 @@ const (
// - 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
// 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 {
if pr.HasMerged {
return ErrHasMerged
@@ -176,21 +176,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 forceMerge {
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)
@@ -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)
}
// 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 := isRepoAdmin
if protectedBranchRule != nil {
canForceMerge = git_model.CanBypassBranchProtection(ctx, protectedBranchRule, doer, isRepoAdmin)
}
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
}
}

View File

@@ -305,6 +305,49 @@
{{end}}
</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="ui checkbox">
<input name="block_on_rejected_reviews" type="checkbox" {{if .Rule.BlockOnRejectedReviews}}checked{{end}}>

View File

@@ -22666,6 +22666,20 @@
"type": "string",
"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": {
"type": "string",
"format": "date-time",
@@ -22679,6 +22693,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
@@ -23522,6 +23540,20 @@
"type": "string",
"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": {
"type": "boolean",
"x-go-name": "DismissStaleApprovals"
@@ -23530,6 +23562,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
@@ -24929,6 +24965,20 @@
"type": "boolean",
"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": {
"type": "boolean",
"x-go-name": "DismissStaleApprovals"
@@ -24937,6 +24987,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"

View File

@@ -2850,6 +2850,20 @@
"type": "string",
"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": {
"format": "date-time",
"type": "string",
@@ -2863,6 +2877,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
@@ -3724,6 +3742,20 @@
"type": "string",
"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": {
"type": "boolean",
"x-go-name": "DismissStaleApprovals"
@@ -3732,6 +3764,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"x-go-name": "EnableForcePush"
@@ -5100,6 +5136,20 @@
"type": "boolean",
"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": {
"type": "boolean",
"x-go-name": "DismissStaleApprovals"
@@ -5108,6 +5158,10 @@
"type": "boolean",
"x-go-name": "EnableApprovalsWhitelist"
},
"enable_bypass_allowlist": {
"type": "boolean",
"x-go-name": "EnableBypassAllowlist"
},
"enable_force_push": {
"type": "boolean",
"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) {
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
testAPICreateBranchProtection(t, "non-existing/branch", 1, http.StatusCreated)
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
}
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) {
defer tests.PrepareTestEnv(t)()

View File

@@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
@@ -29,6 +30,7 @@ import (
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/queue"
"code.gitea.io/gitea/modules/setting"
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) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
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 */
.repository.settings.branches .branch-protection .ui.checkbox .help,
.repository.settings.branches .branch-protection .checkbox-sub-item {
padding-left: 26px;
padding-left: 20px;
}
.repository.settings.branches .branch-protection .status-check-matched-mark {