From 09f1a55739489930ee977592f8257ef83770e59c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Tue, 18 Nov 2025 15:37:26 +0100 Subject: [PATCH] feat: automatic generation of release notes --- options/locale/locale_en-US.ini | 8 + routers/web/repo/release.go | 40 ++++ routers/web/web.go | 1 + services/forms/repo_form.go | 13 + services/release/notes.go | 352 ++++++++++++++++++++++++++++ services/release/notes_test.go | 119 ++++++++++ templates/repo/release/new.tmpl | 22 ++ web_src/js/features/repo-release.ts | 72 ++++++ 8 files changed, 627 insertions(+) create mode 100644 services/release/notes.go create mode 100644 services/release/notes_test.go diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ea69d45fa2..b7650b7484 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 4ed9e0bdbd..ad1a0a49b4 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,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) diff --git a/routers/web/web.go b/routers/web/web.go index 8b55e4469e..4ba7e5374f 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 6820521ba3..455886ed00 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 0000000000..eacfd2cff9 --- /dev/null +++ b/services/release/notes.go @@ -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 +} diff --git a/services/release/notes_test.go b/services/release/notes_test.go new file mode 100644 index 0000000000..d2f6012296 --- /dev/null +++ b/services/release/notes_test.go @@ -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 +} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 109a18fa0e..61e6c7bef4 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 dfff090ba9..4a6a37a3e4 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,71 @@ 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; + } + 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('.combo-markdown-editor'); + const textarea = editorContainer?.querySelector('textarea[name="content"]') ?? + document.querySelector('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})); + } +}