mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-18 19:11:06 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
20
models/migrations/v1_27/v333.go
Normal file
20
models/migrations/v1_27/v333.go
Normal 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
|
||||
}
|
||||
60
models/migrations/v1_27/v333_test.go
Normal file
60
models/migrations/v1_27/v333_test.go
Normal 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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}>
|
||||
|
||||
54
templates/swagger/v1_json.tmpl
generated
54
templates/swagger/v1_json.tmpl
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user