Allow configuring default PR base branch (fixes #36412) (#36425)

This adds a per-repository default PR base branch and wires it through
PR entry points. It updates compare links and recently pushed branch
prompts to respect the configured base branch, and prevents auto-merge
cleanup from deleting the configured base branch on same-repo PRs.

## Behavior changes
- New PR compare links on repo home/issue list and branch list honor the
configured default PR base branch.
- The "recently pushed new branches" prompt now compares against the
configured base branch.
- Auto-merge branch cleanup skips deleting the configured base branch
(same-repo PRs only).

---------

Signed-off-by: Louis <116039387+tototomate123@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Louis
2026-02-07 02:34:29 +01:00
committed by GitHub
parent 0dacd956fb
commit e2104a1dd5
25 changed files with 213 additions and 99 deletions

View File

@@ -490,12 +490,25 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix() opts.CommitAfterUnix = time.Now().Add(-time.Hour * 2).Unix()
} }
baseBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch) var ignoredCommitIDs []string
baseDefaultBranch, err := GetBranch(ctx, opts.BaseRepo.ID, opts.BaseRepo.DefaultBranch)
if err != nil { if err != nil {
return nil, err log.Warn("GetBranch:DefaultBranch: %v", err)
} else {
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultBranch.CommitID)
} }
// find all related branches, these branches may already created PRs, we will check later baseDefaultTargetBranchName := opts.BaseRepo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig().DefaultTargetBranch
if baseDefaultTargetBranchName != "" && baseDefaultTargetBranchName != opts.BaseRepo.DefaultBranch {
baseDefaultTargetBranch, err := GetBranch(ctx, opts.BaseRepo.ID, baseDefaultTargetBranchName)
if err != nil {
log.Warn("GetBranch:DefaultTargetBranch: %v", err)
} else {
ignoredCommitIDs = append(ignoredCommitIDs, baseDefaultTargetBranch.CommitID)
}
}
// find all related branches, these branches may already have PRs, we will check later
var branches []*Branch var branches []*Branch
if err := db.GetEngine(ctx). if err := db.GetEngine(ctx).
Where(builder.And( Where(builder.And(
@@ -506,7 +519,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
builder.Gte{"commit_time": opts.CommitAfterUnix}, builder.Gte{"commit_time": opts.CommitAfterUnix},
builder.In("repo_id", repoIDs), builder.In("repo_id", repoIDs),
// newly created branch have no changes, so skip them // newly created branch have no changes, so skip them
builder.Neq{"commit_id": baseBranch.CommitID}, builder.NotIn("commit_id", ignoredCommitIDs),
)). )).
OrderBy(db.SearchOrderByRecentUpdated.String()). OrderBy(db.SearchOrderByRecentUpdated.String()).
Find(&branches); err != nil { Find(&branches); err != nil {
@@ -514,10 +527,8 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
} }
newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches)) newBranches := make([]*RecentlyPushedNewBranch, 0, len(branches))
if opts.MaxCount == 0 { opts.MaxCount = util.IfZero(opts.MaxCount, 2) // by default, we display 2 recently pushed new branch
// by default we display 2 recently pushed new branch baseTargetBranchName := opts.BaseRepo.GetPullRequestTargetBranch(ctx)
opts.MaxCount = 2
}
for _, branch := range branches { for _, branch := range branches {
// whether the branch is protected // whether the branch is protected
protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name) protected, err := IsBranchProtected(ctx, branch.RepoID, branch.Name)
@@ -555,7 +566,7 @@ func FindRecentlyPushedNewBranches(ctx context.Context, doer *user_model.User, o
BranchDisplayName: branchDisplayName, BranchDisplayName: branchDisplayName,
BranchName: branch.Name, BranchName: branch.Name,
BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)), BranchLink: fmt.Sprintf("%s/src/branch/%s", branch.Repo.Link(), util.PathEscapeSegments(branch.Name)),
BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, branch.Name), BranchCompareURL: branch.Repo.ComposeBranchCompareURL(opts.BaseRepo, baseTargetBranchName, branch.Name),
CommitTime: branch.CommitTime, CommitTime: branch.CommitTime,
}) })
} }

View File

@@ -0,0 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"context"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/util"
)
func (repo *Repository) GetPullRequestTargetBranch(ctx context.Context) string {
unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
return util.IfZero(unitPRConfig.DefaultTargetBranch, repo.DefaultBranch)
}

