Dawid Góra 2025-12-11 12:20:43 +07:00 committed by GitHub
commit ae366e10c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 805 additions and 20 deletions

@ -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))

@ -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

@ -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))
}

@ -2755,6 +2755,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.

@ -392,6 +392,35 @@ 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 {
ctx.ServerError("GenerateReleaseNotes", err)
}
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)
@ -520,11 +549,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 +596,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("*")

@ -1401,6 +1401,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)

@ -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)"`

@ -0,0 +1,346 @@
// 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"
version "github.com/hashicorp/go-version"
)
// 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
}
func newErrReleaseNotesTagNotFound(tagName string) error {
return util.ErrorWrapTranslatable(util.NewNotExistErrorf("tag %q not found", tagName), "repo.release.generate_notes_tag_not_found", tagName)
}
func newErrReleaseNotesTargetNotFound(ref string) error {
return util.ErrorWrapTranslatable(util.NewNotExistErrorf("release target %q not found", 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
targetVersion := parseSemanticVersion(target)
for _, tag := range tags {
name := strings.TrimSpace(tag.Name)
if strings.EqualFold(name, target) {
foundTarget = true
continue
}
if foundTarget {
if targetVersion != nil {
if candidateVersion := parseSemanticVersion(name); candidateVersion != nil && candidateVersion.GreaterThan(targetVersion) {
continue
}
}
return name, true
}
}
if len(tags) > 0 {
return strings.TrimSpace(tags[0].Name), true
}
return "", false
}
func parseSemanticVersion(tag string) *version.Version {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "v")
tag = strings.TrimPrefix(tag, "V")
v, err := version.NewVersion(tag)
if err != nil {
return nil
}
return v
}
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, pr.MergedUnix, pr.ID)
if err != nil {
return false, fmt.Errorf("check merged PRs for contributor: %w", err)
}
return !hasMergedBefore, nil
}

@ -0,0 +1,216 @@
// 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)
t.Run("skips newer maintenance tags on older release line for latest release", func(t *testing.T) {
tags := []*git.Tag{
{Name: "v1.1.4"},
{Name: "v1.2.0"},
{Name: "v1.1.3"},
{Name: "v1.1.1"},
{Name: "v1.1.0"},
}
prev, ok := findPreviousTagName(tags, "v1.2.0")
require.True(t, ok)
assert.Equal(t, "v1.1.3", prev)
})
t.Run("maintenance release picks previous tag in same line", func(t *testing.T) {
tags := []*git.Tag{
{Name: "v1.1.4"},
{Name: "v1.2.0"},
{Name: "v1.1.3"},
{Name: "v1.1.1"},
{Name: "v1.1.0"},
}
prev, ok := findPreviousTagName(tags, "v1.1.4")
require.True(t, ok)
assert.Equal(t, "v1.1.3", prev)
})
}
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
}

