Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4fce573
feat: automatic generation of release notes
dawidgora Nov 18, 2025
9c9f348
improve links generation
dawidgora Nov 18, 2025
8da1948
apply CR suggestions from @silverwind
dawidgora Nov 19, 2025
61afa90
update previous adjustment
dawidgora Nov 19, 2025
5c99e40
apply @wxiaoguang suggestion
dawidgora Nov 19, 2025
9fbb2b9
remove unused method
dawidgora Nov 19, 2025
6b93144
skip contributors with posterID = 0
dawidgora Nov 19, 2025
9cee5e1
adjustments for CR
dawidgora Nov 19, 2025
bf46d53
improvement: detect previous published release for notes base
dawidgora Nov 19, 2025
9604e6b
make new logic less defensive
dawidgora Nov 19, 2025
1d5e8ea
load shared data on edit (without it, tags list was empty on release …
dawidgora Nov 19, 2025
498646e
cleanup changes
dawidgora Nov 19, 2025
89d93a8
remove unnecessary check
dawidgora Nov 19, 2025
ffcc2c4
Update web_src/js/features/repo-release.ts
silverwind Nov 20, 2025
86e0035
revert previous commit
silverwind Nov 20, 2025
6f73691
Merge branch 'main' into feat/automatic-generation-of-release-notes
dawidgora Nov 25, 2025
0cebfd6
Merge branch 'main' into feat/automatic-generation-of-release-notes
dawidgora Nov 27, 2025
0546bf5
proposal to https://github.com/go-gitea/gitea/pull/35977#discussion_r…
dawidgora Nov 27, 2025
dbe3419
fix fmt
dawidgora Nov 27, 2025
c19ea2b
Merge branch 'main' into feat/automatic-generation-of-release-notes
wxiaoguang Nov 28, 2025
cf11756
improve
wxiaoguang Nov 28, 2025
d368900
fix dropdown
wxiaoguang Nov 28, 2025
355c649
fix broken UI
wxiaoguang Nov 28, 2025
8a48faf
Merge branch 'main' into feat/automatic-generation-of-release-notes
dawidgora Dec 1, 2025
f45bfb6
Merge remote-tracking branch 'origin/main' into feat/automatic-genera…
dawidgora Dec 5, 2025
614cff0
adjustments after merge
dawidgora Dec 5, 2025
7444449
Merge branch 'main' into feat/automatic-generation-of-release-notes
wxiaoguang Dec 13, 2025
e8b379a
fix
wxiaoguang Dec 13, 2025
ab82186
Merge branch 'main' into feat-automatic-generation-of-release-notes
wxiaoguang Dec 16, 2025
47d9c5e
fix
wxiaoguang Dec 16, 2025
6e45fbb
Merge branch 'main' into feat/automatic-generation-of-release-notes
wxiaoguang Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions models/issues/pull_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"

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

// HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo
func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) {
return db.GetEngine(ctx).
return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0)
}

// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit)
func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID int64, beforeUnix timeutil.TimeStamp, excludePullID int64) (bool, error) {
sess := db.GetEngine(ctx).
Join("INNER", "pull_request", "pull_request.issue_id = issue.id").
Where("repo_id=?", repoID).
And("poster_id=?", posterID).
And("is_pull=?", true).
And("pull_request.has_merged=?", true).
And("pull_request.has_merged=?", true)

if beforeUnix > 0 {
sess.And("pull_request.merged_unix < ?", beforeUnix)
}
if excludePullID > 0 {
sess.And("pull_request.id != ?", excludePullID)
}

return sess.
Select("issue.id").
Limit(1).
Get(new(Issue))
Expand Down
8 changes: 8 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ commit = Commit
release = Release
releases = Releases
tag = Tag
git_tag = Git Tag
released_this = released this
tagged_this = tagged this
file.title = %s at %s
Expand Down Expand Up @@ -2755,6 +2756,13 @@ release.add_tag_msg = Use the title and content of release as tag message.
release.add_tag = Create Tag Only
release.releases_for = Releases for %s
release.tags_for = Tags for %s
release.notes = Release notes
release.generate_notes = Generate release notes
release.generate_notes_desc = Automatically add merged pull requests and a changelog link for this release.
release.previous_tag = Previous tag
release.generate_notes_tag_not_found = Tag "%s" does not exist in this repository.
release.generate_notes_target_not_found = The release target "%s" cannot be found.
release.generate_notes_missing_tag = Enter a tag name to generate release notes.
branch.name = Branch Name
branch.already_exists = A branch named "%s" already exists.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.2.0",
"clippie": "4.1.9",
"compare-versions": "6.1.1",
"cropperjs": "1.6.2",
"css-loader": "7.1.2",
"dayjs": "1.11.19",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 37 additions & 4 deletions routers/web/repo/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,32 @@ func NewRelease(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplReleaseNew)
}

// GenerateReleaseNotes builds release notes content for the given tag and base.
func GenerateReleaseNotes(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.GenerateReleaseNotesForm)

if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg())
return
}

content, err := release_service.GenerateReleaseNotes(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, release_service.GenerateReleaseNotesOptions{
TagName: form.TagName,
TagTarget: form.TagTarget,
PreviousTag: form.PreviousTag,
})
if err != nil {
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
ctx.JSONError(errTr.Translate(ctx.Locale))
} else {
ctx.ServerError("GenerateReleaseNotes", err)
}
return
}

ctx.JSON(http.StatusOK, map[string]any{"content": content})
}

// NewReleasePost response for creating a release
func NewReleasePost(ctx *context.Context) {
newReleaseCommon(ctx)
Expand Down Expand Up @@ -520,11 +546,13 @@ func NewReleasePost(ctx *context.Context) {

// EditRelease render release edit page
func EditRelease(ctx *context.Context) {
newReleaseCommon(ctx)
if ctx.Written() {
return
}

ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsReleaseList"] = true
ctx.Data["PageIsEditRelease"] = true
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "release")

tagName := ctx.PathParam("*")
rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName)
Expand Down Expand Up @@ -565,8 +593,13 @@ func EditRelease(ctx *context.Context) {
// EditReleasePost response for edit release
func EditReleasePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.EditReleaseForm)

newReleaseCommon(ctx)
if ctx.Written() {
return
}

ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsReleaseList"] = true
ctx.Data["PageIsEditRelease"] = true

tagName := ctx.PathParam("*")
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/releases", func() {
m.Get("/new", repo.NewRelease)
m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost)
m.Post("/generate-notes", web.Bind(forms.GenerateReleaseNotesForm{}), repo.GenerateReleaseNotes)
m.Post("/delete", repo.DeleteRelease)
m.Post("/attachments", repo.UploadReleaseAttachment)
m.Post("/attachments/remove", repo.DeleteAttachment)
Expand Down
13 changes: 13 additions & 0 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,19 @@ func (f *NewReleaseForm) Validate(req *http.Request, errs binding.Errors) bindin
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}

// GenerateReleaseNotesForm retrieves release notes recommendations.
type GenerateReleaseNotesForm struct {
TagName string `form:"tag_name" binding:"Required;GitRefName;MaxSize(255)"`
TagTarget string `form:"tag_target" binding:"MaxSize(255)"`
PreviousTag string `form:"previous_tag" binding:"MaxSize(255)"`
}

// Validate validates the fields
func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}

// EditReleaseForm form for changing release
type EditReleaseForm struct {
Title string `form:"title" binding:"Required;MaxSize(255)"`
Expand Down
188 changes: 188 additions & 0 deletions services/release/notes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package release

import (
"cmp"
"context"
"fmt"
"slices"
"strings"

issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util"
)

// GenerateReleaseNotesOptions describes how to build release notes content.
type GenerateReleaseNotesOptions struct {
TagName string
TagTarget string
PreviousTag string
}

// GenerateReleaseNotes builds the markdown snippet for release notes.
func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (string, error) {
headCommit, err := resolveHeadCommit(gitRepo, opts.TagName, opts.TagTarget)
if err != nil {
return "", err
}

if opts.PreviousTag == "" {
// no previous tag, usually due to there is no tag in the repo, use the same content as GitHub
content := fmt.Sprintf("**Full Changelog**: %s/commits/tag/%s\n", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName))
return content, nil
}

baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
if err != nil {
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
}

commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String())
if err != nil {
return "", fmt.Errorf("CommitsBetweenIDs: %w", err)
}

prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits)
if err != nil {
return "", err
}

contributors, newContributors, err := collectContributors(ctx, repo.ID, prs)
if err != nil {
return "", err
}

content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors)
return content, nil
}

func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) {
ref := tagName
if !gitRepo.IsTagExist(tagName) {
ref = tagTarget
}

commit, err := gitRepo.GetCommit(ref)
if err != nil {
return nil, util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_target_not_found", ref)
}
return commit, nil
}

func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits []*git.Commit) ([]*issues_model.PullRequest, error) {
prs := make([]*issues_model.PullRequest, 0, len(commits))

for _, commit := range commits {
pr, err := issues_model.GetPullRequestByMergedCommit(ctx, repoID, commit.ID.String())
if err != nil {
if issues_model.IsErrPullRequestNotExist(err) {
continue
}
return nil, fmt.Errorf("GetPullRequestByMergedCommit: %w", err)
}

if err = pr.LoadIssue(ctx); err != nil {
return nil, fmt.Errorf("LoadIssue: %w", err)
}
if err = pr.Issue.LoadAttributes(ctx); err != nil {
return nil, fmt.Errorf("LoadIssueAttributes: %w", err)
}

prs = append(prs, pr)
}

slices.SortFunc(prs, func(a, b *issues_model.PullRequest) int {
if cmpRes := cmp.Compare(b.MergedUnix, a.MergedUnix); cmpRes != 0 {
return cmpRes
}
return cmp.Compare(b.Issue.Index, a.Issue.Index)
})

return prs, nil
}

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 {
var builder strings.Builder
builder.WriteString("## What's Changed\n")

for _, pr := range prs {
prURL := pr.Issue.HTMLURL(ctx)
builder.WriteString(fmt.Sprintf("* %s in [#%d](%s)\n", pr.Issue.Title, pr.Issue.Index, prURL))
}

builder.WriteString("\n")

if len(contributors) > 0 {
builder.WriteString("## Contributors\n")
for _, contributor := range contributors {
builder.WriteString(fmt.Sprintf("* @%s\n", contributor.Name))
}
builder.WriteString("\n")
}

if len(newContributors) > 0 {
builder.WriteString("## New Contributors\n")
for _, contributor := range newContributors {
prURL := contributor.Issue.HTMLURL(ctx)
builder.WriteString(fmt.Sprintf("* @%s made their first contribution in [#%d](%s)\n", contributor.Issue.Poster.Name, contributor.Issue.Index, prURL))
}
builder.WriteString("\n")
}

builder.WriteString("**Full Changelog**: ")
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
builder.WriteString(fmt.Sprintf("[%s...%s](%s)", baseRef, tagName, compareURL))
builder.WriteByte('\n')
return builder.String()
}

func collectContributors(ctx context.Context, repoID int64, prs []*issues_model.PullRequest) ([]*user_model.User, []*issues_model.PullRequest, error) {
contributors := make([]*user_model.User, 0, len(prs))
newContributors := make([]*issues_model.PullRequest, 0, len(prs))
seenContributors := container.Set[int64]{}
seenNew := container.Set[int64]{}

for _, pr := range prs {
poster := pr.Issue.Poster
posterID := poster.ID

if posterID == 0 {
// Migrated PRs may not have a linked local user (PosterID == 0). Skip them for now.
continue
}

if !seenContributors.Contains(posterID) {
contributors = append(contributors, poster)
seenContributors.Add(posterID)
}

if seenNew.Contains(posterID) {
continue
}

isFirst, err := isFirstContribution(ctx, repoID, posterID, pr)
if err != nil {
return nil, nil, err
}
if isFirst {
seenNew.Add(posterID)
newContributors = append(newContributors, pr)
}
}

return contributors, newContributors, nil
}

func isFirstContribution(ctx context.Context, repoID, posterID int64, pr *issues_model.PullRequest) (bool, error) {
hasMergedBefore, err := issues_model.HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, pr.MergedUnix, pr.ID)
if err != nil {
return false, fmt.Errorf("check merged PRs for contributor: %w", err)
}
return !hasMergedBefore, nil
}
Loading