diff --git a/models/git/org_protected_branch.go b/models/git/org_protected_branch.go new file mode 100644 index 0000000000..1d20db2912 --- /dev/null +++ b/models/git/org_protected_branch.go @@ -0,0 +1,197 @@ +// 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 +} diff --git a/models/git/protected_branch_list.go b/models/git/protected_branch_list.go index 6b282835a4..d496c4e546 100644 --- a/models/git/protected_branch_list.go +++ b/models/git/protected_branch_list.go @@ -8,7 +8,10 @@ import ( "sort" "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/glob" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" ) @@ -82,13 +85,44 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) return results, nil } -// GetFirstMatchProtectedBranchRule returns the first matched rules +// GetFirstMatchProtectedBranchRule returns the first matched rule. +// It checks repo-level rules first; if none match, it falls back to org-level rules +// (if the repo belongs to an organization). func GetFirstMatchProtectedBranchRule(ctx context.Context, repoID int64, branchName string) (*ProtectedBranch, error) { rules, err := FindRepoProtectedBranchRules(ctx, repoID) if err != nil { return nil, err } - return rules.GetFirstMatched(branchName), nil + if matched := rules.GetFirstMatched(branchName); matched != nil { + return matched, nil + } + + // Fall back to org-level rules + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return nil, err + } + owner, err := user_model.GetUserByID(ctx, repo.OwnerID) + if err != nil { + return nil, err + } + if !owner.IsOrganization() { + return nil, nil + } + + orgRule, err := FindOrgBranchRuleForBranch(ctx, owner.ID, branchName) + if err != nil { + log.Error("FindOrgBranchRuleForBranch: %v", err) + return nil, nil + } + if orgRule == nil { + return nil, nil + } + + // Convert org rule to a ProtectedBranch with RepoID set so callers work correctly + pb := orgRule.ToProtectedBranch() + pb.RepoID = repoID + return pb, nil } // IsBranchProtected checks if branch is protected diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..ca3010a480 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration { // Gitea 1.26.0 ends at migration ID number 330 (database version 331) newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), + newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..a1fbbe0fa7 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -0,0 +1,50 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type orgProtectedBranch struct { + ID int64 `xorm:"pk autoincr"` + OrgID int64 `xorm:"UNIQUE(s) index"` + RuleName string `xorm:"UNIQUE(s)"` + Priority int64 `xorm:"NOT NULL DEFAULT 0"` + 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 (orgProtectedBranch) TableName() string { + return "org_protected_branch" +} + +// AddOrgProtectedBranchTable creates the org_protected_branch table. +func AddOrgProtectedBranchTable(x *xorm.Engine) error { + return x.Sync(new(orgProtectedBranch)) +} diff --git a/modules/setting/global.go b/modules/setting/global.go index 55dfe485b2..f50288435b 100644 --- a/modules/setting/global.go +++ b/modules/setting/global.go @@ -15,4 +15,9 @@ var ( // AppName is the Application name, used in the page title. ini: "APP_NAME" AppName string + + // HelpURL is the URL shown in the help menu. ini: "HELP_URL" + HelpURL string + // SupportURL is the URL for support links. ini: "SUPPORT_URL" + SupportURL string ) diff --git a/modules/setting/server.go b/modules/setting/server.go index dc58e43c43..fedc8d872f 100644 --- a/modules/setting/server.go +++ b/modules/setting/server.go @@ -116,6 +116,8 @@ var ( func loadServerFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("server") AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") + HelpURL = rootCfg.Section("").Key("HELP_URL").MustString("https://docs.gitea.com") + SupportURL = rootCfg.Section("").Key("SUPPORT_URL").MustString("") Domain = sec.Key("DOMAIN").MustString("localhost") HTTPAddr = sec.Key("HTTP_ADDR").MustString("0.0.0.0") diff --git a/modules/structs/org_branch.go b/modules/structs/org_branch.go new file mode 100644 index 0000000000..7333a4192d --- /dev/null +++ b/modules/structs/org_branch.go @@ -0,0 +1,95 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import "time" + +// OrgBranchProtection represents an org-level branch protection ruleset +type OrgBranchProtection struct { + ID int64 `json:"id"` + OrgID int64 `json:"org_id"` + RuleName string `json:"rule_name"` + Priority int64 `json:"priority"` + EnablePush bool `json:"enable_push"` + EnablePushWhitelist bool `json:"enable_push_whitelist"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + EnableForcePush bool `json:"enable_force_push"` + EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"` + ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals int64 `json:"required_approvals"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals"` + IgnoreStaleApprovals bool `json:"ignore_stale_approvals"` + RequireSignedCommits bool `json:"require_signed_commits"` + ProtectedFilePatterns string `json:"protected_file_patterns"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns"` + BlockAdminMergeOverride bool `json:"block_admin_merge_override"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` +} + +// CreateOrgBranchProtectionOption options for creating an org-level branch protection +type CreateOrgBranchProtectionOption struct { + RuleName string `json:"rule_name" binding:"Required"` + Priority int64 `json:"priority"` + EnablePush bool `json:"enable_push"` + EnablePushWhitelist bool `json:"enable_push_whitelist"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + EnableForcePush bool `json:"enable_force_push"` + EnableForcePushAllowlist bool `json:"enable_force_push_allowlist"` + ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableMergeWhitelist bool `json:"enable_merge_whitelist"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals int64 `json:"required_approvals"` + EnableApprovalsWhitelist bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch bool `json:"block_on_outdated_branch"` + DismissStaleApprovals bool `json:"dismiss_stale_approvals"` + IgnoreStaleApprovals bool `json:"ignore_stale_approvals"` + RequireSignedCommits bool `json:"require_signed_commits"` + ProtectedFilePatterns string `json:"protected_file_patterns"` + UnprotectedFilePatterns string `json:"unprotected_file_patterns"` + BlockAdminMergeOverride bool `json:"block_admin_merge_override"` +} + +// EditOrgBranchProtectionOption options for editing an org-level branch protection +type EditOrgBranchProtectionOption struct { + Priority *int64 `json:"priority"` + EnablePush *bool `json:"enable_push"` + EnablePushWhitelist *bool `json:"enable_push_whitelist"` + PushWhitelistTeams []string `json:"push_whitelist_teams"` + EnableForcePush *bool `json:"enable_force_push"` + EnableForcePushAllowlist *bool `json:"enable_force_push_allowlist"` + ForcePushAllowlistTeams []string `json:"force_push_allowlist_teams"` + EnableMergeWhitelist *bool `json:"enable_merge_whitelist"` + MergeWhitelistTeams []string `json:"merge_whitelist_teams"` + EnableStatusCheck *bool `json:"enable_status_check"` + StatusCheckContexts []string `json:"status_check_contexts"` + RequiredApprovals *int64 `json:"required_approvals"` + EnableApprovalsWhitelist *bool `json:"enable_approvals_whitelist"` + ApprovalsWhitelistTeams []string `json:"approvals_whitelist_teams"` + BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"` + BlockOnOfficialReviewRequests *bool `json:"block_on_official_review_requests"` + BlockOnOutdatedBranch *bool `json:"block_on_outdated_branch"` + DismissStaleApprovals *bool `json:"dismiss_stale_approvals"` + IgnoreStaleApprovals *bool `json:"ignore_stale_approvals"` + RequireSignedCommits *bool `json:"require_signed_commits"` + ProtectedFilePatterns *string `json:"protected_file_patterns"` + UnprotectedFilePatterns *string `json:"unprotected_file_patterns"` + BlockAdminMergeOverride *bool `json:"block_admin_merge_override"` +} diff --git a/modules/structs/repo_branch.go b/modules/structs/repo_branch.go index 75f7878aa6..dee9490998 100644 --- a/modules/structs/repo_branch.go +++ b/modules/structs/repo_branch.go @@ -65,6 +65,8 @@ type BranchProtection struct { ProtectedFilePatterns string `json:"protected_file_patterns"` UnprotectedFilePatterns string `json:"unprotected_file_patterns"` BlockAdminMergeOverride bool `json:"block_admin_merge_override"` + // InheritedFrom indicates where this rule originates ("org" if inherited from org-level rules, empty if repo-level) + InheritedFrom string `json:"inherited_from,omitempty"` // swagger:strfmt date-time Created time.Time `json:"created_at"` // swagger:strfmt date-time diff --git a/modules/templates/helper.go b/modules/templates/helper.go index aebbfc7407..365f77bf05 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -83,6 +83,12 @@ func newFuncMapWebPage() template.FuncMap { "AppVer": func() string { return setting.AppVer }, + "HelpURL": func() string { + return setting.HelpURL + }, + "SupportURL": func() string { + return setting.SupportURL + }, "AppDomain": func() string { // TODO: helm registry still uses it, need to use current request host in the future return setting.Domain }, diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..b44579bf8c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3243,6 +3243,9 @@ "admin.config.lfs_root_path": "LFS Root Path", "admin.config.log_file_root_path": "Log Path", "admin.config.script_type": "Script Type", + "admin.config.help_url": "Help URL", + "admin.config.support_url": "Support URL", + "admin.config.not_set": "Not set", "admin.config.reverse_auth_user": "Reverse Authentication User", "admin.config.ssh_config": "SSH Configuration", "admin.config.ssh_enabled": "Enabled", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index de177d1ad8..66878c55fb 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1696,6 +1696,15 @@ func Routes() *web.Router { m.Delete("", org.DeleteAvatar) }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) + m.Group("/branch_protections", func() { + m.Combo("").Get(org.ListOrgBranchProtections). + Post(bind(api.CreateOrgBranchProtectionOption{}), org.CreateOrgBranchProtection) + m.Group("/*", func() { + m.Get("", org.GetOrgBranchProtection) + m.Patch("", bind(api.EditOrgBranchProtectionOption{}), org.EditOrgBranchProtection) + m.Delete("", org.DeleteOrgBranchProtection) + }) + }, reqToken(), reqOrgOwnership()) m.Group("/blocks", func() { m.Get("", org.ListBlocks) diff --git a/routers/api/v1/org/branch_protection.go b/routers/api/v1/org/branch_protection.go new file mode 100644 index 0000000000..36e7a75d0b --- /dev/null +++ b/routers/api/v1/org/branch_protection.go @@ -0,0 +1,432 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// toAPIOrgBranchProtection converts model to API response +func toAPIOrgBranchProtection(ctx *context.APIContext, rule *git_model.OrgProtectedBranch) *api.OrgBranchProtection { + orgID := rule.OrgID + teams, err := organization.FindOrgTeams(ctx, orgID) + if err != nil { + teams = nil + } + + teamNamesByID := make(map[int64]string, len(teams)) + for _, t := range teams { + teamNamesByID[t.ID] = t.Name + } + + resolveTeamNames := func(ids []int64) []string { + names := make([]string, 0, len(ids)) + for _, id := range ids { + if name, ok := teamNamesByID[id]; ok { + names = append(names, name) + } + } + return names + } + + return &api.OrgBranchProtection{ + ID: rule.ID, + OrgID: rule.OrgID, + RuleName: rule.RuleName, + Priority: rule.Priority, + EnablePush: rule.CanPush, + EnablePushWhitelist: rule.EnableWhitelist, + PushWhitelistTeams: resolveTeamNames(rule.WhitelistTeamIDs), + EnableForcePush: rule.CanForcePush, + EnableForcePushAllowlist: rule.EnableForcePushAllowlist, + ForcePushAllowlistTeams: resolveTeamNames(rule.ForcePushAllowlistTeamIDs), + EnableMergeWhitelist: rule.EnableMergeWhitelist, + MergeWhitelistTeams: resolveTeamNames(rule.MergeWhitelistTeamIDs), + EnableStatusCheck: rule.EnableStatusCheck, + StatusCheckContexts: rule.StatusCheckContexts, + RequiredApprovals: rule.RequiredApprovals, + EnableApprovalsWhitelist: rule.EnableApprovalsWhitelist, + ApprovalsWhitelistTeams: resolveTeamNames(rule.ApprovalsWhitelistTeamIDs), + BlockOnRejectedReviews: rule.BlockOnRejectedReviews, + BlockOnOfficialReviewRequests: rule.BlockOnOfficialReviewRequests, + BlockOnOutdatedBranch: rule.BlockOnOutdatedBranch, + DismissStaleApprovals: rule.DismissStaleApprovals, + IgnoreStaleApprovals: rule.IgnoreStaleApprovals, + RequireSignedCommits: rule.RequireSignedCommits, + ProtectedFilePatterns: rule.ProtectedFilePatterns, + UnprotectedFilePatterns: rule.UnprotectedFilePatterns, + BlockAdminMergeOverride: rule.BlockAdminMergeOverride, + Created: rule.CreatedUnix.AsTime(), + Updated: rule.UpdatedUnix.AsTime(), + } +} + +// resolveTeamIDs converts team names to IDs for the given org, returning 422 on unknown team +func resolveTeamIDs(ctx *context.APIContext, orgID int64, names []string) ([]int64, bool) { + if len(names) == 0 { + return nil, true + } + ids, err := organization.GetTeamIDsByNames(ctx, orgID, names, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.APIError(http.StatusUnprocessableEntity, err) + } else { + ctx.APIErrorInternal(err) + } + return nil, false + } + return ids, true +} + +// ListOrgBranchProtections list org-level branch protection rules +func ListOrgBranchProtections(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/branch_protections organization orgListBranchProtections + // --- + // summary: List an organization's branch protection rules + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/OrgBranchProtectionList" + // "404": + // "$ref": "#/responses/notFound" + + rules, err := git_model.FindOrgProtectedBranchRules(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiRules := make([]*api.OrgBranchProtection, len(rules)) + for i, rule := range rules { + apiRules[i] = toAPIOrgBranchProtection(ctx, rule) + } + ctx.JSON(http.StatusOK, apiRules) +} + +// GetOrgBranchProtection get a specific org-level branch protection rule +func GetOrgBranchProtection(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/branch_protections/{name} organization orgGetBranchProtection + // --- + // summary: Get a specific org-level branch protection rule + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: name + // in: path + // description: name of the branch protection rule + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/OrgBranchProtection" + // "404": + // "$ref": "#/responses/notFound" + + ruleName := ctx.PathParam("*") + rule, err := git_model.GetOrgProtectedBranchByName(ctx, ctx.Org.Organization.ID, ruleName) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toAPIOrgBranchProtection(ctx, rule)) +} + +// CreateOrgBranchProtection create an org-level branch protection rule +func CreateOrgBranchProtection(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/branch_protections organization orgCreateBranchProtection + // --- + // summary: Create an org-level branch protection rule + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrgBranchProtectionOption" + // responses: + // "201": + // "$ref": "#/responses/OrgBranchProtection" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateOrgBranchProtectionOption) + orgID := ctx.Org.Organization.ID + + existing, err := git_model.GetOrgProtectedBranchByName(ctx, orgID, form.RuleName) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if existing != nil { + ctx.APIError(http.StatusForbidden, "org branch protection rule already exists for this pattern") + return + } + + pushTeams, ok := resolveTeamIDs(ctx, orgID, form.PushWhitelistTeams) + if !ok { + return + } + forcePushTeams, ok := resolveTeamIDs(ctx, orgID, form.ForcePushAllowlistTeams) + if !ok { + return + } + mergeTeams, ok := resolveTeamIDs(ctx, orgID, form.MergeWhitelistTeams) + if !ok { + return + } + approvalsTeams, ok := resolveTeamIDs(ctx, orgID, form.ApprovalsWhitelistTeams) + if !ok { + return + } + + rule := &git_model.OrgProtectedBranch{ + OrgID: orgID, + RuleName: form.RuleName, + Priority: form.Priority, + CanPush: form.EnablePush, + EnableWhitelist: form.EnablePush && form.EnablePushWhitelist, + WhitelistTeamIDs: pushTeams, + CanForcePush: form.EnablePush && form.EnableForcePush, + EnableForcePushAllowlist: form.EnablePush && form.EnableForcePush && form.EnableForcePushAllowlist, + ForcePushAllowlistTeamIDs: forcePushTeams, + EnableMergeWhitelist: form.EnableMergeWhitelist, + MergeWhitelistTeamIDs: mergeTeams, + EnableStatusCheck: form.EnableStatusCheck, + StatusCheckContexts: form.StatusCheckContexts, + RequiredApprovals: form.RequiredApprovals, + EnableApprovalsWhitelist: form.EnableApprovalsWhitelist, + ApprovalsWhitelistTeamIDs: approvalsTeams, + BlockOnRejectedReviews: form.BlockOnRejectedReviews, + BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests, + BlockOnOutdatedBranch: form.BlockOnOutdatedBranch, + DismissStaleApprovals: form.DismissStaleApprovals, + IgnoreStaleApprovals: form.IgnoreStaleApprovals, + RequireSignedCommits: form.RequireSignedCommits, + ProtectedFilePatterns: form.ProtectedFilePatterns, + UnprotectedFilePatterns: form.UnprotectedFilePatterns, + BlockAdminMergeOverride: form.BlockAdminMergeOverride, + } + + if err := git_model.CreateOrgProtectedBranch(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, toAPIOrgBranchProtection(ctx, rule)) +} + +// EditOrgBranchProtection edit an org-level branch protection rule +func EditOrgBranchProtection(ctx *context.APIContext) { + // swagger:operation PATCH /orgs/{org}/branch_protections/{name} organization orgEditBranchProtection + // --- + // summary: Edit an org-level branch protection rule. Only fields that are set will be changed + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: name + // in: path + // description: name of the branch protection rule + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditOrgBranchProtectionOption" + // responses: + // "200": + // "$ref": "#/responses/OrgBranchProtection" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.EditOrgBranchProtectionOption) + orgID := ctx.Org.Organization.ID + ruleName := ctx.PathParam("*") + + rule, err := git_model.GetOrgProtectedBranchByName(ctx, orgID, ruleName) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + + if form.Priority != nil { + rule.Priority = *form.Priority + } + if form.EnablePush != nil { + rule.CanPush = *form.EnablePush + } + if form.EnablePushWhitelist != nil { + rule.EnableWhitelist = *form.EnablePushWhitelist + } + if form.PushWhitelistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.PushWhitelistTeams) + if !ok { + return + } + rule.WhitelistTeamIDs = ids + } + if form.EnableForcePush != nil { + rule.CanForcePush = *form.EnableForcePush + } + if form.EnableForcePushAllowlist != nil { + rule.EnableForcePushAllowlist = *form.EnableForcePushAllowlist + } + if form.ForcePushAllowlistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.ForcePushAllowlistTeams) + if !ok { + return + } + rule.ForcePushAllowlistTeamIDs = ids + } + if form.EnableMergeWhitelist != nil { + rule.EnableMergeWhitelist = *form.EnableMergeWhitelist + } + if form.MergeWhitelistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.MergeWhitelistTeams) + if !ok { + return + } + rule.MergeWhitelistTeamIDs = ids + } + if form.EnableStatusCheck != nil { + rule.EnableStatusCheck = *form.EnableStatusCheck + } + if form.StatusCheckContexts != nil { + rule.StatusCheckContexts = form.StatusCheckContexts + } + if form.RequiredApprovals != nil { + rule.RequiredApprovals = *form.RequiredApprovals + } + if form.EnableApprovalsWhitelist != nil { + rule.EnableApprovalsWhitelist = *form.EnableApprovalsWhitelist + } + if form.ApprovalsWhitelistTeams != nil { + ids, ok := resolveTeamIDs(ctx, orgID, form.ApprovalsWhitelistTeams) + if !ok { + return + } + rule.ApprovalsWhitelistTeamIDs = ids + } + if form.BlockOnRejectedReviews != nil { + rule.BlockOnRejectedReviews = *form.BlockOnRejectedReviews + } + if form.BlockOnOfficialReviewRequests != nil { + rule.BlockOnOfficialReviewRequests = *form.BlockOnOfficialReviewRequests + } + if form.BlockOnOutdatedBranch != nil { + rule.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch + } + if form.DismissStaleApprovals != nil { + rule.DismissStaleApprovals = *form.DismissStaleApprovals + } + if form.IgnoreStaleApprovals != nil { + rule.IgnoreStaleApprovals = *form.IgnoreStaleApprovals + } + if form.RequireSignedCommits != nil { + rule.RequireSignedCommits = *form.RequireSignedCommits + } + if form.ProtectedFilePatterns != nil { + rule.ProtectedFilePatterns = *form.ProtectedFilePatterns + } + if form.UnprotectedFilePatterns != nil { + rule.UnprotectedFilePatterns = *form.UnprotectedFilePatterns + } + if form.BlockAdminMergeOverride != nil { + rule.BlockAdminMergeOverride = *form.BlockAdminMergeOverride + } + + if err := git_model.UpdateOrgProtectedBranch(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toAPIOrgBranchProtection(ctx, rule)) +} + +// DeleteOrgBranchProtection delete an org-level branch protection rule +func DeleteOrgBranchProtection(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/branch_protections/{name} organization orgDeleteBranchProtection + // --- + // summary: Delete an org-level branch protection rule + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: name + // in: path + // description: name of the branch protection rule + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + orgID := ctx.Org.Organization.ID + ruleName := ctx.PathParam("*") + + rule, err := git_model.GetOrgProtectedBranchByName(ctx, orgID, ruleName) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil { + ctx.APIErrorNotFound() + return + } + + if err := git_model.DeleteOrgProtectedBranch(ctx, orgID, rule.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go index 03a15b6713..d2cbdd443f 100644 --- a/routers/web/admin/config.go +++ b/routers/web/admin/config.go @@ -114,6 +114,8 @@ func Config(ctx *context.Context) { ctx.Data["Git"] = setting.Git ctx.Data["AccessLogTemplate"] = setting.Log.AccessLogTemplate ctx.Data["LogSQL"] = setting.Database.LogSQL + ctx.Data["HelpURL"] = setting.HelpURL + ctx.Data["SupportURL"] = setting.SupportURL ctx.Data["Loggers"] = log.GetManager().DumpLoggers() config.GetDynGetter().InvalidateCache() diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl index ae37950fb5..05dc3b8d1e 100644 --- a/templates/admin/config.tmpl +++ b/templates/admin/config.tmpl @@ -38,6 +38,16 @@