3396440926
Add organization-scoped branch protection rules that cascade to all
repos within the org. Repo-level rules take precedence; org rules
serve as the fallback when no repo rule matches a branch.
- New table: org_protected_branch (migration v332)
- OrgProtectedBranch model with full CRUD operations
- API endpoints: GET/POST/PATCH/DELETE /api/v1/orgs/{org}/branch_protections
- Inheritance via GetFirstMatchProtectedBranchRule() fallback
- InheritedFrom field added to BranchProtection API response
- Org rules use team-based whitelists (no per-user IDs at org level)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
7.9 KiB
Go
198 lines
7.9 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package git
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models/db"
|
|
"code.gitea.io/gitea/modules/glob"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/timeutil"
|
|
|
|
"xorm.io/builder"
|
|
)
|
|
|
|
// OrgProtectedBranch represents an org-level branch protection ruleset.
|
|
// These rules cascade to all repositories within the organization.
|
|
type OrgProtectedBranch struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
OrgID int64 `xorm:"UNIQUE(s) index"`
|
|
RuleName string `xorm:"UNIQUE(s)"` // branch glob pattern
|
|
Priority int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
globRule glob.Glob `xorm:"-"`
|
|
isPlainName bool `xorm:"-"`
|
|
CanPush bool `xorm:"NOT NULL DEFAULT false"`
|
|
EnableWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
|
WhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
|
EnableMergeWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
|
MergeWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
|
CanForcePush bool `xorm:"NOT NULL DEFAULT false"`
|
|
EnableForcePushAllowlist bool `xorm:"NOT NULL DEFAULT false"`
|
|
ForcePushAllowlistTeamIDs []int64 `xorm:"JSON TEXT"`
|
|
EnableStatusCheck bool `xorm:"NOT NULL DEFAULT false"`
|
|
StatusCheckContexts []string `xorm:"JSON TEXT"`
|
|
RequiredApprovals int64 `xorm:"NOT NULL DEFAULT 0"`
|
|
EnableApprovalsWhitelist bool `xorm:"NOT NULL DEFAULT false"`
|
|
ApprovalsWhitelistTeamIDs []int64 `xorm:"JSON TEXT"`
|
|
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
|
|
BlockOnOfficialReviewRequests 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"`
|
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(OrgProtectedBranch))
|
|
}
|
|
|
|
func (o *OrgProtectedBranch) loadGlob() {
|
|
if o.isPlainName || o.globRule != nil {
|
|
return
|
|
}
|
|
if !IsRuleNameSpecial(o.RuleName) {
|
|
o.isPlainName = true
|
|
return
|
|
}
|
|
var err error
|
|
o.globRule, err = glob.Compile(o.RuleName, '/')
|
|
if err != nil {
|
|
log.Warn("Invalid glob rule for OrgProtectedBranch[%d]: %s %v", o.ID, o.RuleName, err)
|
|
o.globRule = glob.MustCompile(glob.QuoteMeta(o.RuleName), '/')
|
|
}
|
|
}
|
|
|
|
// Match tests if branchName matches this org rule
|
|
func (o *OrgProtectedBranch) Match(branchName string) bool {
|
|
o.loadGlob()
|
|
if o.isPlainName {
|
|
return strings.EqualFold(o.RuleName, branchName)
|
|
}
|
|
return o.globRule.Match(branchName)
|
|
}
|
|
|
|
// ToProtectedBranch converts an org-level rule to a ProtectedBranch for use
|
|
// in the standard enforcement path. Fields that are user-scoped (WhitelistUserIDs etc.)
|
|
// are left empty because org rules only reference teams.
|
|
func (o *OrgProtectedBranch) ToProtectedBranch() *ProtectedBranch {
|
|
return &ProtectedBranch{
|
|
ID: o.ID,
|
|
RuleName: o.RuleName,
|
|
Priority: o.Priority,
|
|
CanPush: o.CanPush,
|
|
EnableWhitelist: o.EnableWhitelist,
|
|
WhitelistTeamIDs: o.WhitelistTeamIDs,
|
|
EnableMergeWhitelist: o.EnableMergeWhitelist,
|
|
MergeWhitelistTeamIDs: o.MergeWhitelistTeamIDs,
|
|
CanForcePush: o.CanForcePush,
|
|
EnableForcePushAllowlist: o.EnableForcePushAllowlist,
|
|
ForcePushAllowlistTeamIDs: o.ForcePushAllowlistTeamIDs,
|
|
EnableStatusCheck: o.EnableStatusCheck,
|
|
StatusCheckContexts: o.StatusCheckContexts,
|
|
RequiredApprovals: o.RequiredApprovals,
|
|
EnableApprovalsWhitelist: o.EnableApprovalsWhitelist,
|
|
ApprovalsWhitelistTeamIDs: o.ApprovalsWhitelistTeamIDs,
|
|
BlockOnRejectedReviews: o.BlockOnRejectedReviews,
|
|
BlockOnOfficialReviewRequests: o.BlockOnOfficialReviewRequests,
|
|
BlockOnOutdatedBranch: o.BlockOnOutdatedBranch,
|
|
DismissStaleApprovals: o.DismissStaleApprovals,
|
|
IgnoreStaleApprovals: o.IgnoreStaleApprovals,
|
|
RequireSignedCommits: o.RequireSignedCommits,
|
|
ProtectedFilePatterns: o.ProtectedFilePatterns,
|
|
UnprotectedFilePatterns: o.UnprotectedFilePatterns,
|
|
BlockAdminMergeOverride: o.BlockAdminMergeOverride,
|
|
CreatedUnix: o.CreatedUnix,
|
|
UpdatedUnix: o.UpdatedUnix,
|
|
}
|
|
}
|
|
|
|
// GetOrgProtectedBranchByName retrieves a single org rule by org ID and rule name
|
|
func GetOrgProtectedBranchByName(ctx context.Context, orgID int64, ruleName string) (*OrgProtectedBranch, error) {
|
|
rule, exist, err := db.Get[OrgProtectedBranch](ctx, builder.Eq{"org_id": orgID, "rule_name": ruleName})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !exist {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
// GetOrgProtectedBranchByID retrieves a single org rule by ID
|
|
func GetOrgProtectedBranchByID(ctx context.Context, orgID, id int64) (*OrgProtectedBranch, error) {
|
|
rule, exist, err := db.Get[OrgProtectedBranch](ctx, builder.Eq{"org_id": orgID, "id": id})
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !exist {
|
|
return nil, nil //nolint:nilnil
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
// FindOrgProtectedBranchRules loads all org-level branch protection rules
|
|
func FindOrgProtectedBranchRules(ctx context.Context, orgID int64) ([]*OrgProtectedBranch, error) {
|
|
var rules []*OrgProtectedBranch
|
|
err := db.GetEngine(ctx).Where("org_id = ?", orgID).Asc("priority", "created_unix").Find(&rules)
|
|
return rules, err
|
|
}
|
|
|
|
// CreateOrgProtectedBranch creates a new org-level branch protection rule
|
|
func CreateOrgProtectedBranch(ctx context.Context, rule *OrgProtectedBranch) error {
|
|
if rule.Priority == 0 {
|
|
var maxPrio int64
|
|
if _, err := db.GetEngine(ctx).SQL(`SELECT MAX(priority) FROM org_protected_branch WHERE org_id = ?`, rule.OrgID).
|
|
Get(&maxPrio); err != nil {
|
|
return err
|
|
}
|
|
rule.Priority = maxPrio + 1
|
|
}
|
|
_, err := db.GetEngine(ctx).Insert(rule)
|
|
if err != nil {
|
|
return fmt.Errorf("Insert OrgProtectedBranch: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateOrgProtectedBranch updates an existing org-level branch protection rule
|
|
func UpdateOrgProtectedBranch(ctx context.Context, rule *OrgProtectedBranch) error {
|
|
_, err := db.GetEngine(ctx).ID(rule.ID).AllCols().Update(rule)
|
|
if err != nil {
|
|
return fmt.Errorf("Update OrgProtectedBranch: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteOrgProtectedBranch deletes an org-level branch protection rule
|
|
func DeleteOrgProtectedBranch(ctx context.Context, orgID, id int64) error {
|
|
affected, err := db.GetEngine(ctx).Where("org_id = ? AND id = ?", orgID, id).Delete(new(OrgProtectedBranch))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return fmt.Errorf("org branch protection rule ID(%d) not found", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindOrgBranchRuleForBranch finds the first matching org rule for a given branch name
|
|
func FindOrgBranchRuleForBranch(ctx context.Context, orgID int64, branchName string) (*OrgProtectedBranch, error) {
|
|
rules, err := FindOrgProtectedBranchRules(ctx, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rule := range rules {
|
|
if rule.Match(branchName) {
|
|
return rule, nil
|
|
}
|
|
}
|
|
return nil, nil //nolint:nilnil
|
|
}
|