@ -15,10 +15,14 @@
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="ui seven wide target">
<div class="inline field {{if .Err_TagName}}error{{end}}">
{{if .PageIsEditRelease}}
{{if .PageIsEditRelease}}
<input id="tag-name" type="hidden" name="tag_name" value="{{.tag_name}}">
<input type="hidden" name="tag_target" value="{{.tag_target}}">
<div class="inline field">
<b>{{.tag_name}}</b><span class="at">@</span><strong>{{.tag_target}}</strong>
{{else}}
</div>
{{else}}
<div class="inline field {{if .Err_TagName}}error{{end}}">
<input id="tag-name" name="tag_name" value="{{.tag_name}}" aria-label="{{ctx.Locale.Tr "repo.release.tag_name"}}" placeholder="{{ctx.Locale.Tr "repo.release.tag_name"}}" autofocus required maxlength="255">
<input id="tag-name-editor" type="hidden" data-existing-tags="{{JsonUtils.EncodeToString .Tags}}" data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}" data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}" data-tag-helper-existing="{{ctx.Locale.Tr "repo.release.tag_helper_existing"}}">
<div id="tag-target-selector" class="tw-inline-block">
@ -41,13 +45,31 @@
<div>
<span id="tag-helper" class="help tw-mt-2 tw-pb-0">{{ctx.Locale.Tr "repo.release.tag_helper"}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="eleven wide tw-pt-0">
<div class="field {{if .Err_Title}}error{{end}}">
<input name="title" aria-label="{{ctx.Locale.Tr "repo.release.title"}}" placeholder="{{ctx.Locale.Tr "repo.release.title"}}" value="{{.title}}" autofocus maxlength="255">
</div>
<div class="field flex-text-block">
<label class="flex-text-inline"><b>{{ctx.Locale.Tr "repo.release.previous_tag"}}</b></label>
<select name="previous_tag" class="ui selection dropdown tw-w-[16rem] max-lg:tw-w-full">
<option value="">{{ctx.Locale.Tr "repo.release.previous_tag_auto"}}</option>
{{range .Tags}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<button type="button"
class="ui button"
id="generate-release-notes"
data-generate-url="{{.RepoLink}}/releases/generate-notes"
data-missing-tag-message="{{ctx.Locale.Tr "repo.release.generate_notes_missing_tag"}}"
data-tooltip-content="{{ctx.Locale.Tr "repo.release.generate_notes_desc"}}">
{{ctx.Locale.Tr "repo.release.generate_notes"}}
</button>
</div>
<span class="help tw-mt-2 tw-mb-4 tw-block">{{ctx.Locale.Tr "repo.release.generate_notes_desc"}}</span>
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository

@ -410,6 +410,12 @@ a.label,
margin-right: .78rem; /* use the same margin as for <img> */
}
.ui.selection.dropdown {
/* Most elements (buttons, inputs, etc.) have 38px default height, so also make dropdown match. Fomantic UI default style makes it about 40px height */
min-height: 38px;
padding: 9px 45px 9px 15px;
}
.ui.selection.dropdown .menu > .item {
border-color: var(--color-secondary);
}

@ -1,20 +1,25 @@
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem, showElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoRelease() {
document.addEventListener('click', (e: Event) => {
if ((e.target as HTMLElement).matches('.remove-rel-attach')) {
const uuid = (e.target as HTMLElement).getAttribute('data-uuid');
const id = (e.target as HTMLElement).getAttribute('data-id');
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
hideElem(`#attachment-${id}`);
}
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target;
if (!(target instanceof HTMLElement) || !target.matches('.remove-rel-attach')) return;
const uuid = target.getAttribute('data-uuid')!;
const id = target.getAttribute('data-id')!;
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
hideElem(`#attachment-${id}`);
});
}
export function initRepoReleaseNew() {
if (!document.querySelector('.repository.new.release')) return;
if (!document.querySelector('.repository.new.release')) return; // FIXME: edit release page also uses this class
initTagNameEditor();
initGenerateReleaseNotes();
}
function initTagNameEditor() {
@ -46,3 +51,52 @@ function initTagNameEditor() {
hideTargetInput(e.target as HTMLInputElement);
});
}
function initGenerateReleaseNotes() {
const button = document.querySelector<HTMLButtonElement>('#generate-release-notes');
if (!button) return;
const tagNameInput = document.querySelector<HTMLInputElement>('#tag-name')!;
const targetInput = document.querySelector<HTMLInputElement>("input[name='tag_target']")!;
const previousTagSelect = document.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
const missingTagMessage = button.getAttribute('data-missing-tag-message')!;
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) {
showErrorToast(data.errorMessage || resp.statusText);
return;
}
fomanticQuery(previousTagSelect).dropdown('set selected', data.previous_tag);
applyGeneratedReleaseNotes(data.content);
} finally {
button.classList.remove('loading', 'disabled');
}
});
}
function applyGeneratedReleaseNotes(content: string) {
const editorContainer = document.querySelector<HTMLElement>('.combo-markdown-editor');
if (!editorContainer) return;
const comboEditor = getComboMarkdownEditor(editorContainer);
comboEditor.value(content);
}

@ -5,7 +5,7 @@ export function initAriaFormFieldPatch() {
for (const el of document.querySelectorAll('.ui.form .field')) {
if (el.hasAttribute('data-field-patched')) continue;
const label = el.querySelector(':scope > label');
const input = el.querySelector(':scope > input');
const input = el.querySelector(':scope > input, :scope > select');
if (!label || !input) continue;
linkLabelAndInput(label, input);
el.setAttribute('data-field-patched', 'true');