feat: cascade merge — auto-create PRs to downstream branches after merge (#460) #710
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user