diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d..19d727ecbd 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -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" @@ -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)) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 981d9de2f8..b740a400a4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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 @@ -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. diff --git a/package.json b/package.json index 9f848ac3ce..9566e42e88 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6c00cd081..7d6b00f675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: clippie: specifier: 4.1.9 version: 4.1.9 + compare-versions: + specifier: 6.1.1 + version: 6.1.1 cropperjs: specifier: 1.6.2 version: 1.6.2 @@ -1870,6 +1873,9 @@ packages: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -5704,6 +5710,8 @@ snapshots: comment-parser@1.4.1: {} + compare-versions@6.1.1: {} + concat-map@0.0.1: {} confbox@0.1.8: {} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 33e0dc5889..1b36dc4d44 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -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) @@ -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) @@ -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("*") diff --git a/routers/web/web.go b/routers/web/web.go index 6890789321..86e51d607e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 6820521ba3..2d33d2b42b 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -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)"` diff --git a/services/release/notes.go b/services/release/notes.go new file mode 100644 index 0000000000..c9dc75af70 --- /dev/null +++ b/services/release/notes.go @@ -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 +} diff --git a/services/release/notes_test.go b/services/release/notes_test.go new file mode 100644 index 0000000000..211c364deb --- /dev/null +++ b/services/release/notes_test.go @@ -0,0 +1,97 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateReleaseNotes(t *testing.T) { + unittest.PrepareTestEnv(t) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + gitRepo, err := gitrepo.OpenRepository(t.Context(), repo) + require.NoError(t, err) + + t.Run("ChangeLogsWithPRs", func(t *testing.T) { + mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" + createMergedPullRequest(t, repo, mergedCommit, 5) + + content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + TagTarget: "DefaultBranch", + PreviousTag: "v1.1", + }) + require.NoError(t, err) + + assert.Equal(t, `## What's Changed +* Release notes test pull request in [#6](https://try.gitea.io/user2/repo1/pulls/6) + +## Contributors +* @user5 + +## New Contributors +* @user5 made their first contribution in [#6](https://try.gitea.io/user2/repo1/pulls/6) + +**Full Changelog**: [v1.1...v1.2.0](https://try.gitea.io/user2/repo1/compare/v1.1...v1.2.0) +`, content) + }) + + t.Run("NoPreviousTag", func(t *testing.T) { + content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + TagTarget: "DefaultBranch", + }) + require.NoError(t, err) + assert.Equal(t, "**Full Changelog**: https://try.gitea.io/user2/repo1/commits/tag/v1.2.0\n", content) + }) +} + +func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64) *issues_model.PullRequest { + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID}) + + issue := &issues_model.Issue{ + RepoID: repo.ID, + Repo: repo, + Poster: user, + PosterID: user.ID, + Title: "Release notes test pull request", + Content: "content", + } + + pr := &issues_model.PullRequest{ + HeadRepoID: repo.ID, + BaseRepoID: repo.ID, + HeadBranch: repo.DefaultBranch, + BaseBranch: repo.DefaultBranch, + Status: issues_model.PullRequestStatusMergeable, + Flow: issues_model.PullRequestFlowGithub, + } + + require.NoError(t, issues_model.NewPullRequest(t.Context(), repo, issue, nil, nil, pr)) + + pr.HasMerged = true + pr.MergedCommitID = mergeCommit + pr.MergedUnix = timeutil.TimeStampNow() + _, err := db.GetEngine(t.Context()). + ID(pr.ID). + Cols("has_merged", "merged_commit_id", "merged_unix"). + Update(pr) + require.NoError(t, err) + + require.NoError(t, pr.LoadIssue(t.Context())) + require.NoError(t, pr.Issue.LoadAttributes(t.Context())) + return pr +} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 109a18fa0e..44f496e73d 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -12,17 +12,25 @@ {{end}} {{template "base/alert" .}} -