diff --git a/CHANGELOG.md b/CHANGELOG.md index 277a454cec..5a65be7877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/models/git/org_repo_defaults.go b/models/git/org_repo_defaults.go new file mode 100644 index 0000000000..7a4d831ce0 --- /dev/null +++ b/models/git/org_repo_defaults.go @@ -0,0 +1,88 @@ +// Copyright 2026 Moko Consulting +// 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 +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 2966c3fb2a..711923b24a 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v366.go b/models/migrations/v1_27/v366.go new file mode 100644 index 0000000000..8ae8a2861a --- /dev/null +++ b/models/migrations/v1_27/v366.go @@ -0,0 +1,31 @@ +// Copyright 2026 Moko Consulting +// 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)) +} diff --git a/modules/structs/org_repo_defaults.go b/modules/structs/org_repo_defaults.go new file mode 100644 index 0000000000..5839e9433c --- /dev/null +++ b/modules/structs/org_repo_defaults.go @@ -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"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f8f720e432..40b5441447 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/org/repo_defaults.go b/routers/api/v1/org/repo_defaults.go new file mode 100644 index 0000000000..74019f37c7 --- /dev/null +++ b/routers/api/v1/org/repo_defaults.go @@ -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) +} diff --git a/routers/init.go b/routers/init.go index 0b6c0e5916..cda1e5f05b 100644 --- a/routers/init.go +++ b/routers/init.go @@ -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() diff --git a/services/org/notifier.go b/services/org/notifier.go new file mode 100644 index 0000000000..b4d63faf52 --- /dev/null +++ b/services/org/notifier.go @@ -0,0 +1,92 @@ +// Copyright 2026 Moko Consulting +// 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) + } + } +}