View File

@@ -0,0 +1,32 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"testing"
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
"github.com/stretchr/testify/assert"
)
func TestDefaultTargetBranchSelection(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
ctx := t.Context()
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1})
assert.Equal(t, repo.DefaultBranch, repo.GetPullRequestTargetBranch(ctx))
repo.Units = nil
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
assert.NoError(t, err)
prConfig := prUnit.PullRequestsConfig()
prConfig.DefaultTargetBranch = "branch2"
prUnit.Config = prConfig
assert.NoError(t, UpdateRepoUnit(ctx, prUnit))
repo.Units = nil
assert.Equal(t, "branch2", repo.GetPullRequestTargetBranch(ctx))
}

View File

@@ -613,16 +613,13 @@ func (repo *Repository) ComposeCompareURL(oldCommitID, newCommitID string) strin
return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID)) return fmt.Sprintf("%s/%s/compare/%s...%s", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), util.PathEscapeSegments(oldCommitID), util.PathEscapeSegments(newCommitID))
} }
func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, branchName string) string { func (repo *Repository) ComposeBranchCompareURL(baseRepo *Repository, baseBranch, branchName string) string {
if baseRepo == nil {
baseRepo = repo
}
var cmpBranchEscaped string var cmpBranchEscaped string
if repo.ID != baseRepo.ID { if repo.ID != baseRepo.ID {
cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name)) cmpBranchEscaped = fmt.Sprintf("%s/%s:", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name))
} }
cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName)) cmpBranchEscaped = fmt.Sprintf("%s%s", cmpBranchEscaped, util.PathEscapeSegments(branchName))
return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseRepo.DefaultBranch), cmpBranchEscaped) return fmt.Sprintf("%s/compare/%s...%s", baseRepo.Link(), util.PathEscapeSegments(baseBranch), cmpBranchEscaped)
} }
// IsOwnedBy returns true when user owns this repository // IsOwnedBy returns true when user owns this repository

View File

@@ -131,6 +131,7 @@ type PullRequestsConfig struct {
DefaultDeleteBranchAfterMerge bool DefaultDeleteBranchAfterMerge bool
DefaultMergeStyle MergeStyle DefaultMergeStyle MergeStyle
DefaultAllowMaintainerEdit bool DefaultAllowMaintainerEdit bool
DefaultTargetBranch string
} }
// FromDB fills up a PullRequestsConfig from serialized format. // FromDB fills up a PullRequestsConfig from serialized format.

View File

