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

+17
View File
@@ -518,3 +518,20 @@ func ModifyColumn(ctx context.Context, x EngineMigration, tableName string, col
func Iterate[Bean any](ctx context.Context, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
return db.Iterate(ctx, cond, f)
}
func EngineTransaction(x EngineMigration, f func(Session) error) error {
sess := x.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
err := f(sess)
if err != nil {
return err
}
return sess.Commit()
}
func IterateTableByColumn[Bean any](ctx context.Context, x EngineMigration, sess Session, colName string, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
return db.IterateTableByColumn(ctx, x, sess, colName, cond, f)
}
+1
View File
@@ -436,6 +436,7 @@ func prepareMigrationTasks() []*migration {
newMigration(357, "Normalize legacy team authorize values", v29.NormalizeLegacyTeamAuthorize),
newMigration(358, "Track previous remember-me token hashes", v29.AddPreviousAuthTokenHash),
newMigration(359, "Add stage and number columns to action_task_step", v29.AddStageAndNumberToActionTaskStep),
newMigration(360, "Move branch protection settings to JSON", v29.MoveBranchProtectionConfigToJSON),
}
return preparedMigrations
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"context"
"gitea.dev/modelmigration/base"
"gitea.dev/modules/json"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"xorm.io/xorm"
)
type protectedBranchConfigV358 struct {
CanPush bool `xorm:"NOT NULL DEFAULT false" json:"can_push,omitzero"`
EnableWhitelist bool `json:"enable_whitelist,omitzero"`
WhitelistUserIDs []int64 `xorm:"JSON TEXT" json:"whitelist_user_ids,omitzero"`
WhitelistTeamIDs []int64 `xorm:"JSON TEXT" json:"whitelist_team_ids,omitzero"`
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false" json:"enable_merge_whitelist,omitzero"`
WhitelistDeployKeys bool `xorm:"NOT NULL DEFAULT false" json:"-"`
MergeWhitelistUserIDs []int64 `xorm:"JSON TEXT" json:"merge_whitelist_user_ids,omitzero"`
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT" json:"merge_whitelist_team_ids,omitzero"`
EnableBypassAllowlist bool `xorm:"NOT NULL DEFAULT false" json:"enable_bypass_allowlist,omitzero"`
BypassAllowlistUserIDs []int64 `xorm:"JSON TEXT" json:"bypass_allowlist_user_ids,omitzero"`
BypassAllowlistTeamIDs []int64 `xorm:"JSON TEXT" json:"bypass_allowlist_team_ids,omitzero"`
CanForcePush bool `xorm:"NOT NULL DEFAULT false" json:"can_force_push,omitzero"`
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false" json:"enable_force_push_allowlist,omitzero"`
ForcePushAllowlistUserIDs []int64 `xorm:"JSON TEXT" json:"force_push_allowlist_user_ids,omitzero"`
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT" json:"force_push_allowlist_team_ids,omitzero"`
ForcePushAllowlistDeployKeys bool `xorm:"NOT NULL DEFAULT false" json:"-"`
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false" json:"enable_status_check,omitzero"`
StatusCheckContexts []string `xorm:"JSON TEXT" json:"status_check_contexts,omitzero"`
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false" json:"enable_approvals_whitelist,omitzero"`
ApprovalsWhitelistUserIDs []int64 `xorm:"JSON TEXT" json:"approvals_whitelist_user_ids,omitzero"`
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT" json:"approvals_whitelist_team_ids,omitzero"`
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0" json:"required_approvals,omitzero"`
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false" json:"block_on_rejected_reviews,omitzero"`
BlockOnOfficialReviewRequests bool `xorm:"NOT NULL DEFAULT false" json:"block_on_official_review_requests,omitzero"`
BlockOnCodeownerReviews bool `xorm:"NOT NULL DEFAULT false" json:"block_on_codeowner_reviews,omitzero"`
BlockOnOutdatedBranch bool `xorm:"NOT NULL DEFAULT false" json:"block_on_outdated_branch,omitzero"`
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false" json:"dismiss_stale_approvals,omitzero"`
IgnoreStaleApprovals bool `xorm:"NOT NULL DEFAULT false" json:"ignore_stale_approvals,omitzero"`
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false" json:"require_signed_commits,omitzero"`
ProtectedFilePatterns string `xorm:"TEXT" json:"protected_file_patterns,omitzero"`
UnprotectedFilePatterns string `xorm:"TEXT" json:"unprotected_file_patterns,omitzero"`
BlockAdminMergeOverride bool `xorm:"NOT NULL DEFAULT false" json:"block_admin_merge_override,omitzero"`
}
type protectedBranchV358 struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s)"`
BranchName string `xorm:"UNIQUE(s)"`
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
Config string `xorm:"LONGTEXT"`
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
}
func (protectedBranchV358) TableName() string { return "protected_branch" }
type intermediateProtectedBranch struct {
ID int64 `xorm:"pk autoincr"`
NewConfig string `xorm:"LONGTEXT config"`
OldConfig protectedBranchConfigV358 `xorm:"extends"`
}
func (intermediateProtectedBranch) TableName() string { return "protected_branch" }
func MoveBranchProtectionConfigToJSON(ctx context.Context, x base.EngineMigration) error {
if _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(protectedBranchV358)); err != nil {
return err
}
exists, err := x.Dialect().IsColumnExist(x.DB(), ctx, "protected_branch", "can_push")
if err != nil || !exists {
return err
}
err = base.EngineTransaction(x, func(sess base.Session) error {
err := base.IterateTableByColumn(ctx, x, sess, "id", nil, func(ctx context.Context, pb *intermediateProtectedBranch) error {
if pb.NewConfig != "" {
return nil // skip the already migrated fields
}
const deployKeyUserID int64 = -3 // frozen user.DeployKeyUserID
if pb.OldConfig.WhitelistDeployKeys {
pb.OldConfig.WhitelistUserIDs = append(pb.OldConfig.WhitelistUserIDs, deployKeyUserID)
}
if pb.OldConfig.ForcePushAllowlistDeployKeys {
pb.OldConfig.ForcePushAllowlistUserIDs = append(pb.OldConfig.ForcePushAllowlistUserIDs, deployKeyUserID)
}
configJson, err := json.Marshal(pb.OldConfig)
util.MustNoError(err)
_, err = sess.ID(pb.ID).Cols("config").Update(&protectedBranchV358{Config: string(configJson)})
return err
})
if err != nil {
return err
}
legacyTableInfo, err := x.TableInfo(new(protectedBranchConfigV358))
if err != nil {
return err
}
return base.DropTableColumns(sess, "protected_branch", legacyTableInfo.ColumnsSeq()...)
})
if err != nil {
return err
}
// SQLite rebuilds the table when dropping columns, so restore its unique index.
return x.Sync(new(protectedBranchV358))
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v29
import (
"fmt"
"testing"
"gitea.dev/modelmigration/migrationtest"
"gitea.dev/modules/json"
"gitea.dev/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type protectedBranchBeforeV358 struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"UNIQUE(s)"`
BranchName string `xorm:"UNIQUE(s)"`
Priority int64
CreatedUnix timeutil.TimeStamp
UpdatedUnix timeutil.TimeStamp
Config protectedBranchConfigV358 `xorm:"extends"`
}
func (protectedBranchBeforeV358) TableName() string { return "protected_branch" }
func TestMoveBranchProtectionConfigToJSON(t *testing.T) {
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(protectedBranchBeforeV358))
defer deferrable()
if x == nil || t.Failed() {
return
}
const configJSON = `{
"can_push":true,
"enable_whitelist":true,
"whitelist_user_ids":[2,4,-3],
"whitelist_team_ids":[1,2],
"enable_merge_whitelist":true,
"merge_whitelist_user_ids":[3,5],
"merge_whitelist_team_ids":[3,4],
"enable_bypass_allowlist":true,
"bypass_allowlist_user_ids":[6,7],
"bypass_allowlist_team_ids":[5,6],
"can_force_push":true,
"enable_force_push_allowlist":true,
"force_push_allowlist_user_ids":[8,9,-3],
"force_push_allowlist_team_ids":[7,8],
"enable_status_check":true,
"status_check_contexts":["ci/*","lint"],
"enable_approvals_whitelist":true,
"approvals_whitelist_user_ids":[10,11],
"approvals_whitelist_team_ids":[9,10],
"required_approvals":2,
"block_on_rejected_reviews":true,
"block_on_official_review_requests":true,
"block_on_codeowner_reviews":true,
"block_on_outdated_branch":true,
"dismiss_stale_approvals":true,
"ignore_stale_approvals":true,
"require_signed_commits":true,
"protected_file_patterns":"*.go;*.md",
"unprotected_file_patterns":"docs/**",
"block_admin_merge_override":true
}`
var config protectedBranchConfigV358
require.NoError(t, json.Unmarshal([]byte(configJSON), &config))
config.WhitelistUserIDs = []int64{2, 4}
config.ForcePushAllowlistUserIDs = []int64{8, 9}
config.WhitelistDeployKeys = true
config.ForcePushAllowlistDeployKeys = true
for i := range 101 {
branch := protectedBranchBeforeV358{RepoID: 1, BranchName: fmt.Sprintf("release-%d/*", i), Priority: int64(i), CreatedUnix: 123, UpdatedUnix: 456}
if i%2 == 0 {
branch.Config = config
} else if i == 1 {
branch.Config.StatusCheckContexts = []string{}
}
_, err := x.Insert(&branch)
require.NoError(t, err)
}
require.NoError(t, MoveBranchProtectionConfigToJSON(t.Context(), x))
// A retry must preserve the migrated configuration and its indexes.
require.NoError(t, MoveBranchProtectionConfigToJSON(t.Context(), x))
var branches []protectedBranchV358
require.NoError(t, x.Asc("id").Find(&branches))
require.Len(t, branches, 101)
for i, branch := range branches {
assert.EqualValues(t, i+1, branch.ID)
assert.EqualValues(t, 1, branch.RepoID)
assert.Equal(t, fmt.Sprintf("release-%d/*", i), branch.BranchName)
assert.EqualValues(t, i, branch.Priority)
assert.EqualValues(t, 123, branch.CreatedUnix)
assert.EqualValues(t, 456, branch.UpdatedUnix)
if i%2 == 0 {
assert.JSONEq(t, configJSON, branch.Config)
} else if i == 1 {
assert.JSONEq(t, `{"status_check_contexts":[]}`, branch.Config)
} else {
assert.JSONEq(t, "{}", branch.Config)
}
}
tables, err := x.DBMetas()
require.NoError(t, err)
for _, table := range tables {
if table.Name == "protected_branch" {
assert.ElementsMatch(t, []string{"id", "repo_id", "branch_name", "priority", "config", "created_unix", "updated_unix"}, table.ColumnsSeq())
}
}
_, err = x.Insert(&protectedBranchV358{RepoID: 1, BranchName: branches[0].BranchName})
require.Error(t, err, "the repository/rule unique constraint must survive the migration")
}