Skip to content

Commit 0ba862c

Browse files
0xGREGsilverwindclaudewxiaoguangGiteaBot
authored
Add DEFAULT_TITLE_SOURCE setting for pull request title default behavior (go-gitea#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: go-gitea#37463 Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Giteabot <teabot@gitea.io> Co-authored-by: Nicolas <bircni@icloud.com>
1 parent deec2b0 commit 0ba862c

4 files changed

Lines changed: 106 additions & 19 deletions

File tree

custom/conf/app.example.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,11 @@ LEVEL = Info
11691169
;; 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.
11701170
;RETARGET_CHILDREN_ON_MERGE = true
11711171
;;
1172+
;; Default source for the pull request title when opening a new PR.
1173+
;; "first-commit" uses the oldest commit's summary.
1174+
;; "auto" uses commit's summary if the PR only has one commit, normalizes the branch name if multiple commits.
1175+
;DEFAULT_TITLE_SOURCE = first-commit
1176+
;;
11721177
;; 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.
11731178
;; Use "-1" to always check all pull requests (old behavior). Use "0" to always delay the checks.
11741179
;DELAY_CHECK_FOR_INACTIVE_DAYS = 7

modules/setting/repository.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const (
1818
RepoCreatingPublic = "public"
1919
)
2020

21+
// enumerates the values for [repository.pull-request] DEFAULT_TITLE_SOURCE
22+
const (
23+
RepoPRTitleSourceFirstCommit = "first-commit"
24+
RepoPRTitleSourceAuto = "auto"
25+
)
26+
2127
// ItemsPerPage maximum items per page in forks, watchers and stars of a repo
2228
const ItemsPerPage = 40
2329

@@ -89,6 +95,7 @@ var (
8995
RetargetChildrenOnMerge bool
9096
DelayCheckForInactiveDays int
9197
DefaultDeleteBranchAfterMerge bool
98+
DefaultTitleSource string
9299
} `ini:"repository.pull-request"`
93100

94101
// Issue Setting
@@ -213,6 +220,7 @@ var (
213220
RetargetChildrenOnMerge bool
214221
DelayCheckForInactiveDays int
215222
DefaultDeleteBranchAfterMerge bool
223+
DefaultTitleSource string
216224
}{
217225
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
218226
// Same as GitHub. See
@@ -229,6 +237,7 @@ var (
229237
AddCoCommitterTrailers: true,
230238
RetargetChildrenOnMerge: true,
231239
DelayCheckForInactiveDays: 7,
240+
DefaultTitleSource: RepoPRTitleSourceFirstCommit,
232241
},
233242

234243
// Issue settings

routers/web/repo/compare.go

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"path/filepath"
1414
"sort"
1515
"strings"
16+
"unicode"
1617

1718
"code.gitea.io/gitea/models/db"
1819
git_model "code.gitea.io/gitea/models/git"
@@ -349,13 +350,46 @@ func parseCompareInfo(ctx *context.Context) (*git_service.CompareInfo, error) {
349350
return &compareInfo, nil
350351
}
351352

352-
func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses) (title, content string) {
353-
title = ci.HeadRef.ShortName()
353+
// autoTitleFromBranchName humanizes a branch name into a PR title.
354+
func autoTitleFromBranchName(name string) string {
355+
var buf strings.Builder
356+
var prevIsSpace bool
357+
runes := []rune(name)
358+
for i, r := range runes {
359+
isSpace := unicode.IsSpace(r)
360+
if r == '-' || r == '_' || isSpace {
361+
if !prevIsSpace {
362+
buf.WriteRune(' ')
363+
}
364+
prevIsSpace = true
365+
continue
366+
}
367+
if !prevIsSpace && unicode.IsUpper(r) {
368+
needSpace := i > 0 && unicode.IsLower(runes[i-1]) || i < len(runes)-1 && unicode.IsLower(runes[i+1])
369+
if needSpace {
370+
buf.WriteRune(' ')
371+
}
372+
}
373+
buf.WriteRune(unicode.ToLower(r))
374+
prevIsSpace = isSpace
375+
}
376+
out := strings.TrimSpace(buf.String())
377+
if out == "" {
378+
return out
379+
}
380+
outRunes := []rune(out)
381+
outRunes[0] = unicode.ToUpper(outRunes[0])
382+
return string(outRunes)
383+
}
354384

355-
if len(commits) > 0 {
385+
func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses, defaultTitleSource string) (title, content string) {
386+
useFirstCommitAsTitle := len(commits) == 1 || (defaultTitleSource == setting.RepoPRTitleSourceFirstCommit && len(commits) > 0)
387+
if useFirstCommitAsTitle {
356388
// the "commits" are from "ShowPrettyFormatLogToList", which is ordered from newest to oldest, here take the oldest one
357389
c := commits[len(commits)-1]
358390
title = strings.TrimSpace(c.UserCommit.Summary())
391+
} else {
392+
title = autoTitleFromBranchName(ci.HeadRef.ShortName())
359393
}
360394

361395
if len(commits) == 1 {
@@ -491,7 +525,10 @@ func prepareCompareDiff(ctx *context.Context, ci *git_service.CompareInfo, white
491525
ctx.Data["Commits"] = commits
492526
ctx.Data["CommitCount"] = len(commits)
493527

494-
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits)
528+
ctx.Data["title"], ctx.Data["content"] = prepareNewPullRequestTitleContent(ci, commits, setting.Repository.PullRequest.DefaultTitleSource)
529+
ctx.Data["Username"] = ci.HeadRepo.OwnerName
530+
ctx.Data["Reponame"] = ci.HeadRepo.Name
531+
495532
setCompareContext(ctx, beforeCommit, headCommit, ci.HeadRepo.OwnerName, repo.Name)
496533

497534
return false

routers/web/repo/compare_test.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
issues_model "code.gitea.io/gitea/models/issues"
1414
user_model "code.gitea.io/gitea/models/user"
1515
"code.gitea.io/gitea/modules/git"
16+
"code.gitea.io/gitea/modules/setting"
1617
git_service "code.gitea.io/gitea/services/git"
1718
"code.gitea.io/gitea/services/gitdiff"
1819

@@ -61,31 +62,66 @@ func TestNewPullRequestTitleContent(t *testing.T) {
6162
}
6263
}
6364

64-
title, content := prepareNewPullRequestTitleContent(ci, nil)
65-
assert.Equal(t, "head-branch", title)
65+
// no commit
66+
title, content := prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceAuto)
67+
assert.Equal(t, "Head branch", title)
6668
assert.Empty(t, content)
6769

68-
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-only")})
69-
assert.Equal(t, "title-only", title)
70+
title, content = prepareNewPullRequestTitleContent(ci, nil, setting.RepoPRTitleSourceFirstCommit)
71+
assert.Equal(t, "Head branch", title)
7072
assert.Empty(t, content)
7173

72-
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))})
73-
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
74-
assert.Equal(t, "…aaaaaaaaa\n", content)
75-
76-
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title\nbody")})
77-
assert.Equal(t, "title", title)
74+
// single commit
75+
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceAuto)
76+
assert.Equal(t, "single-commit-title", title)
7877
assert.Equal(t, "body", content)
7978

