Skip to content

Commit 34fd3c9

Browse files
bircnicodexsilverwindclaudewxiaoguang
authored
feat: Add default PR branch update style setting (#37410)
Adds repository-level settings for pull request branch updates so admins can choose the default update method and disable merge or rebase updates. <img width="1025" height="158" src="https://github.com/user-attachments/assets/d030973b-0ddd-4035-b04f-145c445084d7" /> --------- Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent 16189a6 commit 34fd3c9

20 files changed

Lines changed: 493 additions & 60 deletions

File tree

models/repo/git.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const (
2929
MergeStyleRebaseUpdate MergeStyle = "rebase-update-only"
3030
)
3131

32+
// UpdateStyle is a pull request branch update style
33+
type UpdateStyle string
34+
35+
const (
36+
// UpdateStyleMerge merges the base branch into the pull request branch
37+
UpdateStyleMerge UpdateStyle = "merge"
38+
// UpdateStyleRebase rebases the pull request branch onto the base branch
39+
UpdateStyleRebase UpdateStyle = "rebase"
40+
)
41+
3242
// UpdateDefaultBranch updates the default branch
3343
func UpdateDefaultBranch(ctx context.Context, repo *Repository) error {
3444
_, err := db.GetEngine(ctx).ID(repo.ID).Cols("default_branch").Update(repo)

models/repo/pull_request_default_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"code.gitea.io/gitea/models/unit"
1010
"code.gitea.io/gitea/models/unittest"
11+
"code.gitea.io/gitea/modules/util"
1112

1213
"github.com/stretchr/testify/assert"
1314
)
@@ -30,3 +31,82 @@ func TestDefaultTargetBranchSelection(t *testing.T) {
3031
repo.Units = nil
3132
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
3233
}
34+
35+
func TestPullRequestConfigFromDB(t *testing.T) {
36+
cases := []struct {
37+
// name describes the row shape under test; the comments capture why each row matters.
38+
name string
39+
json string
40+
wantMergeUpdate bool
41+
wantRebaseUpdate bool
42+
wantDefaultStyle UpdateStyle
43+
wantValidatesPass bool
44+
}{
45+
{
46+
// Empty object exercises the all-defaults path (e.g. fresh repos created via low-level paths).
47+
name: "defaults", json: "{}",
48+
wantMergeUpdate: true, wantRebaseUpdate: true,
49+
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
50+
},
51+
{
52+
// Realistic upgrade case: pre-PR JSON lacks the new fields and has AllowRebaseUpdate=false.
53+
// Historical setting must be preserved while new fields take safe defaults.
54+
name: "legacy without new fields",
55+
json: `{"AllowMerge":true,"AllowRebase":true,"AllowRebaseMerge":true,"AllowSquash":true,"AllowRebaseUpdate":false}`,
56+
wantMergeUpdate: true, wantRebaseUpdate: false,
57+
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
58+
},
59+
{
60+
// Partially-migrated row with explicit empty string must normalize so ValidateUpdateSettings passes.
61+
name: "empty default style", json: `{"DefaultUpdateStyle":""}`,
62+
wantMergeUpdate: true, wantRebaseUpdate: true,
63+
wantDefaultStyle: UpdateStyleMerge, wantValidatesPass: true,
64+
},
65+
}
66+
for _, tc := range cases {
67+
t.Run(tc.name, func(t *testing.T) {
68+
cfg := new(PullRequestsConfig)
69+
assert.NoError(t, cfg.FromDB([]byte(tc.json)))
70+
assert.Equal(t, tc.wantMergeUpdate, cfg.AllowMergeUpdate)
71+
assert.Equal(t, tc.wantRebaseUpdate, cfg.AllowRebaseUpdate)
72+
assert.Equal(t, tc.wantDefaultStyle, cfg.DefaultUpdateStyle)
73+
if tc.wantValidatesPass {
74+
assert.NoError(t, cfg.ValidateUpdateSettings())
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestPullRequestConfigValidateUpdateSettingsInvalidArgument(t *testing.T) {
81+
cases := []struct {
82+
name string
83+
cfg PullRequestsConfig
84+
}{
85+
{
86+
name: "invalid default style",
87+
cfg: PullRequestsConfig{
88+
AllowMergeUpdate: true,
89+
AllowRebaseUpdate: true,
90+
DefaultUpdateStyle: "invalid",
91+
},
92+
},
93+
{
94+
name: "no update style enabled",
95+
cfg: PullRequestsConfig{
96+
DefaultUpdateStyle: UpdateStyleMerge,
97+
},
98+
},
99+
{
100+
name: "default update style disabled",
101+
cfg: PullRequestsConfig{
102+
AllowRebaseUpdate: true,
103+
DefaultUpdateStyle: UpdateStyleMerge,
104+
},
105+
},
106+
}
107+
for _, tc := range cases {
108+
t.Run(tc.name, func(t *testing.T) {
109+
assert.ErrorIs(t, tc.cfg.ValidateUpdateSettings(), util.ErrInvalidArgument)
110+
})
111+
}
112+
}

models/repo/repo_unit.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ type PullRequestsConfig struct {
125125
AllowFastForwardOnly bool
126126
AllowManualMerge bool
127127
AutodetectManualMerge bool
128+
AllowMergeUpdate bool
128129
AllowRebaseUpdate bool
130+
DefaultUpdateStyle UpdateStyle
129131
DefaultDeleteBranchAfterMerge bool
130132
DefaultMergeStyle MergeStyle
131133
DefaultAllowMaintainerEdit bool
@@ -139,7 +141,9 @@ func DefaultPullRequestsConfig() *PullRequestsConfig {
139141
AllowRebaseMerge: true,
140142
AllowSquash: true,
141143
AllowFastForwardOnly: true,
144+
AllowMergeUpdate: true,
142145
AllowRebaseUpdate: true,
146+
DefaultUpdateStyle: UpdateStyleMerge,
143147
DefaultAllowMaintainerEdit: true,
144148
}
145149
cfg.DefaultDeleteBranchAfterMerge = setting.Repository.PullRequest.DefaultDeleteBranchAfterMerge
@@ -152,7 +156,9 @@ func DefaultPullRequestsConfig() *PullRequestsConfig {
152156
func (cfg *PullRequestsConfig) FromDB(bs []byte) error {
153157
// set default values for existing PullRequestConfig in DB
154158
*cfg = *DefaultPullRequestsConfig()
155-
return json.UnmarshalHandleDoubleEncode(bs, &cfg)
159+
_ = json.UnmarshalHandleDoubleEncode(bs, &cfg) // don't let corrupted database value cause unnecessary 500 error
160+
cfg.DefaultUpdateStyle = util.IfZero(cfg.DefaultUpdateStyle, UpdateStyleMerge)
161+
return nil
156162
}
157163

158164
// ToDB exports a PullRequestsConfig to a serialized format.
@@ -170,6 +176,32 @@ func (cfg *PullRequestsConfig) IsMergeStyleAllowed(mergeStyle MergeStyle) bool {
170176
mergeStyle == MergeStyleManuallyMerged && cfg.AllowManualMerge
171177
}
172178

179+
// IsUpdateStyleAllowed returns if a pull request branch update style is allowed
180+
func (cfg *PullRequestsConfig) IsUpdateStyleAllowed(updateStyle UpdateStyle) bool {
181+
switch updateStyle {
182+
case UpdateStyleMerge:
183+
return cfg.AllowMergeUpdate
184+
case UpdateStyleRebase:
185+
return cfg.AllowRebaseUpdate
186+
default:
187+
return false
188+
}
189+
}
190+
191+
// ValidateUpdateSettings checks that the AllowMerge/RebaseUpdate flags and DefaultUpdateStyle are mutually consistent.
192+
func (cfg *PullRequestsConfig) ValidateUpdateSettings() error {
193+
if cfg.DefaultUpdateStyle != UpdateStyleMerge && cfg.DefaultUpdateStyle != UpdateStyleRebase {
194+
return util.NewInvalidArgumentErrorf("default update style must be merge or rebase")
195+
}
196+
if !cfg.AllowMergeUpdate && !cfg.AllowRebaseUpdate {
197+
return util.NewInvalidArgumentErrorf("at least one pull request branch update style must be enabled")
198+
}
199+
if !cfg.IsUpdateStyleAllowed(cfg.DefaultUpdateStyle) {
200+
return util.NewInvalidArgumentErrorf("default update style must be enabled")
201+
}
202+
return nil
203+
}
204+
173205
func DefaultPullRequestsUnit(repoID int64) RepoUnit {
174206
return RepoUnit{RepoID: repoID, Type: unit.TypePullRequests, Config: DefaultPullRequestsConfig()}
175207
}

modules/structs/repo.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,13 @@ type Repository struct {
113113
AllowRebaseMerge bool `json:"allow_rebase_explicit"`
114114
AllowSquash bool `json:"allow_squash_merge"`
115115
AllowFastForwardOnly bool `json:"allow_fast_forward_only_merge"`
116+
AllowMergeUpdate bool `json:"allow_merge_update"`
116117
AllowRebaseUpdate bool `json:"allow_rebase_update"`
117118
AllowManualMerge bool `json:"allow_manual_merge"`
118119
AutodetectManualMerge bool `json:"autodetect_manual_merge"`
119120
DefaultDeleteBranchAfterMerge bool `json:"default_delete_branch_after_merge"`
120121
DefaultMergeStyle string `json:"default_merge_style"`
122+
DefaultUpdateStyle string `json:"default_update_style"`
121123
DefaultAllowMaintainerEdit bool `json:"default_allow_maintainer_edit"`
122124
AvatarURL string `json:"avatar_url"`
123125
Internal bool `json:"internal"`
@@ -224,12 +226,16 @@ type EditRepoOption struct {
224226
AllowManualMerge *bool `json:"allow_manual_merge,omitempty"`
225227
// either `true` to enable AutodetectManualMerge, or `false` to prevent it. Note: In some special cases, misjudgments can occur.
226228
AutodetectManualMerge *bool `json:"autodetect_manual_merge,omitempty"`
229+
// either `true` to allow updating pull request branch by merge, or `false` to prevent it.
230+
AllowMergeUpdate *bool `json:"allow_merge_update,omitempty"`
227231
// either `true` to allow updating pull request branch by rebase, or `false` to prevent it.
228232
AllowRebaseUpdate *bool `json:"allow_rebase_update,omitempty"`
229233
// set to `true` to delete pr branch after merge by default
230234
DefaultDeleteBranchAfterMerge *bool `json:"default_delete_branch_after_merge,omitempty"`
231235
// set to a merge style to be used by this repository: "merge", "rebase", "rebase-merge", "squash", or "fast-forward-only".
232236
DefaultMergeStyle *string `json:"default_merge_style,omitempty"`
237+
// set to an update style to be used by this repository: "merge" or "rebase".
238+
DefaultUpdateStyle *string `json:"default_update_style,omitempty"`
233239
// set to `true` to allow edits from maintainers by default
234240
DefaultAllowMaintainerEdit *bool `json:"default_allow_maintainer_edit,omitempty"`
235241
// set to `true` to archive this repository.

options/locale/locale_en-US.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2138,7 +2138,9 @@
21382138
"repo.settings.pulls_desc": "Enable Repository Pull Requests",
21392139
"repo.settings.pulls.ignore_whitespace": "Ignore Whitespace for Conflicts",
21402140
"repo.settings.pulls.enable_autodetect_manual_merge": "Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)",
2141+
"repo.settings.pulls.allow_merge_update": "Enable updating pull request branch by merge",
21412142
"repo.settings.pulls.allow_rebase_update": "Enable updating pull request branch by rebase",
2143+
"repo.settings.pulls.default_update_style": "Default branch update style",
21422144
"repo.settings.pulls.default_target_branch": "Default target branch for new pull requests",
21432145
"repo.settings.pulls.default_target_branch_default": "Default branch (%s)",
21442146
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",

routers/api/v1/repo/pull.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,15 +1253,17 @@ func UpdatePullRequest(ctx *context.APIContext) {
12531253
return
12541254
}
12551255

1256-
rebase := ctx.FormString("style") == "rebase"
1256+
// keep API back-compat: when no style is given, default to "merge" rather than the repo's DefaultUpdateStyle,
1257+
// so existing API clients keep getting a merge update.
1258+
rebase := repo_model.UpdateStyle(ctx.FormString("style", string(repo_model.UpdateStyleMerge))) == repo_model.UpdateStyleRebase
12571259

1258-
allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, pr, ctx.Doer)
1260+
userUpdateStyles, err := pull_service.CheckUserAllowedToUpdate(ctx, pr, ctx.Doer)
12591261
if err != nil {
12601262
ctx.APIErrorInternal(err)
12611263
return
12621264
}
12631265

1264-
if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) {
1266+
if (rebase && !userUpdateStyles.RebaseAllowed) || (!rebase && !userUpdateStyles.MergeAllowed) {
12651267
ctx.Status(http.StatusForbidden)
12661268
return
12671269
}

routers/api/v1/repo/repo.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,10 +885,20 @@ func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error {
885885
optional.AssignPtrValue(changed, &config.AllowFastForwardOnly, opts.AllowFastForwardOnly)
886886
optional.AssignPtrValue(changed, &config.AllowManualMerge, opts.AllowManualMerge)
887887
optional.AssignPtrValue(changed, &config.AutodetectManualMerge, opts.AutodetectManualMerge)
888+
optional.AssignPtrValue(changed, &config.AllowMergeUpdate, opts.AllowMergeUpdate)
888889
optional.AssignPtrValue(changed, &config.AllowRebaseUpdate, opts.AllowRebaseUpdate)
889890
optional.AssignPtrValue(changed, &config.DefaultDeleteBranchAfterMerge, opts.DefaultDeleteBranchAfterMerge)
890891
optional.AssignPtrValue(changed, &config.DefaultAllowMaintainerEdit, opts.DefaultAllowMaintainerEdit)
891892
optional.AssignPtrString(changed, &config.DefaultMergeStyle, opts.DefaultMergeStyle)
893+
optional.AssignPtrString(changed, &config.DefaultUpdateStyle, opts.DefaultUpdateStyle)
894+
// only validate update-style fields when the caller is actually changing one of them,
895+
// so unrelated PATCH calls don't reject historical configs.
896+
if opts.AllowMergeUpdate != nil || opts.AllowRebaseUpdate != nil || opts.DefaultUpdateStyle != nil {
897+
if err := config.ValidateUpdateSettings(); err != nil {
898+
ctx.APIError(http.StatusUnprocessableEntity, err)
899+
return err
900+
}
901+
}
892902
if *changed || mustInsertPullRequestUnit {
893903
units = append(units, repo_model.RepoUnit{
894904
RepoID: repo.ID,

routers/web/repo/issue_view.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -867,10 +867,8 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
867867

868868
if !prInfo.IsPullRequestBroken {
869869
data.ShowUpdatePullInfo = pull.CommitsBehind > 0 && !issue.IsClosed && !pull.IsChecking() && !pull.IsFilesConflicted() && !prInfo.IsPullRequestBroken
870-
var err error
871-
data.UpdateAllowed, data.UpdateByRebaseAllowed, err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer)
872-
if err != nil {
873-
ctx.ServerError("IsUserAllowedToUpdate", err)
870+
prInfo.preparePullUpdateActions(ctx)
871+
if ctx.Written() {
874872
return
875873
}
876874
}
@@ -975,6 +973,45 @@ func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *
975973
ctx.Data["PullMergeBoxData"] = prInfo.MergeBoxData
976974
}
977975

976+
func (prInfo *pullRequestViewInfo) preparePullUpdateActions(ctx *context.Context) {
977+
pull := prInfo.issue.PullRequest
978+
data := prInfo.MergeBoxData
979+
userUpdateStyles, err := pull_service.CheckUserAllowedToUpdate(ctx, pull, ctx.Doer)
980+
if err != nil {
981+
ctx.ServerError("IsUserAllowedToUpdate", err)
982+
return
983+
}
984+
if !userUpdateStyles.MergeAllowed && !userUpdateStyles.RebaseAllowed {
985+
return
986+
}
987+
988+
issueLink := prInfo.issue.Link()
989+
mergeAction := &pullUpdateAction{
990+
URL: issueLink + "/update?style=merge",
991+
Text: ctx.Tr("repo.pulls.update_branch"),
992+
}
993+
rebaseAction := &pullUpdateAction{
994+
URL: issueLink + "/update?style=rebase",
995+
Text: ctx.Tr("repo.pulls.update_branch_rebase"),
996+
}
997+
998+
if userUpdateStyles.MergeAllowed {
999+
data.UpdateStyleOptions = append(data.UpdateStyleOptions, mergeAction)
1000+
}
1001+
if userUpdateStyles.RebaseAllowed {
1002+
data.UpdateStyleOptions = append(data.UpdateStyleOptions, rebaseAction)
1003+
}
1004+
1005+
if userUpdateStyles.DefaultUpdateStyle == repo_model.UpdateStyleRebase && userUpdateStyles.RebaseAllowed {
1006+
data.UpdatePrimaryAction = rebaseAction
1007+
} else if userUpdateStyles.DefaultUpdateStyle == repo_model.UpdateStyleMerge && userUpdateStyles.MergeAllowed {
1008+
data.UpdatePrimaryAction = mergeAction
1009+
} else {
1010+
data.UpdatePrimaryAction = data.UpdateStyleOptions[0]
1011+
}
1012+
data.UpdatePrimaryAction.Selected = true
1013+
}
1014+
9781015
func (prInfo *pullRequestViewInfo) prepareMergeBoxProtectionChecks(ctx *context.Context) {
9791016
pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, prInfo.issue.PullRequest.BaseBranch)
9801017
if err != nil {

routers/web/repo/pull.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,9 @@ type pullMergeBoxData struct {
280280
CanMergeNow bool // PR is mergeable, either no blocker, or doer is admin and can bypass the blockers
281281
allowMerge bool // doer has permission to merge
282282

283-
ShowUpdatePullInfo bool
284-
UpdateAllowed bool
285-
UpdateByRebaseAllowed bool
283+
ShowUpdatePullInfo bool
284+
UpdatePrimaryAction *pullUpdateAction
285+
UpdateStyleOptions []*pullUpdateAction
286286

287287
MergeFormProps map[string]any
288288
ShowPullCommands bool
@@ -308,6 +308,12 @@ type pullMergeBoxData struct {
308308
InfoSections []*pullInfoSection
309309
}
310310

311+
type pullUpdateAction struct {
312+
URL string
313+
Text template.HTML
314+
Selected bool
315+
}
316+
311317
// pullRequestViewInfo is a structured type for viewing pull request
312318
// Refactoring plan:
313319
// * move dynamic template-data-based variable into this struct
@@ -957,8 +963,6 @@ func UpdatePullRequest(ctx *context.Context) {
957963
return
958964
}
959965

960-
rebase := ctx.FormString("style") == "rebase"
961-
962966
if err := issue.PullRequest.LoadBaseRepo(ctx); err != nil {
963967
ctx.ServerError("LoadBaseRepo", err)
964968
return
@@ -968,13 +972,14 @@ func UpdatePullRequest(ctx *context.Context) {
968972
return
969973
}
970974

971-
allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, issue.PullRequest, ctx.Doer)
975+
userUpdateStyles, err := pull_service.CheckUserAllowedToUpdate(ctx, issue.PullRequest, ctx.Doer)
972976
if err != nil {
973977
ctx.ServerError("IsUserAllowedToMerge", err)
974978
return
975979
}
976980

977-
if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) {
981+
rebase := ctx.FormString("style", string(userUpdateStyles.DefaultUpdateStyle)) == string(repo_model.UpdateStyleRebase)
982+
if (rebase && !userUpdateStyles.RebaseAllowed) || (!rebase && !userUpdateStyles.MergeAllowed) {
978983
ctx.JSONError(ctx.Tr("repo.pulls.update_not_allowed"))
979984
return
980985
}

0 commit comments

Comments
 (0)