Skip to content

Commit 0e916c6

Browse files
Automatic generation of release notes (#35977)
Similar to GitHub, release notes can now be generated automatically. The generator is server-side and gathers the merged PRs and contributors and returns the corresponding Markdown text. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent 14911d4 commit 0e916c6

File tree

17 files changed

+629
-173
lines changed

17 files changed

+629
-173
lines changed

models/issues/pull_list.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
user_model "code.gitea.io/gitea/models/user"
1515
"code.gitea.io/gitea/modules/container"
1616
"code.gitea.io/gitea/modules/log"
17+
"code.gitea.io/gitea/modules/timeutil"
1718
"code.gitea.io/gitea/modules/util"
1819

1920
"xorm.io/builder"
@@ -324,12 +325,26 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
324325

325326
// HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo
326327
func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) {
327-
return db.GetEngine(ctx).
328+
return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0)
329+
}
330+
331+
// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit)
332+
func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID int64, beforeUnix timeutil.TimeStamp, excludePullID int64) (bool, error) {
333+
sess := db.GetEngine(ctx).
328334
Join("INNER", "pull_request", "pull_request.issue_id = issue.id").
329335
Where("repo_id=?", repoID).
330336
And("poster_id=?", posterID).
331337
And("is_pull=?", true).
332-
And("pull_request.has_merged=?", true).
338+
And("pull_request.has_merged=?", true)
339+
340+
if beforeUnix > 0 {
341+
sess.And("pull_request.merged_unix < ?", beforeUnix)
342+
}
343+
if excludePullID > 0 {
344+
sess.And("pull_request.id != ?", excludePullID)
345+
}
346+
347+
return sess.
333348
Select("issue.id").
334349
Limit(1).
335350
Get(new(Issue))

options/locale/locale_en-US.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,7 @@ commit = Commit
12941294
release = Release
12951295
releases = Releases
12961296
tag = Tag
1297+
git_tag = Git Tag
12971298
released_this = released this
12981299
tagged_this = tagged this
12991300
file.title = %s at %s
@@ -2755,6 +2756,13 @@ release.add_tag_msg = Use the title and content of release as tag message.
27552756
release.add_tag = Create Tag Only
27562757
release.releases_for = Releases for %s
27572758
release.tags_for = Tags for %s
2759+
release.notes = Release notes
2760+
release.generate_notes = Generate release notes
2761+
release.generate_notes_desc = Automatically add merged pull requests and a changelog link for this release.
2762+
release.previous_tag = Previous tag
2763+
release.generate_notes_tag_not_found = Tag "%s" does not exist in this repository.
2764+
release.generate_notes_target_not_found = The release target "%s" cannot be found.
2765+
release.generate_notes_missing_tag = Enter a tag name to generate release notes.
27582766
27592767
branch.name = Branch Name
27602768
branch.already_exists = A branch named "%s" already exists.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"chartjs-adapter-dayjs-4": "1.0.4",
2727
"chartjs-plugin-zoom": "2.2.0",
2828
"clippie": "4.1.9",
29+
"compare-versions": "6.1.1",
2930
"cropperjs": "1.6.2",
3031
"css-loader": "7.1.2",
3132
"dayjs": "1.11.19",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

routers/web/repo/release.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,32 @@ func NewRelease(ctx *context.Context) {
392392
ctx.HTML(http.StatusOK, tplReleaseNew)
393393
}
394394

395+
// GenerateReleaseNotes builds release notes content for the given tag and base.
396+
func GenerateReleaseNotes(ctx *context.Context) {
397+
form := web.GetForm(ctx).(*forms.GenerateReleaseNotesForm)
398+
399+
if ctx.HasError() {
400+
ctx.JSONError(ctx.GetErrMsg())
401+
return
402+
}
403+
404+
content, err := release_service.GenerateReleaseNotes(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, release_service.GenerateReleaseNotesOptions{
405+
TagName: form.TagName,
406+
TagTarget: form.TagTarget,
407+
PreviousTag: form.PreviousTag,
408+
})
409+
if err != nil {
410+
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
411+
ctx.JSONError(errTr.Translate(ctx.Locale))
412+
} else {
413+
ctx.ServerError("GenerateReleaseNotes", err)
414+
}
415+
return
416+
}
417+
418+
ctx.JSON(http.StatusOK, map[string]any{"content": content})
419+
}
420+
395421
// NewReleasePost response for creating a release
396422
func NewReleasePost(ctx *context.Context) {
397423
newReleaseCommon(ctx)
@@ -520,11 +546,13 @@ func NewReleasePost(ctx *context.Context) {
520546

521547
// EditRelease render release edit page
522548
func EditRelease(ctx *context.Context) {
549+
newReleaseCommon(ctx)
550+
if ctx.Written() {
551+
return
552+
}
553+
523554
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
524-
ctx.Data["PageIsReleaseList"] = true
525555
ctx.Data["PageIsEditRelease"] = true
526-
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
527-
upload.AddUploadContext(ctx, "release")
528556

529557
tagName := ctx.PathParam("*")
530558
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
@@ -565,8 +593,13 @@ func EditRelease(ctx *context.Context) {
565593
// EditReleasePost response for edit release
566594
func EditReleasePost(ctx *context.Context) {
567595
form := web.GetForm(ctx).(*forms.EditReleaseForm)
596+
597+
newReleaseCommon(ctx)
598+
if ctx.Written() {
599+
return
600+
}
601+
568602
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
569-
ctx.Data["PageIsReleaseList"] = true
570603
ctx.Data["PageIsEditRelease"] = true
571604

572605
tagName := ctx.PathParam("*")

routers/web/web.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,6 +1403,7 @@ func registerWebRoutes(m *web.Router) {
14031403
m.Group("/releases", func() {
14041404
m.Get("/new", repo.NewRelease)
14051405
m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost)
1406+
m.Post("/generate-notes", web.Bind(forms.GenerateReleaseNotesForm{}), repo.GenerateReleaseNotes)
14061407
m.Post("/delete", repo.DeleteRelease)
14071408
m.Post("/attachments", repo.UploadReleaseAttachment)
14081409
m.Post("/attachments/remove", repo.DeleteAttachment)

services/forms/repo_form.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,19 @@ func (f *NewReleaseForm) Validate(req *http.Request, errs binding.Errors) bindin
638638
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
639639
}
640640

641+
// GenerateReleaseNotesForm retrieves release notes recommendations.
642+
type GenerateReleaseNotesForm struct {
643+
TagName string `form:"tag_name" binding:"Required;GitRefName;MaxSize(255)"`
644+
TagTarget string `form:"tag_target" binding:"MaxSize(255)"`
645+
PreviousTag string `form:"previous_tag" binding:"MaxSize(255)"`
646+
}
647+
648+
// Validate validates the fields
649+
func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
650+
ctx := context.GetValidateContext(req)
651+
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
652+
}
653+
641654
// EditReleaseForm form for changing release
642655
type EditReleaseForm struct {
643656
Title string `form:"title" binding:"Required;MaxSize(255)"`

services/release/notes.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package release
5+
6+
import (
7+
"cmp"
8+
"context"
9+
"fmt"
10+
"slices"
11+
"strings"
12+
13+
issues_model "code.gitea.io/gitea/models/issues"
14+
repo_model "code.gitea.io/gitea/models/repo"
15+
user_model "code.gitea.io/gitea/models/user"
16+
"code.gitea.io/gitea/modules/container"
17+
"code.gitea.io/gitea/modules/git"
18+
"code.gitea.io/gitea/modules/util"
19+
)
20+
21+
// GenerateReleaseNotesOptions describes how to build release notes content.
22+
type GenerateReleaseNotesOptions struct {
23+
TagName string
24+
TagTarget string
25+
PreviousTag string
26+
}
27+
28+
// GenerateReleaseNotes builds the markdown snippet for release notes.
29+
func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (string, error) {
30+
headCommit, err := resolveHeadCommit(gitRepo, opts.TagName, opts.TagTarget)
31+
if err != nil {
32+
return "", err
33+
}
34+
35+
if opts.PreviousTag == "" {
36+
// no previous tag, usually due to there is no tag in the repo, use the same content as GitHub
37+
content := fmt.Sprintf("**Full Changelog**: %s/commits/tag/%s\n", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName))
38+
return content, nil
39+
}
40+
41+
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
42+
if err != nil {
43+
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
44+
}
45+
46+
commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String())
47+
if err != nil {
48+
return "", fmt.Errorf("CommitsBetweenIDs: %w", err)
49+
}
50+
51+
prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits)
52+
if err != nil {
53+
return "", err
54+
}
55+
56+
contributors, newContributors, err := collectContributors(ctx, repo.ID, prs)
57+
if err != nil {
58+
return "", err
59+
}
60+
61+
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors)
62+
return content, nil
63+
}
64+
65+
func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) {
66+
ref := tagName
67+
if !gitRepo.IsTagExist(tagName) {
68+
ref = tagTarget
69+
}
70+
71+
commit, err := gitRepo.GetCommit(ref)
72+
if err != nil {
73+
return nil, util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_target_not_found", ref)
74+
}
75+
return commit, nil
76+
}
77+
78+
func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits []*git.Commit) ([]*issues_model.PullRequest, error) {
79+
prs := make([]*issues_model.PullRequest, 0, len(commits))
80+
81+
for _, commit := range commits {
82+
pr, err := issues_model.GetPullRequestByMergedCommit(ctx, repoID, commit.ID.String())
83+
if err != nil {
84+
if issues_model.IsErrPullRequestNotExist(err) {
85+
continue
86+
}
87+
return nil, fmt.Errorf("GetPullRequestByMergedCommit: %w", err)
88+
}
89+
90+
if err = pr.LoadIssue(ctx); err != nil {
91+
return nil, fmt.Errorf("LoadIssue: %w", err)
92+
}
93+
if err = pr.Issue.LoadAttributes(ctx); err != nil {
94+
return nil, fmt.Errorf("LoadIssueAttributes: %w", err)
95+
}
96+
97+
prs = append(prs, pr)
98+
}
99+
100+
slices.SortFunc(prs, func(a, b *issues_model.PullRequest) int {
101+
if cmpRes := cmp.Compare(b.MergedUnix, a.MergedUnix); cmpRes != 0 {
102+
return cmpRes
103+
}
104+
return cmp.Compare(b.Issue.Index, a.Issue.Index)
105+
})
106+
107+
return prs, nil
108+
}
109+
110+
func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest) string {
111+
var builder strings.Builder
112+
builder.WriteString("## What's Changed\n")
113+
114+
for _, pr := range prs {
115+
prURL := pr.Issue.HTMLURL(ctx)
116+
builder.WriteString(fmt.Sprintf("* %s in [#%d](%s)\n", pr.Issue.Title, pr.Issue.Index, prURL))
117+
}
118+
119+
builder.WriteString("\n")
120+
121+
if len(contributors) > 0 {
122+
builder.WriteString("## Contributors\n")
123+
for _, contributor := range contributors {
124+
builder.WriteString(fmt.Sprintf("* @%s\n", contributor.Name))
125+
}
126+
builder.WriteString("\n")
127+
}
128+
129+
if len(newContributors) > 0 {
130+
builder.WriteString("## New Contributors\n")
131+
for _, contributor := range newContributors {
132+
prURL := contributor.Issue.HTMLURL(ctx)
133+
builder.WriteString(fmt.Sprintf("* @%s made their first contribution in [#%d](%s)\n", contributor.Issue.Poster.Name, contributor.Issue.Index, prURL))
134+
}
135+
builder.WriteString("\n")
136+
}
137+
138+
builder.WriteString("**Full Changelog**: ")
139+
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
140+
builder.WriteString(fmt.Sprintf("[%s...%s](%s)", baseRef, tagName, compareURL))
141+
builder.WriteByte('\n')
142+
return builder.String()
143+
}
144+
145+
func collectContributors(ctx context.Context, repoID int64, prs []*issues_model.PullRequest) ([]*user_model.User, []*issues_model.PullRequest, error) {
146+
contributors := make([]*user_model.User, 0, len(prs))
147+
newContributors := make([]*issues_model.PullRequest, 0, len(prs))
148+
seenContributors := container.Set[int64]{}
149+
seenNew := container.Set[int64]{}
150+
151+
for _, pr := range prs {
152+
poster := pr.Issue.Poster
153+
posterID := poster.ID
154+
155+
if posterID == 0 {
156+
// Migrated PRs may not have a linked local user (PosterID == 0). Skip them for now.
157+
continue
158+
}
159+
160+
if !seenContributors.Contains(posterID) {
161+
contributors = append(contributors, poster)
162+
seenContributors.Add(posterID)
163+
}
164+
165+
if seenNew.Contains(posterID) {
166+
continue
167+
}
168+
169+
isFirst, err := isFirstContribution(ctx, repoID, posterID, pr)
170+
if err != nil {
171+
return nil, nil, err
172+
}
173+
if isFirst {
174+
seenNew.Add(posterID)
175+
newContributors = append(newContributors, pr)
176+
}
177+
}
178+
179+
return contributors, newContributors, nil
180+
}
181+
182+
func isFirstContribution(ctx context.Context, repoID, posterID int64, pr *issues_model.PullRequest) (bool, error) {
183+
hasMergedBefore, err := issues_model.HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, pr.MergedUnix, pr.ID)
184+
if err != nil {
185+
return false, fmt.Errorf("check merged PRs for contributor: %w", err)
186+
}
187+
return !hasMergedBefore, nil
188+
}

0 commit comments

Comments
 (0)