feat: cascade merge — auto-create PRs to downstream branches after merge (#460) #710

Merged
jmiller merged 4 commits from feature/cascade-merge into dev 2026-06-28 08:52:29 +00:00
9 changed files with 387 additions and 0 deletions
+1
View File
@@ -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)
+1
View File
@@ -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
}
+24
View File
@@ -0,0 +1,24 @@
// 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"
)
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))
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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"`
}
+9
View File
@@ -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)
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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)
}
+2
View File
@@ -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()
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}