mirror of https://github.com/go-gitea/gitea.git
feat: automatic generation of release notes
parent
de69e7f16a
commit
09f1a55739
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,45 @@ 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 {
|
||||
var tagErr release_service.ErrReleaseNotesTagNotFound
|
||||
var targetErr release_service.ErrReleaseNotesTargetNotFound
|
||||
switch {
|
||||
case release_service.IsErrReleaseNotesNoBaseTag(err):
|
||||
ctx.JSONError(ctx.Tr("repo.release.generate_notes_no_base_tag"))
|
||||
case errors.As(err, &tagErr):
|
||||
ctx.JSONError(ctx.Tr("repo.release.generate_notes_tag_not_found", tagErr.TagName))
|
||||
case errors.As(err, &targetErr):
|
||||
ctx.JSONError(ctx.Tr("repo.release.generate_notes_target_not_found", targetErr.Ref))
|
||||
default:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,352 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package release
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
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
|
||||
}
|
||||
|
||||
// IsErrReleaseNotesTagNotFound reports whether the error is ErrReleaseNotesTagNotFound.
|
||||
func IsErrReleaseNotesTagNotFound(err error) bool {
|
||||
_, ok := err.(ErrReleaseNotesTagNotFound)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// IsErrReleaseNotesNoBaseTag reports whether the error is ErrReleaseNotesNoBaseTag.
|
||||
func IsErrReleaseNotesNoBaseTag(err error) bool {
|
||||
_, ok := err.(ErrReleaseNotesNoBaseTag)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, ErrReleaseNotesTargetNotFound{Ref: 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 != "" {
|
||||
if gitRepo.IsTagExist(requestedBase) {
|
||||
baseCommit, err := gitRepo.GetCommit(requestedBase)
|
||||
if err != nil {
|
||||
return nil, ErrReleaseNotesTagNotFound{TagName: requestedBase}
|
||||
}
|
||||
return &baseSelection{
|
||||
CompareBase: requestedBase,
|
||||
PreviousTag: requestedBase,
|
||||
Commit: baseCommit,
|
||||
}, nil
|
||||
}
|
||||
return nil, ErrReleaseNotesTagNotFound{TagName: requestedBase}
|
||||
}
|
||||
|
||||
rel, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID)
|
||||
switch {
|
||||
case err == nil:
|
||||
candidate := strings.TrimSpace(rel.TagName)
|
||||
if !strings.EqualFold(candidate, tagName) {
|
||||
if gitRepo.IsTagExist(candidate) {
|
||||
baseCommit, err := gitRepo.GetCommit(candidate)
|
||||
if err != nil {
|
||||
return nil, ErrReleaseNotesTagNotFound{TagName: candidate}
|
||||
}
|
||||
return &baseSelection{
|
||||
CompareBase: candidate,
|
||||
PreviousTag: candidate,
|
||||
Commit: baseCommit,
|
||||
}, nil
|
||||
}
|
||||
return nil, ErrReleaseNotesTagNotFound{TagName: candidate}
|
||||
}
|
||||
case repo_model.IsErrReleaseNotExist(err):
|
||||
// fall back to tags below
|
||||
default:
|
||||
return nil, fmt.Errorf("GetLatestReleaseByRepoID: %w", err)
|
||||
}
|
||||
|
||||
tagInfos, _, err := gitRepo.GetTagInfos(0, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetTagInfos: %w", err)
|
||||
}
|
||||
|
||||
for _, tag := range tagInfos {
|
||||
if strings.EqualFold(tag.Name, tagName) {
|
||||
continue
|
||||
}
|
||||
baseCommit, err := gitRepo.GetCommit(tag.Name)
|
||||
if err != nil {
|
||||
return nil, ErrReleaseNotesTagNotFound{TagName: tag.Name}
|
||||
}
|
||||
return &baseSelection{
|
||||
CompareBase: tag.Name,
|
||||
PreviousTag: tag.Name,
|
||||
Commit: baseCommit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
initialCommit, err := findInitialCommit(headCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &baseSelection{
|
||||
CompareBase: initialCommit.ID.String(),
|
||||
PreviousTag: "",
|
||||
Commit: initialCommit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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) {
|
||||
seen := container.Set[int64]{}
|
||||
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 !pr.HasMerged || seen.Contains(pr.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
seen.Add(pr.ID)
|
||||
prs = append(prs, pr)
|
||||
}
|
||||
|
||||
sort.Slice(prs, func(i, j int) bool {
|
||||
if prs[i].MergedUnix != prs[j].MergedUnix {
|
||||
return prs[i].MergedUnix > prs[j].MergedUnix
|
||||
}
|
||||
return prs[i].Issue.Index > prs[j].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 {
|
||||
builder.WriteString(fmt.Sprintf("* %s in %s\n", pr.Issue.Title, pr.Issue.HTMLURL(ctx)))
|
||||
}
|
||||
|
||||
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 {
|
||||
builder.WriteString(fmt.Sprintf("* @%s made their first contribution in %s\n", contributor.Issue.Poster.Name, contributor.Issue.HTMLURL(ctx)))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
builder.WriteString("**Full Changelog**: ")
|
||||
builder.WriteString(fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName)))
|
||||
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 {
|
||||
if pr.Issue == nil || pr.Issue.Poster == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
posterID := pr.Issue.PosterID
|
||||
if posterID == 0 {
|
||||
posterID = pr.Issue.Poster.ID
|
||||
}
|
||||
if posterID == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !seenContributors.Contains(posterID) {
|
||||
contributors = append(contributors, pr.Issue.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) {
|
||||
count, err := db.GetEngine(ctx).
|
||||
Table("issue").
|
||||
Join("INNER", "pull_request", "pull_request.issue_id = issue.id").
|
||||
Where("issue.repo_id = ?", repoID).
|
||||
And("pull_request.has_merged = ?", true).
|
||||
And("issue.poster_id = ?", posterID).
|
||||
And("pull_request.id != ?", pr.ID).
|
||||
And("pull_request.merged_unix < ?", pr.MergedUnix).
|
||||
Count()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("count merged PRs for contributor: %w", err)
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package release
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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.Cleanup(func() { gitRepo.Close() })
|
||||
|
||||
mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa"
|
||||
pr := createMergedPullRequest(t, repo, mergedCommit, 5)
|
||||
|
||||
result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||
TagName: "v1.2.0",
|
||||
Target: "master",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "v1.1", result.PreviousTag)
|
||||
assert.Contains(t, result.Content, "## What's Changed")
|
||||
assert.Contains(t, result.Content, pr.Issue.Title)
|
||||
assert.Contains(t, result.Content, fmt.Sprintf("/pulls/%d", pr.Index))
|
||||
assert.Contains(t, result.Content, "## Contributors")
|
||||
assert.Contains(t, result.Content, "@user5")
|
||||
assert.Contains(t, result.Content, "## New Contributors")
|
||||
assert.Contains(t, result.Content, repo.HTMLURL(t.Context())+"/compare/v1.1...v1.2.0")
|
||||
}
|
||||
|
||||
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)
|
||||
t.Cleanup(func() { gitRepo.Close() })
|
||||
|
||||
var releases []repo_model.Release
|
||||
err = db.GetEngine(t.Context()).
|
||||
Where("repo_id=?", repo.ID).
|
||||
Asc("id").
|
||||
Find(&releases)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.GetEngine(t.Context()).
|
||||
Where("repo_id=?", repo.ID).
|
||||
Delete(new(repo_model.Release))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if len(releases) == 0 {
|
||||
return
|
||||
}
|
||||
_, err := db.GetEngine(t.Context()).Insert(&releases)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||
TagName: "v1.2.0",
|
||||
Target: "master",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1.1", result.PreviousTag)
|
||||
assert.Contains(t, result.Content, "@user5")
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
<div class="ui seven wide target">
|
||||
<div class="inline field {{if .Err_TagName}}error{{end}}">
|
||||
{{if .PageIsEditRelease}}
|
||||
<input id="tag-name" type="hidden" name="tag_name" value="{{.tag_name}}">
|
||||
<input type="hidden" name="tag_target" value="{{.tag_target}}">
|
||||
<b>{{.tag_name}}</b><span class="at">@</span><strong>{{.tag_target}}</strong>
|
||||
{{else}}
|
||||
<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">
|
||||
|
|
@ -48,6 +50,26 @@
|
|||
<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">
|
||||
<div class="tw-flex tw-flex-wrap tw-items-center tw-gap-3">
|
||||
<label for="release-previous-tag" class="tw-mb-0 tw-text-sm tw-font-semibold tw-whitespace-nowrap">{{ctx.Locale.Tr "repo.release.previous_tag"}}</label>
|
||||
<select id="release-previous-tag" name="previous_tag" class="ui selection dropdown tw-w-[16rem] tw-h-[38px] tw-min-h-[38px] 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 tw-inline-flex tw-items-center tw-justify-center tw-h-[38px] tw-min-h-[38px]"
|
||||
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>
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -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,71 @@ 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>('#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;
|
||||
}
|
||||
if (!generateUrl) {
|
||||
showErrorToast('Missing release notes endpoint');
|
||||
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().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.errorMessage || data.error || resp.statusText);
|
||||
}
|
||||
|
||||
if (previousTagSelect && 'previous_tag' in data) {
|
||||
previousTagSelect.value = data.previous_tag || '';
|
||||
previousTagSelect.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
if (data && 'content' in data) {
|
||||
applyGeneratedReleaseNotes(data.content || '');
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
button.classList.remove('loading', 'disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyGeneratedReleaseNotes(content: string) {
|
||||
const editorContainer = document.querySelector<HTMLElement>('.combo-markdown-editor');
|
||||
const textarea = editorContainer?.querySelector<HTMLTextAreaElement>('textarea[name="content"]') ??
|
||||
document.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
|
||||
|
||||
const comboEditor = getComboMarkdownEditor(editorContainer);
|
||||
if (comboEditor?.easyMDE) {
|
||||
comboEditor.easyMDE.value(content);
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = content;
|
||||
textarea.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue