diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d1fb..f1ec84a3dc9d2 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -324,12 +324,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, beforeUnix, 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/models/repo/release.go b/models/repo/release.go index 67aa390e6dc45..46d05cff9b1fa 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -322,6 +322,36 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro return rel, nil } +// GetPreviousPublishedRelease returns the most recent published release created before the provided release. +func GetPreviousPublishedRelease(ctx context.Context, repoID int64, current *Release) (*Release, error) { + cond := builder.NewCond(). + And(builder.Eq{"repo_id": repoID}). + And(builder.Eq{"is_draft": false}). + And(builder.Eq{"is_prerelease": false}). + And(builder.Eq{"is_tag": false}). + And(builder.Or( + builder.Lt{"created_unix": current.CreatedUnix}, + builder.And( + builder.Eq{"created_unix": current.CreatedUnix}, + builder.Lt{"id": current.ID}, + ), + )) + + rel := new(Release) + has, err := db.GetEngine(ctx). + Desc("created_unix", "id"). + Where(cond). + Get(rel) + if err != nil { + return nil, err + } + if !has { + return nil, ErrReleaseNotExist{0, "previous"} + } + + return rel, nil +} + type releaseMetaSearch struct { ID []int64 Rel []*Release diff --git a/models/repo/release_test.go b/models/repo/release_test.go index 01f0fb3cff78e..ab9d00959ceda 100644 --- a/models/repo/release_test.go +++ b/models/repo/release_test.go @@ -7,6 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) @@ -37,3 +38,40 @@ func Test_FindTagsByCommitIDs(t *testing.T) { assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "v1.0", rels[2].TagName) } + +func TestGetPreviousPublishedRelease(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + current := unittest.AssertExistsAndLoadBean(t, &Release{ID: 8}) + prev, err := GetPreviousPublishedRelease(t.Context(), current.RepoID, current) + assert.NoError(t, err) + assert.EqualValues(t, 7, prev.ID) +} + +func TestGetPreviousPublishedRelease_NoPublishedCandidate(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repoID := int64(1) + draft := &Release{ + RepoID: repoID, + PublisherID: 1, + TagName: "draft-prev", + LowerTagName: "draft-prev", + IsDraft: true, + CreatedUnix: timeutil.TimeStamp(2), + } + current := &Release{ + RepoID: repoID, + PublisherID: 1, + TagName: "published-current", + LowerTagName: "published-current", + CreatedUnix: timeutil.TimeStamp(3), + } + + err := InsertReleases(t.Context(), draft, current) + assert.NoError(t, err) + + _, err = GetPreviousPublishedRelease(t.Context(), repoID, current) + assert.Error(t, err) + assert.True(t, IsErrReleaseNotExist(err)) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ea69d45fa2003..b7650b7484a7a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2751,6 +2751,14 @@ 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.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.previous_tag_auto = Auto +release.generate_notes_tag_not_found = Tag "%s" does not exist in this repository. +release.generate_notes_no_base_tag = No previous tag found to generate release notes. +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/routers/web/repo/release.go b/routers/web/repo/release.go index 4ed9e0bdbde44..141bd8951f7e4 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" @@ -390,6 +391,38 @@ 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 + } + + result, err := release_service.GenerateReleaseNotes(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, release_service.GenerateReleaseNotesOptions{ + TagName: form.TagName, + Target: form.Target, + PreviousTag: form.PreviousTag, + }) + if err != nil { + if errTr := util.ErrorAsTranslatable(err); errTr != nil { + ctx.JSONError(errTr.Translate(ctx.Locale)) + } else { + log.Error("GenerateReleaseNotes: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "errorMessage": ctx.Tr("error.occurred"), + }) + } + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": result.Content, + "previous_tag": result.PreviousTag, + }) +} + // NewReleasePost response for creating a release func NewReleasePost(ctx *context.Context) { newReleaseCommon(ctx) @@ -518,11 +551,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) @@ -563,8 +598,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 8b55e4469eeb7..4ba7e5374fe63 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1406,6 +1406,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 6820521ba3de2..455886ed006d5 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)"` + Target 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 0000000000000..e4da2ef77becb --- /dev/null +++ b/services/release/notes.go @@ -0,0 +1,363 @@ +// 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 + Target string + PreviousTag string +} + +// GenerateReleaseNotesResult holds the rendered notes and the base tag used. +type GenerateReleaseNotesResult struct { + Content string + PreviousTag string +} + +// ErrReleaseNotesTagNotFound indicates a requested tag does not exist in git. +type ErrReleaseNotesTagNotFound struct { + TagName string +} + +func (err ErrReleaseNotesTagNotFound) Error() string { + return fmt.Sprintf("tag %q not found", err.TagName) +} + +func (err ErrReleaseNotesTagNotFound) Unwrap() error { + return util.ErrNotExist +} + +func newErrReleaseNotesTagNotFound(tagName string) error { + return util.ErrorWrapTranslatable(ErrReleaseNotesTagNotFound{TagName: tagName}, "repo.release.generate_notes_tag_not_found", tagName) +} + +// ErrReleaseNotesNoBaseTag indicates there is no tag to diff against. +type ErrReleaseNotesNoBaseTag struct{} + +func (err ErrReleaseNotesNoBaseTag) Error() string { + return "no previous tag found for release notes" +} + +func (err ErrReleaseNotesNoBaseTag) Unwrap() error { + return util.ErrNotExist +} + +// ErrReleaseNotesTargetNotFound indicates the release target ref cannot be resolved. +type ErrReleaseNotesTargetNotFound struct { + Ref string +} + +func (err ErrReleaseNotesTargetNotFound) Error() string { + return fmt.Sprintf("release target %q not found", err.Ref) +} + +func (err ErrReleaseNotesTargetNotFound) Unwrap() error { + return util.ErrNotExist +} + +func newErrReleaseNotesTargetNotFound(ref string) error { + return util.ErrorWrapTranslatable(ErrReleaseNotesTargetNotFound{Ref: ref}, "repo.release.generate_notes_target_not_found", ref) +} + +// GenerateReleaseNotes builds the markdown snippet for release notes. +func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (*GenerateReleaseNotesResult, error) { + tagName := strings.TrimSpace(opts.TagName) + if tagName == "" { + return nil, util.NewInvalidArgumentErrorf("empty target tag name for release notes") + } + + headCommit, err := resolveHeadCommit(repo, gitRepo, tagName, opts.Target) + if err != nil { + return nil, err + } + + baseSelection, err := resolveBaseTag(ctx, repo, gitRepo, headCommit, tagName, opts.PreviousTag) + if err != nil { + return nil, err + } + + commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseSelection.Commit.ID.String()) + if err != nil { + return nil, fmt.Errorf("CommitsBetweenIDs: %w", err) + } + + prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits) + if err != nil { + return nil, err + } + + contributors, newContributors, err := collectContributors(ctx, repo.ID, prs) + if err != nil { + return nil, err + } + + content := buildReleaseNotesContent(ctx, repo, tagName, baseSelection.CompareBase, prs, contributors, newContributors) + return &GenerateReleaseNotesResult{ + Content: content, + PreviousTag: baseSelection.PreviousTag, + }, nil +} + +func resolveHeadCommit(repo *repo_model.Repository, gitRepo *git.Repository, tagName, target string) (*git.Commit, error) { + ref := tagName + if !gitRepo.IsTagExist(tagName) { + ref = strings.TrimSpace(target) + if ref == "" { + ref = repo.DefaultBranch + } + } + + commit, err := gitRepo.GetCommit(ref) + if err != nil { + return nil, newErrReleaseNotesTargetNotFound(ref) + } + return commit, nil +} + +type baseSelection struct { + CompareBase string + PreviousTag string + Commit *git.Commit +} + +func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, headCommit *git.Commit, tagName, requestedBase string) (*baseSelection, error) { + requestedBase = strings.TrimSpace(requestedBase) + if requestedBase != "" { + return buildBaseSelectionForTag(gitRepo, requestedBase) + } + + candidate, err := autoPreviousReleaseTag(ctx, repo, tagName) + if err != nil { + return nil, err + } + if candidate != "" { + return buildBaseSelectionForTag(gitRepo, candidate) + } + + tagInfos, _, err := gitRepo.GetTagInfos(0, 0) + if err != nil { + return nil, fmt.Errorf("GetTagInfos: %w", err) + } + + if previousTag, ok := findPreviousTagName(tagInfos, tagName); ok { + return buildBaseSelectionForTag(gitRepo, previousTag) + } + + initialCommit, err := findInitialCommit(headCommit) + if err != nil { + return nil, err + } + return &baseSelection{ + CompareBase: initialCommit.ID.String(), + PreviousTag: "", + Commit: initialCommit, + }, nil +} + +func buildBaseSelectionForTag(gitRepo *git.Repository, tagName string) (*baseSelection, error) { + baseCommit, err := gitRepo.GetCommit(tagName) + if err != nil { + return nil, newErrReleaseNotesTagNotFound(tagName) + } + return &baseSelection{ + CompareBase: tagName, + PreviousTag: tagName, + Commit: baseCommit, + }, nil +} + +func autoPreviousReleaseTag(ctx context.Context, repo *repo_model.Repository, tagName string) (string, error) { + currentRelease, err := repo_model.GetRelease(ctx, repo.ID, tagName) + switch { + case err == nil: + return findPreviousPublishedReleaseTag(ctx, repo, currentRelease) + case repo_model.IsErrReleaseNotExist(err): + // this tag has no stored release, fall back to latest release below + default: + return "", fmt.Errorf("GetRelease: %w", err) + } + + rel, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) + switch { + case err == nil: + if strings.EqualFold(rel.TagName, tagName) { + return "", nil + } + return rel.TagName, nil + case repo_model.IsErrReleaseNotExist(err): + return "", nil + default: + return "", fmt.Errorf("GetLatestReleaseByRepoID: %w", err) + } +} + +func findPreviousPublishedReleaseTag(ctx context.Context, repo *repo_model.Repository, current *repo_model.Release) (string, error) { + prev, err := repo_model.GetPreviousPublishedRelease(ctx, repo.ID, current) + switch { + case err == nil: + case repo_model.IsErrReleaseNotExist(err): + return "", nil + default: + return "", fmt.Errorf("GetPreviousPublishedRelease: %w", err) + } + + return prev.TagName, nil +} + +func findPreviousTagName(tags []*git.Tag, target string) (string, bool) { + foundTarget := false + for _, tag := range tags { + name := strings.TrimSpace(tag.Name) + if strings.EqualFold(name, target) { + foundTarget = true + continue + } + if foundTarget { + return name, true + } + } + if len(tags) > 0 { + return strings.TrimSpace(tags[0].Name), true + } + return "", false +} + +func findInitialCommit(commit *git.Commit) (*git.Commit, error) { + current := commit + for current.ParentCount() > 0 { + parent, err := current.Parent(0) + if err != nil { + return nil, fmt.Errorf("Parent: %w", err) + } + current = parent + } + return current, 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)) + 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, int64(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 0000000000000..631b8e57fb39f --- /dev/null +++ b/services/release/notes_test.go @@ -0,0 +1,188 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "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/git" + "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) + + mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" + pr := createMergedPullRequest(t, repo, mergedCommit, 5) + + result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + Target: "DefaultBranch", + }) + require.NoError(t, err) + + assert.Equal(t, "v1.1", result.PreviousTag) + assert.Contains(t, result.Content, "## What's Changed") + prURL := pr.Issue.HTMLURL(t.Context()) + assert.Contains(t, result.Content, fmt.Sprintf("%s in [#%d](%s)", pr.Issue.Title, pr.Index, prURL)) + assert.Contains(t, result.Content, "## Contributors") + assert.Contains(t, result.Content, "@user5") + assert.Contains(t, result.Content, "## New Contributors") + compareURL := repo.HTMLURL(t.Context()) + "/compare/v1.1...v1.2.0" + assert.Contains(t, result.Content, fmt.Sprintf("[v1.1...v1.2.0](%s)", compareURL)) +} + +func TestGenerateReleaseNotes_NoReleaseFallsBackToTags(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) + + mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" + createMergedPullRequest(t, repo, mergedCommit, 5) + + _, err = db.GetEngine(t.Context()). + Where("repo_id=?", repo.ID). + Delete(new(repo_model.Release)) + require.NoError(t, err) + + result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ + TagName: "v1.2.0", + Target: "DefaultBranch", + }) + require.NoError(t, err) + assert.Equal(t, "v1.1", result.PreviousTag) + assert.Contains(t, result.Content, "@user5") +} + +func TestAutoPreviousReleaseTag_UsesPrevPublishedRelease(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx := t.Context() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + prev := insertTestRelease(ctx, t, repo, "auto-prev", timeutil.TimeStamp(100), releaseInsertOptions{}) + insertTestRelease(ctx, t, repo, "auto-draft", timeutil.TimeStamp(150), releaseInsertOptions{IsDraft: true}) + insertTestRelease(ctx, t, repo, "auto-pre", timeutil.TimeStamp(175), releaseInsertOptions{IsPrerelease: true}) + current := insertTestRelease(ctx, t, repo, "auto-current", timeutil.TimeStamp(200), releaseInsertOptions{}) + + candidate, err := autoPreviousReleaseTag(ctx, repo, current.TagName) + require.NoError(t, err) + assert.Equal(t, prev.TagName, candidate) +} + +func TestAutoPreviousReleaseTag_LatestReleaseFallback(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx := t.Context() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + latest := insertTestRelease(ctx, t, repo, "auto-latest", timeutil.TimeStampNow(), releaseInsertOptions{}) + + candidate, err := autoPreviousReleaseTag(ctx, repo, "missing-tag") + require.NoError(t, err) + assert.Equal(t, latest.TagName, candidate) +} + +func TestFindPreviousTagName(t *testing.T) { + tags := []*git.Tag{ + {Name: "v2.0.0"}, + {Name: "v1.1.0"}, + {Name: "v1.0.0"}, + } + + prev, ok := findPreviousTagName(tags, "v1.1.0") + require.True(t, ok) + assert.Equal(t, "v1.0.0", prev) + + prev, ok = findPreviousTagName(tags, "v9.9.9") + require.True(t, ok) + assert.Equal(t, "v2.0.0", prev) + + _, ok = findPreviousTagName([]*git.Tag{}, "v1.0.0") + assert.False(t, ok) +} + +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 +} + +type releaseInsertOptions struct { + IsDraft bool + IsPrerelease bool + IsTag bool +} + +func insertTestRelease(ctx context.Context, t *testing.T, repo *repo_model.Repository, tag string, created timeutil.TimeStamp, opts releaseInsertOptions) *repo_model.Release { + t.Helper() + lower := strings.ToLower(tag) + + release := &repo_model.Release{ + RepoID: repo.ID, + PublisherID: repo.OwnerID, + TagName: tag, + LowerTagName: lower, + Target: repo.DefaultBranch, + Title: tag, + Sha1: fmt.Sprintf("%040d", int64(created)+time.Now().UnixNano()), + IsDraft: opts.IsDraft, + IsPrerelease: opts.IsPrerelease, + IsTag: opts.IsTag, + CreatedUnix: created, + } + + _, err := db.GetEngine(ctx).Insert(release) + require.NoError(t, err) + + return release +} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 109a18fa0e2e3..61e6c7bef4d76 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -17,6 +17,8 @@
{{if .PageIsEditRelease}} + + {{.tag_name}}@{{.tag_target}} {{else}} @@ -48,6 +50,26 @@
+
+
+ + + +
+
+ {{ctx.Locale.Tr "repo.release.generate_notes_desc"}}
{{template "shared/combomarkdowneditor" (dict "MarkdownPreviewInRepo" $.Repository diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index dfff090ba9991..589289dbcf27c 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -1,3 +1,6 @@ +import {POST} from '../modules/fetch.ts'; +import {showErrorToast} from '../modules/toast.ts'; +import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts'; export function initRepoRelease() { @@ -15,6 +18,7 @@ export function initRepoReleaseNew() { if (!document.querySelector('.repository.new.release')) return; initTagNameEditor(); + initGenerateReleaseNotes(); } function initTagNameEditor() { @@ -46,3 +50,58 @@ function initTagNameEditor() { hideTargetInput(e.target as HTMLInputElement); }); } + +function initGenerateReleaseNotes() { + const button = document.querySelector('#generate-release-notes'); + if (!button) return; + + const tagNameInput = document.querySelector('#tag-name'); + const targetInput = document.querySelector("input[name='tag_target']"); + const previousTagSelect = document.querySelector('#release-previous-tag'); + const missingTagMessage = button.getAttribute('data-missing-tag-message') || 'Tag name is required'; + const generateUrl = button.getAttribute('data-generate-url'); + + button.addEventListener('click', async () => { + const tagName = tagNameInput.value.trim(); + + if (!tagName) { + showErrorToast(missingTagMessage); + tagNameInput?.focus(); + return; + } + + const form = new URLSearchParams(); + form.set('tag_name', tagName); + form.set('tag_target', targetInput.value || ''); + form.set('previous_tag', previousTagSelect.value || ''); + + button.classList.add('loading', 'disabled'); + try { + const resp = await POST(generateUrl, { + data: form, + }); + + const data = await resp.json(); + + if (!resp.ok) { + throw new Error(data.errorMessage || resp.statusText); + } + previousTagSelect.value = data.previous_tag; + previousTagSelect.dispatchEvent(new Event('change', {bubbles: true})); + + applyGeneratedReleaseNotes(data.content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showErrorToast(message); + } finally { + button.classList.remove('loading', 'disabled'); + } + }); +} + +function applyGeneratedReleaseNotes(content: string) { + const editorContainer = document.querySelector('.combo-markdown-editor'); + + const comboEditor = getComboMarkdownEditor(editorContainer); + comboEditor.value(content); +}