mirror of
https://github.com/go-gitea/gitea.git
synced 2026-06-14 03:29:55 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user