feat(org): org-level repository defaults applied on repo create/transfer (#727) #731
@@ -7,6 +7,7 @@
|
||||
- Org branch protection: org-level rules can now also protect against branch deletion (`enable_delete` + delete allowlist teams), mirroring the per-repo delete allowlist (#727)
|
||||
- Org-level tag protection: protect tag patterns org-wide (e.g. `v*`) with a team allowlist, layered on top of each repo's own protected tags — a tag is controllable only if allowed at both levels (fail-closed). API at `/orgs/{org}/tag_protections`; enforced at the git push/delete hook and the release create/delete paths; shown read-only in the repo Tag settings (#727)
|
||||
- Org-level push policy: one policy per org, enforced in the pre-receive hook across all its repositories — branch/tag name conventions (glob), a mandatory secret-scanning block-on-push that repos cannot disable, a max pushed-file size, and blocked file-path patterns. API at `/orgs/{org}/push_policy`. Naming is fail-closed; the content checks (blocked paths, max size) fail open on error so a policy bug can never block every push (#727)
|
||||
- Org-level repository defaults: an org can force new/transferred repositories private and set default pull-request settings (allowed merge styles, default merge style, auto-delete branch after merge), applied via a notifier when a repo is created in or transferred into the org (best-effort — never blocks repo creation). API at `/orgs/{org}/repo_defaults` (#727)
|
||||
- Code security scanner: pattern-based detection of SQL injection, XSS, command injection, path traversal, insecure deserialization, hardcoded credentials, and weak cryptography across Go/PHP/Python/JS/TS (#552)
|
||||
- Cascade merge: auto-create PRs to downstream branches after merge with configurable rules per repo (#460)
|
||||
- Issue status presets: 4 built-in templates (default, software-development, support-tickets, bug-tracking) with API + web UI (#507)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// OrgRepoDefaults holds an organization's default repository settings, applied to a
|
||||
// repository when it is created in or transferred into the org (via a notifier).
|
||||
// There is at most one row per org. See issue #727.
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(OrgRepoDefaults))
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaults returns the org's repo defaults, or nil if none are configured.
|
||||
func GetOrgRepoDefaults(ctx context.Context, orgID int64) (*OrgRepoDefaults, error) {
|
||||
defaults, exist, err := db.Get[OrgRepoDefaults](ctx, builder.Eq{"org_id": orgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !exist {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return defaults, nil
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaultsForRepo returns the repo-defaults of the repo's owning org, or
|
||||
// nil if the owner is not an organization or has none configured.
|
||||
func GetOrgRepoDefaultsForRepo(ctx context.Context, repo *repo_model.Repository) (*OrgRepoDefaults, error) {
|
||||
owner, err := user_model.GetUserByID(ctx, repo.OwnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !owner.IsOrganization() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return GetOrgRepoDefaults(ctx, owner.ID)
|
||||
}
|
||||
|
||||
// UpsertOrgRepoDefaults creates or updates the single repo-defaults row for an org.
|
||||
func UpsertOrgRepoDefaults(ctx context.Context, defaults *OrgRepoDefaults) error {
|
||||
existing, err := GetOrgRepoDefaults(ctx, defaults.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
if _, err := db.GetEngine(ctx).Insert(defaults); err != nil {
|
||||
return fmt.Errorf("Insert OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defaults.ID = existing.ID
|
||||
if _, err := db.GetEngine(ctx).ID(existing.ID).AllCols().Update(defaults); err != nil {
|
||||
return fmt.Errorf("Update OrgRepoDefaults: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrgRepoDefaults removes an org's repo defaults.
|
||||
func DeleteOrgRepoDefaults(ctx context.Context, orgID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("org_id = ?", orgID).Delete(new(OrgRepoDefaults))
|
||||
return err
|
||||
}
|
||||
@@ -442,6 +442,7 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(362, "Add delete allowlist to org protected branch", v1_27.AddDeleteAllowlistToOrgProtectedBranch),
|
||||
newMigration(363, "Add org protected tag table", v1_27.AddOrgProtectedTagTable),
|
||||
newMigration(364, "Add org push policy table", v1_27.AddOrgPushPolicyTable),
|
||||
newMigration(365, "Add org repo defaults table", v1_27.AddOrgRepoDefaultsTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddOrgRepoDefaultsTable creates the org repository-defaults table (one row per
|
||||
// org), applied to repositories created in or transferred into the org. See #727.
|
||||
func AddOrgRepoDefaultsTable(x *xorm.Engine) error {
|
||||
type OrgRepoDefaults struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"UNIQUE NOT NULL"`
|
||||
ForcePrivate bool `xorm:"NOT NULL DEFAULT false"`
|
||||
ApplyPRDefaults bool `xorm:"NOT NULL DEFAULT false"`
|
||||
AllowMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebase bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowRebaseMerge bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowSquash bool `xorm:"NOT NULL DEFAULT true"`
|
||||
AllowFastForwardOnly bool `xorm:"NOT NULL DEFAULT true"`
|
||||
DefaultMergeStyle string `xorm:"TEXT"`
|
||||
DeleteBranchAfterMerge bool `xorm:"NOT NULL DEFAULT false"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
return x.Sync(new(OrgRepoDefaults))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package structs
|
||||
|
||||
// OrgRepoDefaults represents an organization's default repository settings
|
||||
type OrgRepoDefaults struct {
|
||||
OrgID int64 `json:"org_id"`
|
||||
ForcePrivate bool `json:"force_private"`
|
||||
ApplyPRDefaults bool `json:"apply_pr_defaults"`
|
||||
AllowMerge bool `json:"allow_merge"`
|
||||
AllowRebase bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge bool `json:"allow_rebase_merge"`
|
||||
AllowSquash bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
|
||||
// EditOrgRepoDefaultsOption options for editing an org's repo defaults. Only fields
|
||||
// that are set will be changed.
|
||||
type EditOrgRepoDefaultsOption struct {
|
||||
ForcePrivate *bool `json:"force_private"`
|
||||
ApplyPRDefaults *bool `json:"apply_pr_defaults"`
|
||||
AllowMerge *bool `json:"allow_merge"`
|
||||
AllowRebase *bool `json:"allow_rebase"`
|
||||
AllowRebaseMerge *bool `json:"allow_rebase_merge"`
|
||||
AllowSquash *bool `json:"allow_squash"`
|
||||
AllowFastForwardOnly *bool `json:"allow_fast_forward_only"`
|
||||
DefaultMergeStyle *string `json:"default_merge_style"`
|
||||
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge"`
|
||||
}
|
||||
@@ -1839,6 +1839,12 @@ func Routes() *web.Router {
|
||||
Delete(org.DeleteOrgPushPolicy)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/repo_defaults", func() {
|
||||
m.Combo("").Get(org.GetOrgRepoDefaults).
|
||||
Patch(bind(api.EditOrgRepoDefaultsOption{}), org.EditOrgRepoDefaults).
|
||||
Delete(org.DeleteOrgRepoDefaults)
|
||||
}, reqToken(), reqOrgOwnership())
|
||||
|
||||
m.Group("/blocks", func() {
|
||||
m.Get("", org.ListBlocks)
|
||||
m.Group("/{username}", func() {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
|
||||
)
|
||||
|
||||
// toAPIOrgRepoDefaults converts the model to its API representation. A nil value is
|
||||
// rendered as the effective defaults (all merge styles allowed) so clients always
|
||||
// get a consistent shape.
|
||||
func toAPIOrgRepoDefaults(d *git_model.OrgRepoDefaults, orgID int64) *api.OrgRepoDefaults {
|
||||
if d == nil {
|
||||
return &api.OrgRepoDefaults{
|
||||
OrgID: orgID,
|
||||
AllowMerge: true,
|
||||
AllowRebase: true,
|
||||
AllowRebaseMerge: true,
|
||||
AllowSquash: true,
|
||||
AllowFastForwardOnly: true,
|
||||
}
|
||||
}
|
||||
return &api.OrgRepoDefaults{
|
||||
OrgID: d.OrgID,
|
||||
ForcePrivate: d.ForcePrivate,
|
||||
ApplyPRDefaults: d.ApplyPRDefaults,
|
||||
AllowMerge: d.AllowMerge,
|
||||
AllowRebase: d.AllowRebase,
|
||||
AllowRebaseMerge: d.AllowRebaseMerge,
|
||||
AllowSquash: d.AllowSquash,
|
||||
AllowFastForwardOnly: d.AllowFastForwardOnly,
|
||||
DefaultMergeStyle: d.DefaultMergeStyle,
|
||||
DeleteBranchAfterMerge: d.DeleteBranchAfterMerge,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrgRepoDefaults get the organization's default repository settings
|
||||
func GetOrgRepoDefaults(ctx *context.APIContext) {
|
||||
orgID := ctx.Org.Organization.ID
|
||||
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||
}
|
||||
|
||||
// EditOrgRepoDefaults create or update the organization's default repository settings
|
||||
func EditOrgRepoDefaults(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.EditOrgRepoDefaultsOption)
|
||||
orgID := ctx.Org.Organization.ID
|
||||
|
||||
defaults, err := git_model.GetOrgRepoDefaults(ctx, orgID)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
if defaults == nil {
|
||||
defaults = &git_model.OrgRepoDefaults{
|
||||
OrgID: orgID,
|
||||
AllowMerge: true,
|
||||
AllowRebase: true,
|
||||
AllowRebaseMerge: true,
|
||||
AllowSquash: true,
|
||||
AllowFastForwardOnly: true,
|
||||
}
|
||||
}
|
||||
|
||||
if form.ForcePrivate != nil {
|
||||
defaults.ForcePrivate = *form.ForcePrivate
|
||||
}
|
||||
if form.ApplyPRDefaults != nil {
|
||||
defaults.ApplyPRDefaults = *form.ApplyPRDefaults
|
||||
}
|
||||
if form.AllowMerge != nil {
|
||||
defaults.AllowMerge = *form.AllowMerge
|
||||
}
|
||||
if form.AllowRebase != nil {
|
||||
defaults.AllowRebase = *form.AllowRebase
|
||||
}
|
||||
if form.AllowRebaseMerge != nil {
|
||||
defaults.AllowRebaseMerge = *form.AllowRebaseMerge
|
||||
}
|
||||
if form.AllowSquash != nil {
|
||||
defaults.AllowSquash = *form.AllowSquash
|
||||
}
|
||||
if form.AllowFastForwardOnly != nil {
|
||||
defaults.AllowFastForwardOnly = *form.AllowFastForwardOnly
|
||||
}
|
||||
if form.DefaultMergeStyle != nil {
|
||||
defaults.DefaultMergeStyle = *form.DefaultMergeStyle
|
||||
}
|
||||
if form.DeleteBranchAfterMerge != nil {
|
||||
defaults.DeleteBranchAfterMerge = *form.DeleteBranchAfterMerge
|
||||
}
|
||||
|
||||
if err := git_model.UpsertOrgRepoDefaults(ctx, defaults); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, toAPIOrgRepoDefaults(defaults, orgID))
|
||||
}
|
||||
|
||||
// DeleteOrgRepoDefaults remove the organization's default repository settings
|
||||
func DeleteOrgRepoDefaults(ctx *context.APIContext) {
|
||||
if err := git_model.DeleteOrgRepoDefaults(ctx, ctx.Org.Organization.ID); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
repo_migrations "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/migrations"
|
||||
mirror_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mirror"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/oauth2_provider"
|
||||
org_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/org"
|
||||
packages_spec "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/packages/pkgspec"
|
||||
pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull"
|
||||
release_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/release"
|
||||
@@ -155,6 +156,7 @@ func InitWebInstalled(ctx context.Context) {
|
||||
mustInit(pull_service.Init)
|
||||
mustInit(automerge.Init)
|
||||
cascade.Init()
|
||||
org_service.Init()
|
||||
mustInit(task.Init)
|
||||
mustInit(repo_migrations.Init)
|
||||
eventsource.GetManager().Init()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
git_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
|
||||
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
|
||||
user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
|
||||
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
|
||||
notify_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/notify"
|
||||
repo_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/repository"
|
||||
)
|
||||
|
||||
// Init registers the org notifier so organization repository defaults are applied to
|
||||
// repositories as they are created in or transferred into the org. The notifier
|
||||
// pattern avoids the services/repository -> services/org import cycle.
|
||||
func Init() {
|
||||
notify_service.RegisterNotifier(&repoDefaultsNotifier{})
|
||||
}
|
||||
|
||||
type repoDefaultsNotifier struct {
|
||||
notify_service.NullNotifier
|
||||
}
|
||||
|
||||
func (n *repoDefaultsNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
|
||||
applyOrgRepoDefaults(ctx, u, repo)
|
||||
}
|
||||
|
||||
func (n *repoDefaultsNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
|
||||
applyOrgRepoDefaults(ctx, nil, repo)
|
||||
}
|
||||
|
||||
// applyOrgRepoDefaults applies the owning organization's default repository settings
|
||||
// to a repo that has just joined the org. Best-effort: errors are logged and never
|
||||
// propagated, so a defaults bug can never break repository creation or transfer.
|
||||
func applyOrgRepoDefaults(ctx context.Context, owner *user_model.User, repo *repo_model.Repository) {
|
||||
if owner == nil {
|
||||
if err := repo.LoadOwner(ctx); err != nil {
|
||||
log.Error("org repo defaults: load owner of repo %d: %v", repo.ID, err)
|
||||
return
|
||||
}
|
||||
owner = repo.Owner
|
||||
}
|
||||
if owner == nil || !owner.IsOrganization() {
|
||||
return
|
||||
}
|
||||
|
||||
defaults, err := git_model.GetOrgRepoDefaults(ctx, owner.ID)
|
||||
if err != nil {
|
||||
log.Error("org repo defaults: load for org %d: %v", owner.ID, err)
|
||||
return
|
||||
}
|
||||
if defaults == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if defaults.ForcePrivate && !repo.IsPrivate {
|
||||
repo.IsPrivate = true
|
||||
if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, repo, "is_private"); err != nil {
|
||||
log.Error("org repo defaults: force private on repo %d: %v", repo.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if defaults.ApplyPRDefaults {
|
||||
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
|
||||
if err != nil {
|
||||
// The repository may not have pull requests enabled; nothing to apply.
|
||||
return
|
||||
}
|
||||
cfg := prUnit.PullRequestsConfig()
|
||||
cfg.AllowMerge = defaults.AllowMerge
|
||||
cfg.AllowRebase = defaults.AllowRebase
|
||||
cfg.AllowRebaseMerge = defaults.AllowRebaseMerge
|
||||
cfg.AllowSquash = defaults.AllowSquash
|
||||
cfg.AllowFastForwardOnly = defaults.AllowFastForwardOnly
|
||||
cfg.DefaultDeleteBranchAfterMerge = defaults.DeleteBranchAfterMerge
|
||||
if defaults.DefaultMergeStyle != "" {
|
||||
cfg.DefaultMergeStyle = repo_model.MergeStyle(defaults.DefaultMergeStyle)
|
||||
}
|
||||
if err := repo_service.UpdateRepositoryUnits(ctx, repo, []repo_model.RepoUnit{{
|
||||
RepoID: repo.ID,
|
||||
Type: unit.TypePullRequests,
|
||||
Config: cfg,
|
||||
}}, nil); err != nil {
|
||||
log.Error("org repo defaults: update PR unit on repo %d: %v", repo.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user