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) + } }