From 0ba862cb9779a6c720f5031c4838427ddf90f86f Mon Sep 17 00:00:00 2001 From: 0xGREG <28388707+0xGREG@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:33:20 +0200 Subject: [PATCH] Add DEFAULT_TITLE_SOURCE setting for pull request title default behavior (#37465) Adds a new `DEFAULT_TITLE_SOURCE` option under `[repository.pull-request]` with three values: - `first-commit` (default): uses the oldest commit summary, current behavior since v1.26 - `auto`: normalizes branch name as title for multi-commit PRs (just like GitHub), use commit summary for single-commit PRs Closes: #37463 Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.7) Co-authored-by: wxiaoguang Co-authored-by: Giteabot Co-authored-by: Nicolas --- custom/conf/app.example.ini | 5 +++ modules/setting/repository.go | 9 +++++ routers/web/repo/compare.go | 45 ++++++++++++++++++++-- routers/web/repo/compare_test.go | 66 ++++++++++++++++++++++++-------- 4 files changed, 106 insertions(+), 19 deletions(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 97af5fa5fbd..42459571917 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1169,6 +1169,11 @@ LEVEL = Info ;; Retarget child pull requests to the parent pull request branch target on merge of parent pull request. It only works on merged PRs where the head and base branch target the same repo. ;RETARGET_CHILDREN_ON_MERGE = true ;; +;; Default source for the pull request title when opening a new PR. +;; "first-commit" uses the oldest commit's summary. +;; "auto" uses commit's summary if the PR only has one commit, normalizes the branch name if multiple commits. +;DEFAULT_TITLE_SOURCE = first-commit +;; ;; Delay mergeable check until page view or API access, for pull requests that have not been updated in the specified days when their base branches get updated. ;; Use "-1" to always check all pull requests (old behavior). Use "0" to always delay the checks. ;DELAY_CHECK_FOR_INACTIVE_DAYS = 7 diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 9195b7ee503..a8bc91c0895 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -18,6 +18,12 @@ const ( RepoCreatingPublic = "public" ) +// enumerates the values for [repository.pull-request] DEFAULT_TITLE_SOURCE +const ( + RepoPRTitleSourceFirstCommit = "first-commit" + RepoPRTitleSourceAuto = "auto" +) + // ItemsPerPage maximum items per page in forks, watchers and stars of a repo const ItemsPerPage = 40 @@ -89,6 +95,7 @@ var ( RetargetChildrenOnMerge bool DelayCheckForInactiveDays int DefaultDeleteBranchAfterMerge bool + DefaultTitleSource string } `ini:"repository.pull-request"` // Issue Setting @@ -213,6 +220,7 @@ var ( RetargetChildrenOnMerge bool DelayCheckForInactiveDays int DefaultDeleteBranchAfterMerge bool + DefaultTitleSource string }{ WorkInProgressPrefixes: []string{"WIP:", "[WIP]"}, // Same as GitHub. See @@ -229,6 +237,7 @@ var ( AddCoCommitterTrailers: true, RetargetChildrenOnMerge: true, DelayCheckForInactiveDays: 7, + DefaultTitleSource: RepoPRTitleSourceFirstCommit, }, // Issue settings diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index f37c9ef2d17..174cb1be222 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -13,6 +13,7 @@ import ( "path/filepath" "sort" "strings" + "unicode" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" @@ -349,13 +350,46 @@ func parseCompareInfo(ctx *context.Context) (*git_service.CompareInfo, error) { return &compareInfo, nil } -func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses) (title, content string) { - title = ci.HeadRef.ShortName() +// autoTitleFromBranchName humanizes a branch name into a PR title. +func autoTitleFromBranchName(name string) string { + var buf strings.Builder + var prevIsSpace bool + runes := []rune(name) + for i, r := range runes { + isSpace := unicode.IsSpace(r) + if r == '-' || r == '_' || isSpace { + if !prevIsSpace { + buf.WriteRune(' ') + } + prevIsSpace = true + continue + } + if !prevIsSpace && unicode.IsUpper(r) { + needSpace := i > 0 && unicode.IsLower(runes[i-1]) || i < len(runes)-1 && unicode.IsLower(runes[i+1]) + if needSpace { + buf.WriteRune(' ') + } + } + buf.WriteRune(unicode.ToLower(r)) + prevIsSpace = isSpace + } + out := strings.TrimSpace(buf.String()) + if out == "" { + return out + } + outRunes := []rune(out) + outRunes[0] = unicode.ToUpper(outRunes[0]) + return string(outRunes) +} - if len(commits) > 0 { +func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses, defaultTitleSource string) (title, content string) { + useFirstCommitAsTitle := len(commits) == 1 || (defaultTitleSource == setting.RepoPRTitleSourceFirstCommit && len(commits) > 0) + if useFirstCommitAsTitle { // the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one c := commits[len(commits)-1] title = strings.TrimSpace(c.UserCommit.Summary()) + } else { + title = autoTitleFromBranchName(ci.HeadRef.ShortName()) } if len(commits) == 1 { @@ -491,7 +525,10 @@ func prepareCompareDiff(ctx *context.Context, ci *git_service.CompareInfo, white ctx.Data["Commits"] = commits ctx.Data["CommitCount"] = len(commits) - ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits) + ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource) + ctx.Data["Username"] = ci.HeadRepo.OwnerName + ctx.Data["Reponame"] = ci.HeadRepo.Name + setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name) return false diff --git a/routers/web/repo/compare_test.go b/routers/web/repo/compare_test.go index 700aba8821f..63b0f287e5a 100644 --- a/routers/web/repo/compare_test.go +++ b/routers/web/repo/compare_test.go @@ -13,6 +13,7 @@ import ( issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" git_service "code.gitea.io/gitea/services/git" "code.gitea.io/gitea/services/gitdiff" @@ -61,31 +62,66 @@ func TestNewPullRequestTitleContent(t *testing.T) { } } - title, content := prepareNewPullRequestTitleContent(ci, nil) - assert.Equal(t, "head-branch", title) + // no commit + title, content := prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceAuto) + assert.Equal(t, "Head branch", title) assert.Empty(t, content) - title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-only")}) - assert.Equal(t, "title-only", title) + title, content = prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceFirstCommit) + assert.Equal(t, "Head branch", title) assert.Empty(t, content) - title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))}) - assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title) - assert.Equal(t, "…aaaaaaaaa\n", content) - - title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title\nbody")}) - assert.Equal(t, "title", title) + // single commit + title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceAuto) + assert.Equal(t, "single-commit-title", title) assert.Equal(t, "body", content) - title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")}) - assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content" - assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content) + title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceFirstCommit) + assert.Equal(t, "single-commit-title", title) + assert.Equal(t, "body", content) - title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{ + // multiple commits + commits := []*git_model.SignCommitWithStatuses{ // ordered from newest to oldest mockCommit("title2\nbody2"), mockCommit("title1\nbody1"), - }) + } + title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceAuto) + assert.Equal(t, "Head branch", title) + assert.Empty(t, content) + + title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceFirstCommit) assert.Equal(t, "title1", title) assert.Empty(t, content) + + // title string handling + title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))}, setting.RepoPRTitleSourceFirstCommit) + assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title) + assert.Equal(t, "…aaaaaaaaa\n", content) + + title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")}, setting.RepoPRTitleSourceFirstCommit) + assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content" + assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content) +} + +func TestAutoTitleFromBranchName(t *testing.T) { + cases := []struct { + branch string + want string + }{ + {"fix/the-bug", "Fix/the bug"}, + {"Already-Capitalized", "Already capitalized"}, + {"ALL-CAPS-BRANCH", "All caps branch"}, + {"FixHTMLBug", "Fix html bug"}, + {"MixedCase-Name", "Mixed case name"}, + {"fooBar-baz", "Foo bar baz"}, + {"foo/BAR", "Foo/bar"}, + {"_leading-underscore", "Leading underscore"}, + {"CamelCase", "Camel case"}, + {"foo--double-dash", "Foo double dash"}, + {"123-fix", "123 fix"}, + } + for _, c := range cases { + assert.Equal(t, c.want, autoTitleFromBranchName(c.branch), "branch: %q", c.branch) + } }