refactor: migrate protected branch fields into one json column and support system users (#39735)

1. migrate protected branch fields into one json column
2. use the dropdown list for push/force-push user selection, and Actions
user can be added into the list now.
This commit is contained in:
wxiaoguang authored and GitHub committed 2026-10-13 00:40:21 +00:00
1 parent f270534e83
commit c6e2513570
25 files changed
+659 -334

No files matched your search

+9 -5
View File
@@ -13,8 +13,12 @@ import (
"xorm.io/xorm/schemas"
)
func iterateTableByColumn[Bean any](ctx context.Context, colName string, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
table, err := xormEngine.TableInfo(new(Bean))
type sqlTableInfoProvider interface {
TableInfo(bean any) (*schemas.Table, error)
}
func IterateTableByColumn[Bean any](ctx context.Context, tableInfo sqlTableInfoProvider, sess SQLSession, colName string, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
table, err := tableInfo.TableInfo(new(Bean))
if err != nil {
return err
}
@@ -36,7 +40,7 @@ func iterateTableByColumn[Bean any](ctx context.Context, colName string, cond bu
}
beans := make([]*Bean, 0, batchSize)
query := GetEngine(ctx).Table(table.Name).Asc(colName)
query := sess.Table(table.Name).Asc(colName)
batchCond := cond
if lastColValue != nil {
@@ -68,9 +72,9 @@ func iterateTableByColumn[Bean any](ctx context.Context, colName string, cond bu
}
func IterateByColumn[Bean any](ctx context.Context, colName string, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
return iterateTableByColumn(ctx, colName, cond, f)
return IterateTableByColumn(ctx, xormEngine, GetEngine(ctx), colName, cond, f)
}
func Iterate[Bean any](ctx context.Context, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
return iterateTableByColumn(ctx, "", cond, f)
return IterateTableByColumn(ctx, xormEngine, GetEngine(ctx), "", cond, f)
}
+60 -57
View File
@@ -28,50 +28,53 @@ var ErrBranchIsProtected = util.ErrorWrap(util.ErrPermissionDenied, "branch is p
// ProtectedBranch struct
type ProtectedBranch struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s)"`
Repo *repo_model.Repository `xorm:"-"`
RuleName string `xorm:"'branch_name' UNIQUE(s)"` // a branch name or a glob match to branch name
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
globRule glob.Glob `xorm:"-"`
isPlainName bool `xorm:"-"`
CanPush bool `xorm:"NOT NULL DEFAULT false"`
EnableWhitelist bool
WhitelistUserIDs []int64 `xorm:"JSON TEXT"`
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
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"`
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
ForcePushAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false"`
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
StatusCheckContexts []string `xorm:"JSON TEXT"`
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT"`
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false"`
BlockOnCodeownerReviews bool `xorm:"NOT NULL DEFAULT false"`
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false"`
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
IgnoreStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
ProtectedFilePatterns string `xorm:"TEXT"`
UnprotectedFilePatterns string `xorm:"TEXT"`
BlockAdminMergeOverride bool `xorm:"NOT NULL DEFAULT false"`
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s)"`
Repo *repo_model.Repository `xorm:"-"`
RuleName string `xorm:"'branch_name' UNIQUE(s)"` // a branch name or a glob match to branch name
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
globRule glob.Glob `xorm:"-"`
isPlainName bool `xorm:"-"`
ProtectedBranchConfig `xorm:"'config' JSON LONGTEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
}
// ProtectedBranchConfig stores protection settings in a single JSON column.
type ProtectedBranchConfig struct {
CanPush bool `json:"can_push,omitzero"`
EnableWhitelist bool `json:"enable_whitelist,omitzero"`
WhitelistUserIDs []int64 `json:"whitelist_user_ids,omitzero"`
WhitelistTeamIDs []int64 `json:"whitelist_team_ids,omitzero"`
EnableMergeWhitelist bool `json:"enable_merge_whitelist,omitzero"`
MergeWhitelistUserIDs []int64 `json:"merge_whitelist_user_ids,omitzero"`
MergeWhitelistTeamIDs []int64 `json:"merge_whitelist_team_ids,omitzero"`
EnableBypassAllowlist bool `json:"enable_bypass_allowlist,omitzero"`
BypassAllowlistUserIDs []int64 `json:"bypass_allowlist_user_ids,omitzero"`
BypassAllowlistTeamIDs []int64 `json:"bypass_allowlist_team_ids,omitzero"`
CanForcePush bool `json:"can_force_push,omitzero"`
EnableForcePushAllowlist bool `json:"enable_force_push_allowlist,omitzero"`
ForcePushAllowlistUserIDs []int64 `json:"force_push_allowlist_user_ids,omitzero"`
ForcePushAllowlistTeamIDs []int64 `json:"force_push_allowlist_team_ids,omitzero"`
EnableStatusCheck bool `json:"enable_status_check,omitzero"`
StatusCheckContexts []string `json:"status_check_contexts,omitzero"`
EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist,omitzero"`
ApprovalsWhitelistUserIDs []int64 `json:"approvals_whitelist_user_ids,omitzero"`
ApprovalsWhitelistTeamIDs []int64 `json:"approvals_whitelist_team_ids,omitzero"`
RequiredApprovals int64 `json:"required_approvals,omitzero"`
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews,omitzero"`
BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests,omitzero"`
BlockOnCodeownerReviews bool `json:"block_on_codeowner_reviews,omitzero"`
BlockOnOutdatedBranch bool `json:"block_on_outdated_branch,omitzero"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals,omitzero"`
IgnoreStaleApprovals bool `json:"ignore_stale_approvals,omitzero"`
RequireSignedCommits bool `json:"require_signed_commits,omitzero"`
ProtectedFilePatterns string `json:"protected_file_patterns,omitzero"`
UnprotectedFilePatterns string `json:"unprotected_file_patterns,omitzero"`
BlockAdminMergeOverride bool `json:"block_admin_merge_override,omitzero"`
}
func init() {
db.RegisterModel(new(ProtectedBranch))
}
@@ -525,13 +528,17 @@ func updateUserWhitelist(ctx context.Context, repo *repo_model.Repository, curre
whitelist = make([]int64, 0, len(newWhitelist))
for _, userID := range newWhitelist {
if userID == user_model.ActionsUserID || userID == user_model.DeployKeyUserID {
whitelist = append(whitelist, userID)
continue
}
user, err := user_model.GetUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("GetUserByID [user_id: %d, repo_id: %d]: %v", userID, repo.ID, err)
return nil, fmt.Errorf("GetUserByID [user_id: %d, repo_id: %d]: %w", userID, repo.ID, err)
}
perm, err := access_model.GetIndividualUserRepoPermission(ctx, repo, user)
if err != nil {
return nil, fmt.Errorf("GetIndividualUserRepoPermission [user_id: %d, repo_id: %d]: %v", userID, repo.ID, err)
return nil, fmt.Errorf("GetIndividualUserRepoPermission [user_id: %d, repo_id: %d]: %w", userID, repo.ID, err)
}
if !perm.CanWrite(unit.TypeCode) {
@@ -589,7 +596,7 @@ func DeleteProtectedBranch(ctx context.Context, repo *repo_model.Repository, id
}
// removeIDsFromProtectedBranch is a helper function to remove IDs from protected branch options
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64, columnNames []string) error {
func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID, teamID int64) error {
lenUserIDs, lenForcePushIDs, lenApprovalIDs, lenMergeIDs := len(p.WhitelistUserIDs), len(p.ForcePushAllowlistUserIDs), len(p.ApprovalsWhitelistUserIDs), len(p.MergeWhitelistUserIDs)
lenTeamIDs, lenForcePushTeamIDs, lenApprovalTeamIDs, lenMergeTeamIDs := len(p.WhitelistTeamIDs), len(p.ForcePushAllowlistTeamIDs), len(p.ApprovalsWhitelistTeamIDs), len(p.MergeWhitelistTeamIDs)
@@ -615,7 +622,7 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI
lenForcePushTeamIDs != len(p.ForcePushAllowlistTeamIDs) ||
lenApprovalTeamIDs != len(p.ApprovalsWhitelistTeamIDs) ||
lenMergeTeamIDs != len(p.MergeWhitelistTeamIDs)) {
if _, err := db.GetEngine(ctx).ID(p.ID).Cols(columnNames...).Update(p); err != nil {
if _, err := db.GetEngine(ctx).ID(p.ID).Cols("config").Update(p); err != nil {
return fmt.Errorf("updateProtectedBranches: %v", err)
}
}
@@ -624,22 +631,18 @@ func removeIDsFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userI
// RemoveUserIDFromProtectedBranch removes all user ids from protected branch options
func RemoveUserIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, userID int64) error {
columnNames := []string{
"whitelist_user_i_ds",
"force_push_allowlist_user_i_ds",
"merge_whitelist_user_i_ds",
"approvals_whitelist_user_i_ds",
}
return removeIDsFromProtectedBranch(ctx, p, userID, 0, columnNames)
return removeIDsFromProtectedBranch(ctx, p, userID, 0)
}
// RemoveTeamIDFromProtectedBranch removes all team ids from protected branch options
func RemoveTeamIDFromProtectedBranch(ctx context.Context, p *ProtectedBranch, teamID int64) error {
columnNames := []string{
"whitelist_team_i_ds",
"force_push_allowlist_team_i_ds",
"merge_whitelist_team_i_ds",
"approvals_whitelist_team_i_ds",
}
return removeIDsFromProtectedBranch(ctx, p, 0, teamID, columnNames)
return removeIDsFromProtectedBranch(ctx, p, 0, teamID)
}
func (protectBranch *ProtectedBranch) HasDeployKeyPush() bool {
return slices.Contains(protectBranch.WhitelistUserIDs, user_model.DeployKeyUserID)
}
func (protectBranch *ProtectedBranch) HasDeployKeyForcePush() bool {
return slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user_model.DeployKeyUserID)
}
+26
View File
@@ -12,6 +12,7 @@ import (
user_model "gitea.dev/models/user"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBranchRuleMatch(t *testing.T) {
@@ -202,3 +203,28 @@ func TestCanBypassBranchProtection(t *testing.T) {
// User does not bypass when not in allowlisted teams.
testBypass(t, false, pb, user, false)
}
func TestProtectedBranchConfig(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
pb := &ProtectedBranch{
RepoID: 1, RuleName: "config-test",
ProtectedBranchConfig: ProtectedBranchConfig{
CanPush: true, WhitelistUserIDs: []int64{2, 4}, WhitelistTeamIDs: []int64{1, 2}, StatusCheckContexts: []string{},
},
}
require.NoError(t, db.Insert(ctx, pb))
loaded := unittest.AssertExistsAndLoadBean(t, &ProtectedBranch{ID: pb.ID})
assert.Equal(t, pb.ProtectedBranchConfig, loaded.ProtectedBranchConfig)
require.NoError(t, RemoveUserIDFromProtectedBranch(ctx, loaded, 2))
require.NoError(t, RemoveTeamIDFromProtectedBranch(ctx, loaded, 1))
loaded = unittest.AssertExistsAndLoadBean(t, &ProtectedBranch{ID: pb.ID})
assert.Equal(t, []int64{4}, loaded.WhitelistUserIDs)
assert.Equal(t, []int64{2}, loaded.WhitelistTeamIDs)
assert.True(t, loaded.CanPush)
loaded.ProtectedBranchConfig = ProtectedBranchConfig{}
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
require.NoError(t, UpdateProtectBranch(ctx, repo, loaded, WhitelistOptions{}))
loaded = unittest.AssertExistsAndLoadBean(t, &ProtectedBranch{ID: pb.ID})
assert.Equal(t, ProtectedBranchConfig{}, loaded.ProtectedBranchConfig)
}
+1 -1
View File
@@ -188,5 +188,5 @@ func MakeIDsFromAPIAssigneesToAdd(ctx context.Context, oneAssignee string, multi
}
// Get the IDs of all assignees
return user_model.GetUserIDsByNames(ctx, requestAssignees, false)
return user_model.GetDoerUserIDsByNames(ctx, requestAssignees)
}
+1 -1
View File
@@ -433,7 +433,7 @@ func TestRecalculateReviewsOfficial(t *testing.T) {
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("config").Update(rule)
assert.NoError(t, err)
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
+48 -18
View File
@@ -560,13 +560,13 @@ func GetUserSalt() (string, error) {
}
type globalVarsStruct struct {
customCharsReplacement *strings.Replacer
removeCharsRE *regexp.Regexp
transformDiacritics transform.Transformer
replaceCharsHyphenRE *regexp.Regexp
emailToReplacer *strings.Replacer
systemUserNewFuncs map[int64]func() *User
systemUserNameIdMap map[string]int64
customCharsReplacement *strings.Replacer
removeCharsRE *regexp.Regexp
transformDiacritics transform.Transformer
replaceCharsHyphenRE *regexp.Regexp
emailToReplacer *strings.Replacer
systemUserNewFuncs map[int64]func() *User
systemUserLowerNameIdMap map[string]int64
}
var globalVars = sync.OnceValue(func() *globalVarsStruct {
@@ -592,11 +592,11 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
userFuncs := []func() *User{NewGhostUser, NewActionsUser, NewDeployKeyUser, NewCliUser, NewAuthSourceUser}
ret.systemUserNewFuncs = map[int64]func() *User{}
ret.systemUserNameIdMap = map[string]int64{}
ret.systemUserLowerNameIdMap = map[string]int64{}
for _, fn := range userFuncs {
u := fn()
ret.systemUserNewFuncs[u.ID] = fn
ret.systemUserNameIdMap[u.LowerName] = u.ID
ret.systemUserLowerNameIdMap[u.LowerName] = u.ID
}
return ret
})
@@ -1148,20 +1148,50 @@ func GetMailableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([]
Find(&ous)
}
// GetUserIDsByNames returns a slice of ids corresponds to names.
func GetUserIDsByNames(ctx context.Context, names []string, ignoreNonExistent bool) ([]int64, error) {
ids := make([]int64, 0, len(names))
func getDoerUserIDsByNames(ctx context.Context, names []string, allowSystemDoer bool) ([]int64, error) {
namesLower := make([]string, 0, len(names))
namesMap := make(map[string]*User, len(names))
for _, name := range names {
u, err := GetUserByName(ctx, name)
if err != nil {
if ignoreNonExistent {
continue
nameLower := strings.ToLower(name)
namesLower = append(namesLower, nameLower)
namesMap[nameLower] = nil
if allowSystemDoer {
if id, ok := globalVars().systemUserLowerNameIdMap[nameLower]; ok && id != GhostUserID {
namesMap[nameLower] = globalVars().systemUserNewFuncs[id]()
}
return nil, err
}
}
var users []*User
err := db.GetEngine(ctx).In("lower_name", namesLower).Where("`type` IN (?,?)", UserTypeIndividual, UserTypeBot).Find(&users)
if err != nil {
return nil, err
}
for _, user := range users {
namesMap[user.LowerName] = user
}
ids := make([]int64, 0, len(names))
var errFinal error
for _, nameLower := range namesLower {
u := namesMap[nameLower]
if u == nil {
errFinal = ErrUserNotExist{Name: nameLower}
continue
}
ids = append(ids, u.ID)
}
return ids, nil
return ids, errFinal
}
func GetDoerUserIDsByNamesWithSystem(ctx context.Context, names []string) ([]int64, error) {
return getDoerUserIDsByNames(ctx, names, true)
}
// GetDoerUserIDsByNames returns a slice of ids corresponds to names.
// It always returns found user IDs even if some names are not found, but returns an error if any name is not found.
func GetDoerUserIDsByNames(ctx context.Context, names []string) ([]int64, error) {
return getDoerUserIDsByNames(ctx, names, false)
}
// GetUsersBySource returns a list of Users for a login source
+8 -4
View File
@@ -73,7 +73,7 @@ func NewActionsUserWithTaskID(id int64) *User {
}
func NewDeployKeyUser() *User {
return newSystemUser(DeployKeyUserID, "(deploy-key)", "Deploy Key")
return newSystemUser(DeployKeyUserID, "gitea:deploy-key", "Deploy Key")
}
func GetDeployKeyUserDeployKeyID(u *User) (int64, bool) {
@@ -94,17 +94,21 @@ func NewDeployKeyUserWithKeyID(id int64) *User {
func NewCliUser() *User {
// for audit log only
return newSystemUser(CliUserID, "(gitea-cli)", "Gitea CLI")
return newSystemUser(CliUserID, "gitea:cli", "Gitea CLI")
}
func NewAuthSourceUser() *User {
// for audit log only
return newSystemUser(AuthSourceUserID, "(gitea-auth-source)", "Gitea Auth Source")
return newSystemUser(AuthSourceUserID, "gitea:auth-source", "Gitea Auth Source")
}
func GetSystemUserByName(name string) *User {
lowerName := strings.ToLower(name)
uid := globalVars().systemUserNameIdMap[lowerName]
uid := globalVars().systemUserLowerNameIdMap[lowerName]
return GetSystemUserByID(uid)
}
func GetSystemUserByID(uid int64) *User {
if fn := globalVars().systemUserNewFuncs[uid]; fn != nil {
return fn()
}
+7 -9
View File
@@ -21,6 +21,7 @@ import (
"gitea.dev/modules/structs"
"gitea.dev/modules/test"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -363,18 +364,15 @@ func TestCreateUserWithoutCustomTimestamps(t *testing.T) {
assert.LessOrEqual(t, fetched.UpdatedUnix, timestampEnd)
}
func TestGetUserIDsByNames(t *testing.T) {
func TestGetDoerUserIDsByNames(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ids, err := user_model.GetDoerUserIDsByNames(t.Context(), []string{"user1", "user2", "none_existing_user"})
assert.ErrorIs(t, err, util.ErrNotExist)
assert.Equal(t, []int64{1, 2}, ids)
// ignore non existing
IDs, err := user_model.GetUserIDsByNames(t.Context(), []string{"user1", "user2", "none_existing_user"}, true)
ids, err = user_model.GetDoerUserIDsByNamesWithSystem(t.Context(), []string{"gitea-actions", "gitea:deploy-key"})
assert.NoError(t, err)
assert.Equal(t, []int64{1, 2}, IDs)
// ignore non existing
IDs, err = user_model.GetUserIDsByNames(t.Context(), []string{"user1", "do_not_exist"}, false)
assert.Error(t, err)
assert.Equal(t, []int64(nil), IDs)
assert.Equal(t, []int64{-2, -3}, ids)
}
func TestGetMaileableUsersByIDs(t *testing.T) {