80-
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")})
81-
assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content"
82-
assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content)
79+
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("single-commit-title\nbody")}, setting.RepoPRTitleSourceFirstCommit)
80+
assert.Equal(t, "single-commit-title", title)
81+
assert.Equal(t, "body", content)
8382

84-
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{
83+
// multiple commits
84+
commits := []*git_model.SignCommitWithStatuses{
8585
// ordered from newest to oldest
8686
mockCommit("title2\nbody2"),
8787
mockCommit("title1\nbody1"),
88-
})
88+
}
89+
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceAuto)
90+
assert.Equal(t, "Head branch", title)
91+
assert.Empty(t, content)
92+
93+
title, content = prepareNewPullRequestTitleContent(ci, commits, setting.RepoPRTitleSourceFirstCommit)
8994
assert.Equal(t, "title1", title)
9095
assert.Empty(t, content)
96+
97+
// title string handling
98+
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("title-" + strings.Repeat("a", 255))}, setting.RepoPRTitleSourceFirstCommit)
99+
assert.Equal(t, "title-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…", title)
100+
assert.Equal(t, "…aaaaaaaaa\n", content)
101+
102+
title, content = prepareNewPullRequestTitleContent(ci, []*git_model.SignCommitWithStatuses{mockCommit("a\xf0\xf0\xf0\nb\xf0\xf0\xf0")}, setting.RepoPRTitleSourceFirstCommit)
103+
assert.Equal(t, "a?", title) // FIXME: GIT-COMMIT-MESSAGE-ENCODING: "title" doesn't use the same charset converting logic as "content"
104+
assert.Equal(t, "b"+string(utf8.RuneError)+string(utf8.RuneError), content)
105+
}
106+
107+
func TestAutoTitleFromBranchName(t *testing.T) {
108+
cases := []struct {
109+
branch string
110+
want string
111+
}{
112+
{"fix/the-bug", "Fix/the bug"},
113+
{"Already-Capitalized", "Already capitalized"},
114+
{"ALL-CAPS-BRANCH", "All caps branch"},
115+
{"FixHTMLBug", "Fix html bug"},
116+
{"MixedCase-Name", "Mixed case name"},
117+
{"fooBar-baz", "Foo bar baz"},
118+
{"foo/BAR", "Foo/bar"},
119+
{"_leading-underscore", "Leading underscore"},
120+
{"CamelCase", "Camel case"},
121+
{"foo--double-dash", "Foo double dash"},
122+
{"123-fix", "123 fix"},
123+
}
124+
for _, c := range cases {
125+
assert.Equal(t, c.want, autoTitleFromBranchName(c.branch), "branch: %q", c.branch)
126+
}
91127
}

0 commit comments

Comments
 (0)