@@ -58,26 +58,27 @@ type Repository struct {
Fork bool `json:"fork"` Fork bool `json:"fork"`
Template bool `json:"template"` Template bool `json:"template"`
// the original repository if this repository is a fork, otherwise null // the original repository if this repository is a fork, otherwise null
Parent *Repository `json:"parent,omitempty"` Parent *Repository `json:"parent,omitempty"`
Mirror bool `json:"mirror"` Mirror bool `json:"mirror"`
Size int `json:"size"` Size int `json:"size"`
Language string `json:"language"` Language string `json:"language"`
LanguagesURL string `json:"languages_url"` LanguagesURL string `json:"languages_url"`
HTMLURL string `json:"html_url"` HTMLURL string `json:"html_url"`
URL string `json:"url"` URL string `json:"url"`
Link string `json:"link"` Link string `json:"link"`
SSHURL string `json:"ssh_url"` SSHURL string `json:"ssh_url"`
CloneURL string `json:"clone_url"` CloneURL string `json:"clone_url"`
OriginalURL string `json:"original_url"` OriginalURL string `json:"original_url"`
Website string `json:"website"` Website string `json:"website"`
Stars int `json:"stars_count"` Stars int `json:"stars_count"`
Forks int `json:"forks_count"` Forks int `json:"forks_count"`
Watchers int `json:"watchers_count"` Watchers int `json:"watchers_count"`
OpenIssues int `json:"open_issues_count"` OpenIssues int `json:"open_issues_count"`
OpenPulls int `json:"open_pr_counter"` OpenPulls int `json:"open_pr_counter"`
Releases int `json:"release_counter"` Releases int `json:"release_counter"`
DefaultBranch string `json:"default_branch"` DefaultBranch string `json:"default_branch"`
Archived bool `json:"archived"` DefaultTargetBranch string `json:"default_target_branch,omitempty"`
Archived bool `json:"archived"`
// swagger:strfmt date-time // swagger:strfmt date-time
Created time.Time `json:"created_at"` Created time.Time `json:"created_at"`
// swagger:strfmt date-time // swagger:strfmt date-time

View File

@@ -2124,6 +2124,8 @@
"repo.settings.pulls.ignore_whitespace": "Ignore Whitespace for Conflicts", "repo.settings.pulls.ignore_whitespace": "Ignore Whitespace for Conflicts",
"repo.settings.pulls.enable_autodetect_manual_merge": "Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)", "repo.settings.pulls.enable_autodetect_manual_merge": "Enable autodetect manual merge (Note: In some special cases, misjudgments can occur)",
"repo.settings.pulls.allow_rebase_update": "Enable updating pull request branch by rebase", "repo.settings.pulls.allow_rebase_update": "Enable updating pull request branch by rebase",
"repo.settings.pulls.default_target_branch": "Default target branch for new pull requests",
"repo.settings.pulls.default_target_branch_default": "Default branch (%s)",
"repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default", "repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default",
"repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default", "repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default",
"repo.settings.releases_desc": "Enable Repository Releases", "repo.settings.releases_desc": "Enable Repository Releases",
@@ -2436,9 +2438,10 @@
"repo.settings.block_outdated_branch_desc": "Merging will not be possible when head branch is behind base branch.", "repo.settings.block_outdated_branch_desc": "Merging will not be possible when head branch is behind base branch.",
"repo.settings.block_admin_merge_override": "Administrators must follow branch protection rules", "repo.settings.block_admin_merge_override": "Administrators must follow branch protection rules",
"repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it.", "repo.settings.block_admin_merge_override_desc": "Administrators must follow branch protection rules and cannot circumvent it.",
"repo.settings.default_branch_desc": "Select a default repository branch for pull requests and code commits:", "repo.settings.default_branch_desc": "Select a default branch for code commits.",
"repo.settings.default_target_branch_desc": "Pull requests can use different default target branch if it is set in the Pull Requests section of Repository Advance Settings.",
"repo.settings.merge_style_desc": "Merge Styles", "repo.settings.merge_style_desc": "Merge Styles",
"repo.settings.default_merge_style_desc": "Default Merge Style", "repo.settings.default_merge_style_desc": "Default merge style",
"repo.settings.choose_branch": "Choose a branch…", "repo.settings.choose_branch": "Choose a branch…",
"repo.settings.no_protected_branch": "There are no protected branches.", "repo.settings.no_protected_branch": "There are no protected branches.",
"repo.settings.edit_protected_branch": "Edit", "repo.settings.edit_protected_branch": "Edit",
@@ -2650,7 +2653,7 @@
"repo.branch.restore_success": "Branch \"%s\" has been restored.", "repo.branch.restore_success": "Branch \"%s\" has been restored.",
"repo.branch.restore_failed": "Failed to restore branch \"%s\".", "repo.branch.restore_failed": "Failed to restore branch \"%s\".",
"repo.branch.protected_deletion_failed": "Branch \"%s\" is protected. It cannot be deleted.", "repo.branch.protected_deletion_failed": "Branch \"%s\" is protected. It cannot be deleted.",
"repo.branch.default_deletion_failed": "Branch \"%s\" is the default branch. It cannot be deleted.", "repo.branch.default_deletion_failed": "Branch \"%s\" is the default or pull request target branch. It cannot be deleted.",
"repo.branch.default_branch_not_exist": "Default branch \"%s\" does not exist.", "repo.branch.default_branch_not_exist": "Default branch \"%s\" does not exist.",
"repo.branch.restore": "Restore Branch \"%s\"", "repo.branch.restore": "Restore Branch \"%s\"",
"repo.branch.download": "Download Branch \"%s\"", "repo.branch.download": "Download Branch \"%s\"",

View File

@@ -155,7 +155,7 @@ func DeleteBranch(ctx *context.APIContext) {
case git.IsErrBranchNotExist(err): case git.IsErrBranchNotExist(err):
ctx.APIErrorNotFound(err) ctx.APIErrorNotFound(err)
case errors.Is(err, repo_service.ErrBranchIsDefault): case errors.Is(err, repo_service.ErrBranchIsDefault):
ctx.APIError(http.StatusForbidden, errors.New("can not delete default branch")) ctx.APIError(http.StatusForbidden, errors.New("can not delete default or pull request target branch"))
case errors.Is(err, git_model.ErrBranchIsProtected): case errors.Is(err, git_model.ErrBranchIsProtected):
ctx.APIError(http.StatusForbidden, errors.New("branch protected")) ctx.APIError(http.StatusForbidden, errors.New("branch protected"))
default: default:

View File

@@ -1138,7 +1138,7 @@ func parseCompareInfo(ctx *context.APIContext, compareParam string) (result *git
return nil, nil return nil, nil
} }
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.BaseOriRef, baseRepo.DefaultBranch)) baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx)))
headRef := headGitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)) headRef := headGitRepo.UnstableGuessRefByShortName(util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch))
log.Trace("Repo path: %q, base ref: %q->%q, head ref: %q->%q", ctx.Repo.Repository.RelativePath(), compareReq.BaseOriRef, baseRef, compareReq.HeadOriRef, headRef) log.Trace("Repo path: %q, base ref: %q->%q, head ref: %q->%q", ctx.Repo.Repository.RelativePath(), compareReq.BaseOriRef, baseRef, compareReq.HeadOriRef, headRef)

