diff --git a/CHANGELOG.md b/CHANGELOG.md index 09803a0167..69bca97950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- 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) - Cross-org status migration: copy status definitions from one org to another via API (#507) - Auto-create default teams on org creation: Developers (write), Reviewers (read), CI/CD (actions+packages) (#513) diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 8fd75de67a..557a1ede75 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -438,6 +438,7 @@ func prepareMigrationTasks() []*migration { newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables), newMigration(359, "Add deploy fields to repo manifest", v1_27.AddDeployFieldsToRepoManifest), newMigration(360, "Add delete allowlist to protected branch", v1_27.AddDeleteAllowlistToProtectedBranch), + newMigration(361, "Add cascade merge rule table", v1_27.AddCascadeMergeRuleTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v362.go b/models/migrations/v1_27/v362.go new file mode 100644 index 0000000000..03b74339fa --- /dev/null +++ b/models/migrations/v1_27/v362.go @@ -0,0 +1,24 @@ +// 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" +) + +func AddCascadeMergeRuleTable(x *xorm.Engine) error { + type CascadeMergeRule struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + Enabled bool `xorm:"NOT NULL DEFAULT true"` + AutoMerge bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` + } + return x.Sync(new(CascadeMergeRule)) +} diff --git a/models/repo/cascade.go b/models/repo/cascade.go new file mode 100644 index 0000000000..8aa59a6f54 --- /dev/null +++ b/models/repo/cascade.go @@ -0,0 +1,63 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "context" + + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +type CascadeMergeRule struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"` + SourceBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + TargetBranch string `xorm:"UNIQUE(s) VARCHAR(255) NOT NULL"` + Enabled bool `xorm:"NOT NULL DEFAULT true"` + AutoMerge bool `xorm:"NOT NULL DEFAULT false"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(CascadeMergeRule)) +} + +func GetCascadeRulesByRepoID(ctx context.Context, repoID int64) ([]*CascadeMergeRule, error) { + rules := make([]*CascadeMergeRule, 0) + return rules, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&rules) +} + +func GetCascadeRulesForBranch(ctx context.Context, repoID int64, sourceBranch string) ([]*CascadeMergeRule, error) { + rules := make([]*CascadeMergeRule, 0) + return rules, db.GetEngine(ctx).Where("repo_id = ? AND source_branch = ? AND enabled = ?", repoID, sourceBranch, true).Find(&rules) +} + +func GetCascadeRuleByID(ctx context.Context, id int64) (*CascadeMergeRule, error) { + rule := &CascadeMergeRule{ID: id} + has, err := db.GetEngine(ctx).Get(rule) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return rule, nil +} + +func CreateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error { + _, err := db.GetEngine(ctx).Insert(rule) + return err +} + +func UpdateCascadeRule(ctx context.Context, rule *CascadeMergeRule) error { + _, err := db.GetEngine(ctx).ID(rule.ID).Cols("source_branch", "target_branch", "enabled", "auto_merge").Update(rule) + return err +} + +func DeleteCascadeRule(ctx context.Context, repoID, id int64) error { + _, err := db.GetEngine(ctx).Where("id = ? AND repo_id = ?", id, repoID).Delete(&CascadeMergeRule{}) + return err +} diff --git a/modules/structs/repo_cascade.go b/modules/structs/repo_cascade.go new file mode 100644 index 0000000000..aaade87eaf --- /dev/null +++ b/modules/structs/repo_cascade.go @@ -0,0 +1,33 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package structs + +import "time" + +// CascadeMergeRule represents a cascade merge rule +type CascadeMergeRule struct { + ID int64 `json:"id"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Enabled bool `json:"enabled"` + AutoMerge bool `json:"auto_merge"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateCascadeMergeRuleOption options for creating a cascade merge rule +type CreateCascadeMergeRuleOption struct { + SourceBranch string `json:"source_branch" binding:"Required"` + TargetBranch string `json:"target_branch" binding:"Required"` + Enabled *bool `json:"enabled"` + AutoMerge *bool `json:"auto_merge"` +} + +// EditCascadeMergeRuleOption options for editing a cascade merge rule +type EditCascadeMergeRuleOption struct { + SourceBranch *string `json:"source_branch"` + TargetBranch *string `json:"target_branch"` + Enabled *bool `json:"enabled"` + AutoMerge *bool `json:"auto_merge"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5967370c6b..9bd32f29b6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1262,6 +1262,15 @@ func Routes() *web.Router { }) m.Post("/priority", bind(api.UpdateBranchProtectionPriories{}), mustNotBeArchived, repo.UpdateBranchProtectionPriories) }, reqToken(), reqAdmin()) + m.Group("/cascade_rules", func() { + m.Get("", repo.ListCascadeRules) + m.Post("", mustNotBeArchived, repo.CreateCascadeRule) + m.Group("/{id}", func() { + m.Get("", repo.GetCascadeRule) + m.Patch("", mustNotBeArchived, repo.EditCascadeRule) + m.Delete("", mustNotBeArchived, repo.DeleteCascadeRule) + }) + }, reqToken(), reqAdmin()) m.Group("/security", func() { m.Get("/alerts", repo.ListSecurityAlerts) m.Get("/alerts/{id}", repo.GetSecurityAlert) diff --git a/routers/api/v1/repo/cascade.go b/routers/api/v1/repo/cascade.go new file mode 100644 index 0000000000..e5459ac29a --- /dev/null +++ b/routers/api/v1/repo/cascade.go @@ -0,0 +1,151 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "encoding/json" + "net/http" + + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +func toCascadeRuleAPI(rule *repo_model.CascadeMergeRule) *api.CascadeMergeRule { + return &api.CascadeMergeRule{ + ID: rule.ID, + SourceBranch: rule.SourceBranch, + TargetBranch: rule.TargetBranch, + Enabled: rule.Enabled, + AutoMerge: rule.AutoMerge, + CreatedAt: rule.CreatedUnix.AsTime(), + UpdatedAt: rule.UpdatedUnix.AsTime(), + } +} + +func ListCascadeRules(ctx *context.APIContext) { + rules, err := repo_model.GetCascadeRulesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + apiRules := make([]*api.CascadeMergeRule, len(rules)) + for i, rule := range rules { + apiRules[i] = toCascadeRuleAPI(rule) + } + ctx.JSON(http.StatusOK, apiRules) +} + +func CreateCascadeRule(ctx *context.APIContext) { + var req api.CreateCascadeMergeRuleOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusBadRequest, err) + return + } + + if req.SourceBranch == "" || req.TargetBranch == "" { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch are required") + return + } + + if req.SourceBranch == req.TargetBranch { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different") + return + } + + rule := &repo_model.CascadeMergeRule{ + RepoID: ctx.Repo.Repository.ID, + SourceBranch: req.SourceBranch, + TargetBranch: req.TargetBranch, + Enabled: true, + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if req.AutoMerge != nil { + rule.AutoMerge = *req.AutoMerge + } + + if err := repo_model.CreateCascadeRule(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusCreated, toCascadeRuleAPI(rule)) +} + +func GetCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule)) +} + +func EditCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + var req api.EditCascadeMergeRuleOption + if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { + ctx.APIError(http.StatusBadRequest, err) + return + } + + if req.SourceBranch != nil { + rule.SourceBranch = *req.SourceBranch + } + if req.TargetBranch != nil { + rule.TargetBranch = *req.TargetBranch + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if req.AutoMerge != nil { + rule.AutoMerge = *req.AutoMerge + } + + if rule.SourceBranch == rule.TargetBranch { + ctx.APIError(http.StatusUnprocessableEntity, "source_branch and target_branch must be different") + return + } + + if err := repo_model.UpdateCascadeRule(ctx, rule); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, toCascadeRuleAPI(rule)) +} + +func DeleteCascadeRule(ctx *context.APIContext) { + rule, err := repo_model.GetCascadeRuleByID(ctx, ctx.PathParamInt64("id")) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if rule == nil || rule.RepoID != ctx.Repo.Repository.ID { + ctx.APIErrorNotFound() + return + } + + if err := repo_model.DeleteCascadeRule(ctx, ctx.Repo.Repository.ID, rule.ID); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/init.go b/routers/init.go index a7742cebd4..0b6c0e5916 100644 --- a/routers/init.go +++ b/routers/init.go @@ -38,6 +38,7 @@ import ( "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/auth/source/oauth2" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/automerge" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cascade" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/cron" feed_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/feed" indexer_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/indexer" @@ -153,6 +154,7 @@ func InitWebInstalled(ctx context.Context) { mustInit(webhook.Init) mustInit(pull_service.Init) mustInit(automerge.Init) + cascade.Init() mustInit(task.Init) mustInit(repo_migrations.Init) eventsource.GetManager().Init() diff --git a/services/cascade/notifier.go b/services/cascade/notifier.go new file mode 100644 index 0000000000..93a6cdbcff --- /dev/null +++ b/services/cascade/notifier.go @@ -0,0 +1,103 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package cascade + +import ( + "context" + "fmt" + + issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + 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" + pull_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/pull" +) + +func Init() { + notify_service.RegisterNotifier(&cascadeNotifier{}) +} + +type cascadeNotifier struct { + notify_service.NullNotifier +} + +func (n *cascadeNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + handleCascade(ctx, doer, pr) +} + +func (n *cascadeNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + handleCascade(ctx, doer, pr) +} + +func handleCascade(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { + if err := pr.LoadBaseRepo(ctx); err != nil { + log.Error("cascade: LoadBaseRepo for PR #%d: %v", pr.Index, err) + return + } + + rules, err := repo_model.GetCascadeRulesForBranch(ctx, pr.BaseRepo.ID, pr.BaseBranch) + if err != nil { + log.Error("cascade: GetCascadeRulesForBranch repo=%d branch=%s: %v", pr.BaseRepo.ID, pr.BaseBranch, err) + return + } + + for _, rule := range rules { + if err := createCascadePR(ctx, doer, pr, rule); err != nil { + log.Error("cascade: failed to create PR %s→%s in repo %d: %v", rule.SourceBranch, rule.TargetBranch, rule.RepoID, err) + } + } +} + +func createCascadePR(ctx context.Context, doer *user_model.User, mergedPR *issues_model.PullRequest, rule *repo_model.CascadeMergeRule) error { + repo := mergedPR.BaseRepo + + existingPRs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(ctx, repo.ID, rule.TargetBranch) + if err != nil { + return fmt.Errorf("check existing PRs: %w", err) + } + for _, existing := range existingPRs { + if existing.HeadBranch == rule.SourceBranch && existing.HeadRepoID == repo.ID { + log.Info("cascade: PR already exists %s→%s in %s/%s (#%d), skipping", + rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name, existing.Index) + return nil + } + } + + title := fmt.Sprintf("cascade: merge %s into %s", rule.SourceBranch, rule.TargetBranch) + body := fmt.Sprintf("Auto-created by cascade merge rule after PR #%d was merged.\n\nSource: `%s` → Target: `%s`", + mergedPR.Index, rule.SourceBranch, rule.TargetBranch) + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Title: title, + Content: body, + PosterID: doer.ID, + Poster: doer, + IsPull: true, + } + + pullRequest := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: rule.SourceBranch, + BaseBranch: rule.TargetBranch, + HeadRepo: repo, + BaseRepo: repo, + Type: issues_model.PullRequestGitea, + } + + if err := pull_service.NewPullRequest(ctx, &pull_service.NewPullRequestOptions{ + Repo: repo, + Issue: issue, + PullRequest: pullRequest, + }); err != nil { + return fmt.Errorf("NewPullRequest: %w", err) + } + + log.Info("cascade: created PR #%d (%s→%s) in %s/%s", + issue.Index, rule.SourceBranch, rule.TargetBranch, repo.OwnerName, repo.Name) + return nil +}