feat: automatic generation of release notes

pull/35976/head
Dawid Góra 2025-11-18 15:37:26 +01:00
parent de69e7f16a
commit 09f1a55739
8 changed files with 627 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

352
services/release/notes.go Normal file
View File

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

View File

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

View File

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

View File

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