View File

@@ -41,6 +41,7 @@ func Branches(ctx *context.Context) {
ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx) ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx)
ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode) ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode)
ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror
// TODO: Can be replaced by ctx.Repo.PullRequestCtx.CanCreateNewPull()
ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) || ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) ||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
ctx.Data["PageIsViewCode"] = true ctx.Data["PageIsViewCode"] = true

View File

@@ -247,7 +247,7 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
} }
// 4 get base and head refs // 4 get base and head refs
baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.DefaultBranch) baseRefName := util.IfZero(compareReq.BaseOriRef, baseRepo.GetPullRequestTargetBranch(ctx))
headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch) headRefName := util.IfZero(compareReq.HeadOriRef, headRepo.DefaultBranch)
baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName) baseRef := ctx.Repo.GitRepo.UnstableGuessRefByShortName(baseRefName)
@@ -276,10 +276,10 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo {
ctx.Data["BaseBranch"] = baseRef.ShortName() // for legacy templates ctx.Data["BaseBranch"] = baseRef.ShortName() // for legacy templates
ctx.Data["HeadUser"] = headOwner ctx.Data["HeadUser"] = headOwner
ctx.Data["HeadBranch"] = headRef.ShortName() // for legacy templates ctx.Data["HeadBranch"] = headRef.ShortName() // for legacy templates
ctx.Repo.PullRequest.SameRepo = isSameRepo
ctx.Data["IsPull"] = true ctx.Data["IsPull"] = true
context.InitRepoPullRequestCtx(ctx, baseRepo, headRepo)
// The current base and head repositories and branches may not // The current base and head repositories and branches may not
// actually be the intended branches that the user wants to // actually be the intended branches that the user wants to
// create a pull-request from - but also determining the head // create a pull-request from - but also determining the head

View File

@@ -109,11 +109,6 @@ func MustAllowPulls(ctx *context.Context) {
ctx.NotFound(nil) ctx.NotFound(nil)
return return
} }
// User can send pull request if owns a forked repository.
if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) {
ctx.Repo.PullRequest.Allowed = true
}
} }
func retrieveProjectsInternal(ctx *context.Context, repo *repo_model.Repository) (open, closed []*project_model.Project) { func retrieveProjectsInternal(ctx *context.Context, repo *repo_model.Repository) (open, closed []*project_model.Project) {

View File

@@ -28,6 +28,7 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
repo_router "code.gitea.io/gitea/routers/web/repo"
actions_service "code.gitea.io/gitea/services/actions" actions_service "code.gitea.io/gitea/services/actions"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@@ -88,6 +89,11 @@ func SettingsCtxData(ctx *context.Context) {
return return
} }
ctx.Data["PushMirrors"] = pushMirrors ctx.Data["PushMirrors"] = pushMirrors
repo_router.PrepareBranchList(ctx)
if ctx.Written() {
return
}
} }
// Settings show a repository's settings page // Settings show a repository's settings page
@@ -622,6 +628,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge, DefaultDeleteBranchAfterMerge: form.DefaultDeleteBranchAfterMerge,
DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle), DefaultMergeStyle: repo_model.MergeStyle(form.PullsDefaultMergeStyle),
DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit, DefaultAllowMaintainerEdit: form.DefaultAllowMaintainerEdit,
DefaultTargetBranch: strings.TrimSpace(form.DefaultTargetBranch),
})) }))
} else if !unit_model.TypePullRequests.UnitGlobalDisabled() { } else if !unit_model.TypePullRequests.UnitGlobalDisabled() {
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests)

View File

@@ -221,7 +221,7 @@ func APIContexter() func(http.Handler) http.Handler {
ctx := &APIContext{ ctx := &APIContext{
Base: base, Base: base,
Cache: cache.GetCache(), Cache: cache.GetCache(),
Repo: &Repository{PullRequest: &PullRequest{}}, Repo: &Repository{},
Org: &APIOrganization{}, Org: &APIOrganization{},
} }

View File

@@ -129,7 +129,7 @@ func NewWebContext(base *Base, render Render, session session.Store) *Context {
Cache: cache.GetCache(), Cache: cache.GetCache(),
Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"), Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
Repo: &Repository{PullRequest: &PullRequest{}}, Repo: &Repository{},
Org: &Organization{}, Org: &Organization{},
} }
ctx.TemplateContext = NewTemplateContextForWeb(ctx) ctx.TemplateContext = NewTemplateContextForWeb(ctx)

View File

@@ -37,11 +37,46 @@ import (
"github.com/editorconfig/editorconfig-core-go/v2" "github.com/editorconfig/editorconfig-core-go/v2"
) )
// PullRequest contains information to make a pull request // PullRequestContext contains context information for making a new pull request
type PullRequest struct { type PullRequestContext struct {
BaseRepo *repo_model.Repository ctx *Context
Allowed bool // it only used by the web tmpl: "PullRequestCtx.Allowed"
SameRepo bool // it only used by the web tmpl: "PullRequestCtx.SameRepo" baseRepo, headRepo *repo_model.Repository
canCreateNewPull *bool
defaultTargetBranch *string
}
func (prc *PullRequestContext) SameRepo() bool {
return prc.baseRepo != nil && prc.headRepo != nil && prc.baseRepo.ID == prc.headRepo.ID
}
func (prc *PullRequestContext) CanCreateNewPull() bool {
if prc.canCreateNewPull != nil {
return *prc.canCreateNewPull
}
ctx := prc.ctx
// People who have push access or have forked repository can propose a new pull request.
can := prc.baseRepo.CanContentChange() &&
(ctx.Repo.CanWrite(unit_model.TypeCode) || (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)))
prc.canCreateNewPull = &can
return can
}
func (prc *PullRequestContext) MakeDefaultCompareLink(headBranch string) string {
return prc.baseRepo.Link() + "/compare/" +
util.PathEscapeSegments(prc.DefaultTargetBranch()) + "..." +
util.Iif(prc.SameRepo(), "", util.PathEscapeSegments(prc.headRepo.OwnerName)+":") +
util.PathEscapeSegments(headBranch)
}
func (prc *PullRequestContext) DefaultTargetBranch() string {
if prc.defaultTargetBranch != nil {
return *prc.defaultTargetBranch
}
branchName := prc.baseRepo.GetPullRequestTargetBranch(prc.ctx)
prc.defaultTargetBranch = &branchName
return branchName
} }
// Repository contains information to operate a repository // Repository contains information to operate a repository
@@ -64,7 +99,7 @@ type Repository struct {
CommitID string CommitID string
CommitsCount int64 CommitsCount int64
PullRequest *PullRequest PullRequestCtx *PullRequestContext
} }
// CanWriteToBranch checks if the branch is writable by the user // CanWriteToBranch checks if the branch is writable by the user
@@ -418,6 +453,12 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
} }
func InitRepoPullRequestCtx(ctx *Context, base, head *repo_model.Repository) {
ctx.Repo.PullRequestCtx = &PullRequestContext{ctx: ctx}
ctx.Repo.PullRequestCtx.baseRepo, ctx.Repo.PullRequestCtx.headRepo = base, head
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequestCtx
}
// RepoAssignment returns a middleware to handle repository assignment // RepoAssignment returns a middleware to handle repository assignment
func RepoAssignment(ctx *Context) { func RepoAssignment(ctx *Context) {
if ctx.Data["Repository"] != nil { if ctx.Data["Repository"] != nil {
@@ -666,28 +707,16 @@ func RepoAssignment(ctx *Context) {
ctx.Data["BranchesCount"] = branchesTotal ctx.Data["BranchesCount"] = branchesTotal
// People who have push access or have forked repository can propose a new pull request. // Pull request is allowed if this is a fork repository, and base repository accepts pull requests.
canPush := ctx.Repo.CanWrite(unit_model.TypeCode) ||
(ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID))
canCompare := false
// Pull request is allowed if this is a fork repository
// and base repository accepts pull requests.
if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) { if repo.BaseRepo != nil && repo.BaseRepo.AllowsPulls(ctx) {
canCompare = true // TODO: this (and below) "BaseRepo" var is not clear and should be removed in the future
ctx.Data["BaseRepo"] = repo.BaseRepo ctx.Data["BaseRepo"] = repo.BaseRepo
ctx.Repo.PullRequest.BaseRepo = repo.BaseRepo InitRepoPullRequestCtx(ctx, repo.BaseRepo, repo)
ctx.Repo.PullRequest.Allowed = canPush
} else if repo.AllowsPulls(ctx) { } else if repo.AllowsPulls(ctx) {
// Or, this is repository accepts pull requests between branches. // Or, this is repository accepts pull requests between branches.
canCompare = true
ctx.Data["BaseRepo"] = repo ctx.Data["BaseRepo"] = repo
ctx.Repo.PullRequest.BaseRepo = repo InitRepoPullRequestCtx(ctx, repo, repo)
ctx.Repo.PullRequest.Allowed = canPush
ctx.Repo.PullRequest.SameRepo = true
} }
ctx.Data["CanCompareOrPull"] = canCompare
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer {
repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) repoTransfer, err := repo_model.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository)

View File

@@ -103,6 +103,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
defaultDeleteBranchAfterMerge := false defaultDeleteBranchAfterMerge := false
defaultMergeStyle := repo_model.MergeStyleMerge defaultMergeStyle := repo_model.MergeStyleMerge
defaultAllowMaintainerEdit := false defaultAllowMaintainerEdit := false
defaultTargetBranch := ""
if unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests); err == nil { if unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests); err == nil {
config := unit.PullRequestsConfig() config := unit.PullRequestsConfig()
hasPullRequests = true hasPullRequests = true
@@ -118,6 +119,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge defaultDeleteBranchAfterMerge = config.DefaultDeleteBranchAfterMerge
defaultMergeStyle = config.GetDefaultMergeStyle() defaultMergeStyle = config.GetDefaultMergeStyle()
defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit defaultAllowMaintainerEdit = config.DefaultAllowMaintainerEdit
defaultTargetBranch = config.DefaultTargetBranch
} }
hasProjects := false hasProjects := false
projectsMode := repo_model.ProjectsModeAll projectsMode := repo_model.ProjectsModeAll
@@ -235,6 +237,7 @@ func innerToRepo(ctx context.Context, repo *repo_model.Repository, permissionInR
DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge, DefaultDeleteBranchAfterMerge: defaultDeleteBranchAfterMerge,
DefaultMergeStyle: string(defaultMergeStyle), DefaultMergeStyle: string(defaultMergeStyle),
DefaultAllowMaintainerEdit: defaultAllowMaintainerEdit, DefaultAllowMaintainerEdit: defaultAllowMaintainerEdit,
DefaultTargetBranch: defaultTargetBranch,
AvatarURL: repo.AvatarLink(ctx), AvatarURL: repo.AvatarLink(ctx),
Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate,
MirrorInterval: mirrorInterval, MirrorInterval: mirrorInterval,

View File

@@ -143,6 +143,7 @@ type RepoSettingForm struct {
PullsAllowRebaseUpdate bool PullsAllowRebaseUpdate bool
DefaultDeleteBranchAfterMerge bool DefaultDeleteBranchAfterMerge bool
DefaultAllowMaintainerEdit bool DefaultAllowMaintainerEdit bool
DefaultTargetBranch string
EnableTimetracker bool EnableTimetracker bool
AllowOnlyContributorsToTrackTime bool AllowOnlyContributorsToTrackTime bool
EnableIssueDependencies bool EnableIssueDependencies bool

View File

@@ -547,10 +547,11 @@ func UpdateBranch(ctx context.Context, repo *repo_model.Repository, gitRepo *git
return gitrepo.Push(ctx, repo, repo, pushOpts) return gitrepo.Push(ctx, repo, repo, pushOpts)
} }
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default") var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default or pull request target")
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error { func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
if branchName == repo.DefaultBranch { unitPRConfig := repo.MustGetUnit(ctx, unit.TypePullRequests).PullRequestsConfig()
if branchName == repo.DefaultBranch || branchName == unitPRConfig.DefaultTargetBranch {
return ErrBranchIsDefault return ErrBranchIsDefault
} }

View File

@@ -133,14 +133,14 @@
<span class="ui orange large label" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.included_desc"}}"> <span class="ui orange large label" data-tooltip-content="{{ctx.Locale.Tr "repo.branch.included_desc"}}">
{{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.branch.included"}} {{svg "octicon-git-pull-request"}} {{ctx.Locale.Tr "repo.branch.included"}}
</span> </span>
{{else if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}} {{else if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}}
<a href="{{$.RepoLink}}/compare/{{PathEscapeSegments $.DefaultBranchBranch.DBBranch.Name}}...{{if ne $.Repository.Owner.Name $.Owner.Name}}{{PathEscape $.Owner.Name}}:{{end}}{{PathEscapeSegments .DBBranch.Name}}?expand=1"> <a href="{{$.PullRequestCtx.MakeDefaultCompareLink .DBBranch.Name}}?expand=1">
<button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button> <button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button>
</a> </a>
{{end}} {{end}}
{{else if and .LatestPullRequest.HasMerged .MergeMovedOn}} {{else if and .LatestPullRequest.HasMerged .MergeMovedOn}}
{{if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}} {{if and (not .DBBranch.IsDeleted) $.AllowsPulls (gt .CommitsAhead 0)}}
<a href="{{$.RepoLink}}/compare/{{PathEscapeSegments $.DefaultBranchBranch.DBBranch.Name}}...{{if ne $.Repository.Owner.Name $.Owner.Name}}{{PathEscape $.Owner.Name}}:{{end}}{{PathEscapeSegments .DBBranch.Name}}?expand=1"> <a href="{{$.PullRequestCtx.MakeDefaultCompareLink .DBBranch.Name}}?expand=1">
<button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button> <button id="new-pull-request" class="ui compact basic button tw-mr-0">{{if $.CanPull}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}</button>
</a> </a>
{{end}} {{end}}

View File

@@ -24,11 +24,12 @@
{{if .PageIsIssueList}} {{if .PageIsIssueList}}
<a class="ui small primary button issue-list-new" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a> <a class="ui small primary button issue-list-new" href="{{.RepoLink}}/issues/new{{if .NewIssueChooseTemplate}}/choose{{end}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
{{else}} {{else}}
<a class="ui small primary button new-pr-button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.Repository.Link}}/compare/{{.Repository.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a> <a class="ui small primary button new-pr-button issue-list-new {{if not .PullRequestCtx.CanCreateNewPull}}disabled{{end}}" href="{{.PullRequestCtx.MakeDefaultCompareLink .Repository.DefaultBranch}}">{{ctx.Locale.Tr "repo.pulls.new"}}</a>
{{end}} {{end}}
{{else}} {{else}}
{{/* archived, view compare page only */}}
{{if not .PageIsIssueList}} {{if not .PageIsIssueList}}
<a class="ui small primary small button issue-list-new{{if not .PullRequestCtx.Allowed}} disabled{{end}}" href="{{if .PullRequestCtx.Allowed}}{{.PullRequestCtx.BaseRepo.Link}}/compare/{{.PullRequestCtx.BaseRepo.DefaultBranch | PathEscapeSegments}}...{{if ne .Repository.Owner.Name .PullRequestCtx.BaseRepo.Owner.Name}}{{PathEscape .Repository.Owner.Name}}:{{end}}{{.Repository.DefaultBranch | PathEscapeSegments}}{{end}}">{{ctx.Locale.Tr "action.compare_commits_general"}}</a> <a class="ui small primary small button issue-list-new" href="{{.PullRequestCtx.MakeDefaultCompareLink .Repository.DefaultBranch}}">{{ctx.Locale.Tr "action.compare_commits_general"}}</a>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>

View File

@@ -9,22 +9,23 @@
{{ctx.Locale.Tr "repo.default_branch"}} {{ctx.Locale.Tr "repo.default_branch"}}
</h4> </h4>
<div class="ui attached segment"> <div class="ui attached segment">
<p> <p>{{ctx.Locale.Tr "repo.settings.default_branch_desc"}}</p>
{{ctx.Locale.Tr "repo.settings.default_branch_desc"}} <form class="ui form" action="{{.Link}}" method="post">
</p>
<form class="tw-flex" action="{{.Link}}" method="post">
<input type="hidden" name="action" value="default_branch"> <input type="hidden" name="action" value="default_branch">
<div class="ui dropdown selection search tw-flex-1 tw-mr-2 tw-max-w-96"> <div class="flex-text-block">
{{svg "octicon-triangle-down" 14 "dropdown icon"}} <div class="ui dropdown selection search tw-flex-1 tw-mr-2 tw-max-w-96">
<input type="hidden" name="branch" value="{{.Repository.DefaultBranch}}"> {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="default text">{{.Repository.DefaultBranch}}</div> <input type="hidden" name="branch" value="{{.Repository.DefaultBranch}}">
<div class="menu"> <div class="default text">{{.Repository.DefaultBranch}}</div>
{{range .Branches}} <div class="menu">
<div class="item" data-value="{{.}}">{{.}}</div> {{range .Branches}}
{{end}} <div class="item" data-value="{{.}}">{{.}}</div>
{{end}}
</div>
</div> </div>
<button class="ui primary button"{{if .Repository.IsEmpty}} disabled{{end}}>{{ctx.Locale.Tr "repo.settings.branches.update_default_branch"}}</button>
</div> </div>
<button class="ui primary button"{{if .Repository.IsEmpty}} disabled{{end}}>{{ctx.Locale.Tr "repo.settings.branches.update_default_branch"}}</button> <div class="help tw-mt-4 tw-p-0">{{ctx.Locale.Tr "repo.settings.default_target_branch_desc"}}</div>
</form> </form>
</div> </div>

View File

@@ -287,6 +287,7 @@
</div> </div>
{{end}} {{end}}
{{/* FIXME: need to split the "Advance Settings" by units, there are too many options here */}}
<h4 class="ui top attached header"> <h4 class="ui top attached header">
{{ctx.Locale.Tr "repo.settings.advanced_settings"}} {{ctx.Locale.Tr "repo.settings.advanced_settings"}}
</h4> </h4>
@@ -594,6 +595,20 @@
</div> </div>
</div> </div>
</div> </div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch"}}</label>
<div class="ui search selection dropdown">
<input type="hidden" name="default_target_branch" value="{{$prUnit.PullRequestsConfig.DefaultTargetBranch}}">
<div class="default text"></div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<div class="item" data-value="">{{ctx.Locale.Tr "repo.settings.pulls.default_target_branch_default" $.Repository.DefaultBranch}}</div>
{{range $branchName := $.Branches}}
<div class="item" data-value="{{$branchName}}">{{$branchName}}</div>
{{end}}
</div>
</div>
</div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}> <input name="default_allow_maintainer_edit" type="checkbox" {{if or (not $pullRequestEnabled) ($prUnit.PullRequestsConfig.DefaultAllowMaintainerEdit)}}checked{{end}}>

View File

@@ -20,15 +20,10 @@
"ShowViewAllRefsEntry" true "ShowViewAllRefsEntry" true
}} }}
{{if and .CanCompareOrPull .RefFullName.IsBranch (not .Repository.IsArchived)}} {{if and .PullRequestCtx.CanCreateNewPull .RefFullName.IsBranch}}
{{$cmpBranch := ""}} {{$compareLink := .PullRequestCtx.MakeDefaultCompareLink .BranchName}}
{{if ne .Repository.ID .BaseRepo.ID}}
{{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}}
{{end}}
{{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}}
{{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}}
<a id="new-pull-request" role="button" class="ui compact basic button" href="{{QueryBuild $compareLink "expand" 1}}" <a id="new-pull-request" role="button" class="ui compact basic button" href="{{QueryBuild $compareLink "expand" 1}}"
data-tooltip-content="{{if .PullRequestCtx.Allowed}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}"> data-tooltip-content="{{ctx.Locale.Tr "repo.pulls.compare_changes"}}">
{{svg "octicon-git-pull-request"}} {{svg "octicon-git-pull-request"}}
</a> </a>
{{end}} {{end}}

View File

@@ -28147,6 +28147,10 @@
"type": "string", "type": "string",
"x-go-name": "DefaultMergeStyle" "x-go-name": "DefaultMergeStyle"
}, },
"default_target_branch": {
"type": "string",
"x-go-name": "DefaultTargetBranch"
},
"description": { "description": {
"type": "string", "type": "string",
"x-go-name": "Description" "x-go-name": "Description"