From 96188a04865f39d810eb53215c47b306030d87a3 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 01/15] feat: automatic generation of release notes dummy commit fix cs fix fix remove test file --- 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 | 125 ++++++++++ templates/repo/release/new.tmpl | 22 ++ web_src/js/features/repo-release.ts | 78 ++++++ 8 files changed, 639 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 ea69d45fa2003..b7650b7484a7a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2751,6 +2751,14 @@ release.add_tag_msg = Use the title and content of release as tag message. release.add_tag = Create Tag Only release.releases_for = Releases for %s release.tags_for = Tags for %s +release.generate_notes = Generate release notes +release.generate_notes_desc = Automatically add merged pull requests and a changelog link for this release. +release.previous_tag = Previous tag +release.previous_tag_auto = Auto +release.generate_notes_tag_not_found = Tag "%s" does not exist in this repository. +release.generate_notes_no_base_tag = No previous tag found to generate release notes. +release.generate_notes_target_not_found = The release target "%s" cannot be found. +release.generate_notes_missing_tag = Enter a tag name to generate release notes. branch.name = Branch Name branch.already_exists = A branch named "%s" already exists. diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 4ed9e0bdbde44..ad1a0a49b4355 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 8b55e4469eeb7..4ba7e5374fe63 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1406,6 +1406,7 @@ func registerWebRoutes(m *web.Router) { m.Group("/releases", func() { m.Get("/new", repo.NewRelease) m.Post("/new", web.Bind(forms.NewReleaseForm{}), repo.NewReleasePost) + m.Post("/generate-notes", web.Bind(forms.GenerateReleaseNotesForm{}), repo.GenerateReleaseNotes) m.Post("/delete", repo.DeleteRelease) m.Post("/attachments", repo.UploadReleaseAttachment) m.Post("/attachments/remove", repo.DeleteAttachment) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 6820521ba3de2..455886ed006d5 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -638,6 +638,19 @@ func (f *NewReleaseForm) Validate(req *http.Request, errs binding.Errors) bindin return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// GenerateReleaseNotesForm retrieves release notes recommendations. +type GenerateReleaseNotesForm struct { + TagName string `form:"tag_name" binding:"Required;GitRefName;MaxSize(255)"` + Target string `form:"tag_target" binding:"MaxSize(255)"` + PreviousTag string `form:"previous_tag" binding:"MaxSize(255)"` +} + +// Validate validates the fields +func (f *GenerateReleaseNotesForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // EditReleaseForm form for changing release type EditReleaseForm struct { Title string `form:"title" binding:"Required;MaxSize(255)"` diff --git a/services/release/notes.go b/services/release/notes.go new file mode 100644 index 0000000000000..eacfd2cff916f --- /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 0000000000000..e72b4e8469192 --- /dev/null +++ b/services/release/notes_test.go @@ -0,0 +1,125 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package release + +import ( + "context" + "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: "DefaultBranch", + }) + 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() }) + + mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" + createMergedPullRequest(t, repo, mergedCommit, 5) + + 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 + } + ctx := context.Background() + _, err := db.GetEngine(ctx).Insert(&releases) + 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 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 +} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 109a18fa0e2e3..61e6c7bef4d76 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -17,6 +17,8 @@
{{if .PageIsEditRelease}} + + {{.tag_name}}@{{.tag_target}} {{else}} @@ -48,6 +50,26 @@
+
+
+ + + +
+
+ {{ctx.Locale.Tr "repo.release.generate_notes_desc"}}
{{template "shared/combomarkdowneditor" (dict "MarkdownPreviewInRepo" $.Repository diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index dfff090ba9991..8af774a215b7a 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,77 @@ 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, + }); + let data: any = {}; + try { + data = await resp.json(); + } catch { + data = {}; + } + 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) { + const message = error instanceof Error ? error.message : String(error); + showErrorToast(message); + } finally { + button.classList.remove('loading', 'disabled'); + } + }); +} + +function applyGeneratedReleaseNotes(content: string) { + const editorContainer = document.querySelector('.combo-markdown-editor'); + const 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})); + } +} From 36158960a1e0458b8e8694ed4764356a5dfcff7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Tue, 18 Nov 2025 18:48:52 +0100 Subject: [PATCH 02/15] improve links generation --- services/release/notes.go | 9 ++++++--- services/release/notes_test.go | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/services/release/notes.go b/services/release/notes.go index eacfd2cff916f..cb03c49686b42 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -268,7 +268,8 @@ func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, 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))) + prURL := pr.Issue.HTMLURL(ctx) + builder.WriteString(fmt.Sprintf("* %s in [#%d](%s)\n", pr.Issue.Title, pr.Issue.Index, prURL)) } builder.WriteString("\n") @@ -284,13 +285,15 @@ func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, 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))) + 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**: ") - builder.WriteString(fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))) + 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() } diff --git a/services/release/notes_test.go b/services/release/notes_test.go index e72b4e8469192..f20997ad9e266 100644 --- a/services/release/notes_test.go +++ b/services/release/notes_test.go @@ -39,12 +39,13 @@ func TestGenerateReleaseNotes(t *testing.T) { 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)) + 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") - assert.Contains(t, result.Content, repo.HTMLURL(t.Context())+"/compare/v1.1...v1.2.0") + 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) { From 58bae7e2e07d7eca9f401e51b200366df5a4fb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 09:41:33 +0100 Subject: [PATCH 03/15] apply CR suggestions from @silverwind --- web_src/js/features/comp/ComboMarkdownEditor.ts | 5 +++-- web_src/js/features/repo-release.ts | 12 ++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index afa3957e2165d..146aa25bbced4 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -378,9 +378,10 @@ export class ComboMarkdownEditor { if (this.easyMDE) { this.easyMDE.value(v); - } else { - this.textarea.value = v; } + // Always update the underlying textarea so consumers remain in sync. + this.textarea.value = v; + this.textarea.dispatchEvent(new Event('input', {bubbles: true})); this.textareaAutosize?.resizeToFit(); } diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index 8af774a215b7a..41eef8dcdb07d 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -83,12 +83,7 @@ function initGenerateReleaseNotes() { const resp = await POST(generateUrl, { data: form, }); - let data: any = {}; - try { - data = await resp.json(); - } catch { - data = {}; - } + const data = await resp.json(); if (!resp.ok) { throw new Error(data.errorMessage || data.error || resp.statusText); } @@ -115,8 +110,9 @@ function applyGeneratedReleaseNotes(content: string) { document.querySelector('textarea[name="content"]'); const comboEditor = getComboMarkdownEditor(editorContainer); - if (comboEditor?.easyMDE) { - comboEditor.easyMDE.value(content); + if (comboEditor) { + comboEditor.value(content); + return; } if (textarea) { From a053e1e3959fc341c67a99649be8b2105941ca69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 09:55:11 +0100 Subject: [PATCH 04/15] update previous adjustment --- web_src/js/features/comp/ComboMarkdownEditor.ts | 5 ++--- web_src/js/features/repo-release.ts | 12 +----------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts index 146aa25bbced4..afa3957e2165d 100644 --- a/web_src/js/features/comp/ComboMarkdownEditor.ts +++ b/web_src/js/features/comp/ComboMarkdownEditor.ts @@ -378,10 +378,9 @@ export class ComboMarkdownEditor { if (this.easyMDE) { this.easyMDE.value(v); + } else { + this.textarea.value = v; } - // Always update the underlying textarea so consumers remain in sync. - this.textarea.value = v; - this.textarea.dispatchEvent(new Event('input', {bubbles: true})); this.textareaAutosize?.resizeToFit(); } diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index 41eef8dcdb07d..8c4a4a28e8581 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -106,17 +106,7 @@ function initGenerateReleaseNotes() { 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) { - comboEditor.value(content); - return; - } - - if (textarea) { - textarea.value = content; - textarea.dispatchEvent(new Event('input', {bubbles: true})); - } + comboEditor.value(content); } From ea1ccc2026dd427ce3117a713513f0863fde4b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 10:13:49 +0100 Subject: [PATCH 05/15] apply @wxiaoguang suggestion https://github.com/go-gitea/gitea/pull/35977#discussion_r2541108502 --- services/release/notes.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/services/release/notes.go b/services/release/notes.go index cb03c49686b42..4d168a0b70822 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -304,20 +304,11 @@ func collectContributors(ctx context.Context, repoID int64, prs []*issues_model. 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 - } + poster := pr.Issue.Poster + posterID := poster.ID if !seenContributors.Contains(posterID) { - contributors = append(contributors, pr.Issue.Poster) + contributors = append(contributors, poster) seenContributors.Add(posterID) } From 0650d1c8fcdb17a1086ed676e79c157afa9ffcdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 10:17:03 +0100 Subject: [PATCH 06/15] remove unused method --- services/release/notes.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/services/release/notes.go b/services/release/notes.go index 4d168a0b70822..8498853292c9c 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -44,12 +44,6 @@ 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{} From 19f76a8eff089a2a2d7ddf7beb4212a55af157e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 11:58:45 +0100 Subject: [PATCH 07/15] skip contributors with posterID = 0 --- services/release/notes.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/release/notes.go b/services/release/notes.go index 8498853292c9c..b364b669ab52c 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -301,6 +301,11 @@ func collectContributors(ctx context.Context, repoID int64, prs []*issues_model. 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) From a65d39f9503c3046bf98d61198b64aee5a674ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 12:36:43 +0100 Subject: [PATCH 08/15] adjustments for CR --- models/issues/pull_list.go | 18 +++++++++-- routers/web/repo/release.go | 13 ++------ services/release/notes.go | 58 ++++++++++++++-------------------- services/release/notes_test.go | 9 ------ 4 files changed, 42 insertions(+), 56 deletions(-) diff --git a/models/issues/pull_list.go b/models/issues/pull_list.go index 84f9f6166d1fb..f1ec84a3dc9d2 100644 --- a/models/issues/pull_list.go +++ b/models/issues/pull_list.go @@ -324,12 +324,26 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error) // HasMergedPullRequestInRepo returns whether the user(poster) has merged pull-request in the repo func HasMergedPullRequestInRepo(ctx context.Context, repoID, posterID int64) (bool, error) { - return db.GetEngine(ctx). + return HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, 0, 0) +} + +// HasMergedPullRequestInRepoBefore returns whether the user has a merged PR before a timestamp (0 = no limit) +func HasMergedPullRequestInRepoBefore(ctx context.Context, repoID, posterID, beforeUnix, excludePullID int64) (bool, error) { + sess := db.GetEngine(ctx). Join("INNER", "pull_request", "pull_request.issue_id = issue.id"). Where("repo_id=?", repoID). And("poster_id=?", posterID). And("is_pull=?", true). - And("pull_request.has_merged=?", true). + And("pull_request.has_merged=?", true) + + if beforeUnix > 0 { + sess.And("pull_request.merged_unix < ?", beforeUnix) + } + if excludePullID > 0 { + sess.And("pull_request.id != ?", excludePullID) + } + + return sess. Select("issue.id"). Limit(1). Get(new(Issue)) diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index ad1a0a49b4355..ec27f96905c8e 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -406,16 +406,9 @@ func GenerateReleaseNotes(ctx *context.Context) { 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: + if errTr := util.ErrorAsTranslatable(err); errTr != nil { + ctx.JSONError(errTr.Translate(ctx.Locale)) + } else { log.Error("GenerateReleaseNotes: %v", err) ctx.JSON(http.StatusInternalServerError, map[string]any{ "errorMessage": ctx.Tr("error.occurred"), diff --git a/services/release/notes.go b/services/release/notes.go index b364b669ab52c..da805ae45ae9f 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -4,12 +4,12 @@ package release import ( + "cmp" "context" "fmt" - "sort" + "slices" "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" @@ -44,6 +44,10 @@ func (err ErrReleaseNotesTagNotFound) Unwrap() error { return util.ErrNotExist } +func newErrReleaseNotesTagNotFound(tagName string) error { + return util.ErrorWrapTranslatable(ErrReleaseNotesTagNotFound{TagName: tagName}, "repo.release.generate_notes_tag_not_found", tagName) +} + // ErrReleaseNotesNoBaseTag indicates there is no tag to diff against. type ErrReleaseNotesNoBaseTag struct{} @@ -55,12 +59,6 @@ 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 @@ -74,6 +72,10 @@ func (err ErrReleaseNotesTargetNotFound) Unwrap() error { return util.ErrNotExist } +func newErrReleaseNotesTargetNotFound(ref string) error { + return util.ErrorWrapTranslatable(ErrReleaseNotesTargetNotFound{Ref: ref}, "repo.release.generate_notes_target_not_found", ref) +} + // GenerateReleaseNotes builds the markdown snippet for release notes. func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (*GenerateReleaseNotesResult, error) { tagName := strings.TrimSpace(opts.TagName) @@ -124,7 +126,7 @@ func resolveHeadCommit(repo *repo_model.Repository, gitRepo *git.Repository, tag commit, err := gitRepo.GetCommit(ref) if err != nil { - return nil, ErrReleaseNotesTargetNotFound{Ref: ref} + return nil, newErrReleaseNotesTargetNotFound(ref) } return commit, nil } @@ -141,7 +143,7 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g if gitRepo.IsTagExist(requestedBase) { baseCommit, err := gitRepo.GetCommit(requestedBase) if err != nil { - return nil, ErrReleaseNotesTagNotFound{TagName: requestedBase} + return nil, newErrReleaseNotesTagNotFound(requestedBase) } return &baseSelection{ CompareBase: requestedBase, @@ -149,7 +151,7 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g Commit: baseCommit, }, nil } - return nil, ErrReleaseNotesTagNotFound{TagName: requestedBase} + return nil, newErrReleaseNotesTagNotFound(requestedBase) } rel, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) @@ -160,7 +162,7 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g if gitRepo.IsTagExist(candidate) { baseCommit, err := gitRepo.GetCommit(candidate) if err != nil { - return nil, ErrReleaseNotesTagNotFound{TagName: candidate} + return nil, newErrReleaseNotesTagNotFound(candidate) } return &baseSelection{ CompareBase: candidate, @@ -168,7 +170,7 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g Commit: baseCommit, }, nil } - return nil, ErrReleaseNotesTagNotFound{TagName: candidate} + return nil, newErrReleaseNotesTagNotFound(candidate) } case repo_model.IsErrReleaseNotExist(err): // fall back to tags below @@ -187,7 +189,7 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g } baseCommit, err := gitRepo.GetCommit(tag.Name) if err != nil { - return nil, ErrReleaseNotesTagNotFound{TagName: tag.Name} + return nil, newErrReleaseNotesTagNotFound(tag.Name) } return &baseSelection{ CompareBase: tag.Name, @@ -220,7 +222,6 @@ func findInitialCommit(commit *git.Commit) (*git.Commit, error) { } 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 { @@ -232,10 +233,6 @@ func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits [ 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) } @@ -243,15 +240,14 @@ func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits [ 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 + slices.SortFunc(prs, func(a, b *issues_model.PullRequest) int { + if cmpRes := cmp.Compare(b.MergedUnix, a.MergedUnix); cmpRes != 0 { + return cmpRes } - return prs[i].Issue.Index > prs[j].Issue.Index + return cmp.Compare(b.Issue.Index, a.Issue.Index) }) return prs, nil @@ -329,17 +325,9 @@ func collectContributors(ctx context.Context, repoID int64, prs []*issues_model. } 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() + hasMergedBefore, err := issues_model.HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, int64(pr.MergedUnix), pr.ID) if err != nil { - return false, fmt.Errorf("count merged PRs for contributor: %w", err) + return false, fmt.Errorf("check merged PRs for contributor: %w", err) } - return count == 0, nil + return !hasMergedBefore, nil } diff --git a/services/release/notes_test.go b/services/release/notes_test.go index f20997ad9e266..13b79e2da3b32 100644 --- a/services/release/notes_test.go +++ b/services/release/notes_test.go @@ -4,7 +4,6 @@ package release import ( - "context" "fmt" "testing" @@ -70,14 +69,6 @@ func TestGenerateReleaseNotes_NoReleaseFallsBackToTags(t *testing.T) { Where("repo_id=?", repo.ID). Delete(new(repo_model.Release)) require.NoError(t, err) - t.Cleanup(func() { - if len(releases) == 0 { - return - } - ctx := context.Background() - _, err := db.GetEngine(ctx).Insert(&releases) - require.NoError(t, err) - }) result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ TagName: "v1.2.0", From 9846fa88685503e8ce7aaf85097500ca1f46ddb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 13:27:41 +0100 Subject: [PATCH 09/15] improvement: detect previous published release for notes base --- models/repo/release.go | 35 ++++++++ models/repo/release_test.go | 38 +++++++++ services/release/notes.go | 144 +++++++++++++++++++++++---------- services/release/notes_test.go | 92 +++++++++++++++++++++ 4 files changed, 265 insertions(+), 44 deletions(-) diff --git a/models/repo/release.go b/models/repo/release.go index 67aa390e6dc45..30eea51c31767 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -6,6 +6,7 @@ package repo import ( "context" + "errors" "fmt" "html/template" "net/url" @@ -322,6 +323,40 @@ 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) { + if current == nil { + return nil, errors.New("current release must not be nil") + } + + cond := builder.NewCond(). + And(builder.Eq{"repo_id": repoID}). + And(builder.Eq{"is_draft": false}). + And(builder.Eq{"is_prerelease": false}). + And(builder.Eq{"is_tag": false}). + And(builder.Or( + builder.Lt{"created_unix": current.CreatedUnix}, + builder.And( + builder.Eq{"created_unix": current.CreatedUnix}, + builder.Lt{"id": current.ID}, + ), + )) + + rel := new(Release) + has, err := db.GetEngine(ctx). + Desc("created_unix", "id"). + Where(cond). + Get(rel) + if err != nil { + return nil, err + } + if !has { + return nil, ErrReleaseNotExist{0, "previous"} + } + + return rel, nil +} + type releaseMetaSearch struct { ID []int64 Rel []*Release diff --git a/models/repo/release_test.go b/models/repo/release_test.go index 01f0fb3cff78e..ab9d00959ceda 100644 --- a/models/repo/release_test.go +++ b/models/repo/release_test.go @@ -7,6 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" ) @@ -37,3 +38,40 @@ func Test_FindTagsByCommitIDs(t *testing.T) { assert.Equal(t, "delete-tag", rels[1].TagName) assert.Equal(t, "v1.0", rels[2].TagName) } + +func TestGetPreviousPublishedRelease(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + current := unittest.AssertExistsAndLoadBean(t, &Release{ID: 8}) + prev, err := GetPreviousPublishedRelease(t.Context(), current.RepoID, current) + assert.NoError(t, err) + assert.EqualValues(t, 7, prev.ID) +} + +func TestGetPreviousPublishedRelease_NoPublishedCandidate(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repoID := int64(1) + draft := &Release{ + RepoID: repoID, + PublisherID: 1, + TagName: "draft-prev", + LowerTagName: "draft-prev", + IsDraft: true, + CreatedUnix: timeutil.TimeStamp(2), + } + current := &Release{ + RepoID: repoID, + PublisherID: 1, + TagName: "published-current", + LowerTagName: "published-current", + CreatedUnix: timeutil.TimeStamp(3), + } + + err := InsertReleases(t.Context(), draft, current) + assert.NoError(t, err) + + _, err = GetPreviousPublishedRelease(t.Context(), repoID, current) + assert.Error(t, err) + assert.True(t, IsErrReleaseNotExist(err)) +} diff --git a/services/release/notes.go b/services/release/notes.go index da805ae45ae9f..b617801560c67 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -141,41 +141,17 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g requestedBase = strings.TrimSpace(requestedBase) if requestedBase != "" { if gitRepo.IsTagExist(requestedBase) { - baseCommit, err := gitRepo.GetCommit(requestedBase) - if err != nil { - return nil, newErrReleaseNotesTagNotFound(requestedBase) - } - return &baseSelection{ - CompareBase: requestedBase, - PreviousTag: requestedBase, - Commit: baseCommit, - }, nil + return buildBaseSelectionForTag(gitRepo, requestedBase) } return nil, newErrReleaseNotesTagNotFound(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, newErrReleaseNotesTagNotFound(candidate) - } - return &baseSelection{ - CompareBase: candidate, - PreviousTag: candidate, - Commit: baseCommit, - }, nil - } - return nil, newErrReleaseNotesTagNotFound(candidate) - } - case repo_model.IsErrReleaseNotExist(err): - // fall back to tags below - default: - return nil, fmt.Errorf("GetLatestReleaseByRepoID: %w", err) + candidate, err := autoPreviousReleaseTag(ctx, repo, tagName) + if err != nil { + return nil, err + } + if candidate != "" { + return buildBaseSelectionForTag(gitRepo, candidate) } tagInfos, _, err := gitRepo.GetTagInfos(0, 0) @@ -183,19 +159,8 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g 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, newErrReleaseNotesTagNotFound(tag.Name) - } - return &baseSelection{ - CompareBase: tag.Name, - PreviousTag: tag.Name, - Commit: baseCommit, - }, nil + if previousTag, ok := findPreviousTagName(tagInfos, tagName); ok { + return buildBaseSelectionForTag(gitRepo, previousTag) } initialCommit, err := findInitialCommit(headCommit) @@ -209,6 +174,97 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g }, nil } +func buildBaseSelectionForTag(gitRepo *git.Repository, tagName string) (*baseSelection, error) { + tagName = strings.TrimSpace(tagName) + if tagName == "" { + return nil, ErrReleaseNotesNoBaseTag{} + } + + 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) { + tagName = strings.TrimSpace(tagName) + if tagName == "" { + return "", nil + } + + 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: + candidate := strings.TrimSpace(rel.TagName) + if candidate == "" || strings.EqualFold(candidate, tagName) { + return "", nil + } + return candidate, 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) { + target := strings.TrimSpace(current.TagName) + 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) + } + + candidate := strings.TrimSpace(prev.TagName) + if candidate == "" || strings.EqualFold(candidate, target) { + return "", nil + } + return candidate, nil +} + +func findPreviousTagName(tags []*git.Tag, target string) (string, bool) { + target = strings.TrimSpace(target) + foundTarget := false + for _, tag := range tags { + name := strings.TrimSpace(tag.Name) + if name == "" { + continue + } + if strings.EqualFold(name, target) { + foundTarget = true + continue + } + if foundTarget { + return name, true + } + } + if !foundTarget && len(tags) > 0 { + name := strings.TrimSpace(tags[0].Name) + if name != "" { + return name, true + } + } + return "", false +} + func findInitialCommit(commit *git.Commit) (*git.Commit, error) { current := commit for current.ParentCount() > 0 { diff --git a/services/release/notes_test.go b/services/release/notes_test.go index 13b79e2da3b32..79db35dad3c2e 100644 --- a/services/release/notes_test.go +++ b/services/release/notes_test.go @@ -4,14 +4,18 @@ 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" @@ -69,6 +73,14 @@ func TestGenerateReleaseNotes_NoReleaseFallsBackToTags(t *testing.T) { Where("repo_id=?", repo.ID). Delete(new(repo_model.Release)) require.NoError(t, err) + t.Cleanup(func() { + if len(releases) == 0 { + return + } + ctx := context.Background() + _, err := db.GetEngine(ctx).Insert(&releases) + require.NoError(t, err) + }) result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ TagName: "v1.2.0", @@ -79,6 +91,52 @@ func TestGenerateReleaseNotes_NoReleaseFallsBackToTags(t *testing.T) { 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{{Name: ""}}, "v1.0.0") + assert.False(t, ok) +} + func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64) *issues_model.PullRequest { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID}) @@ -115,3 +173,37 @@ func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCom 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) + t.Cleanup(func() { + _, err := db.GetEngine(context.Background()).ID(release.ID).Delete(new(repo_model.Release)) + require.NoError(t, err) + }) + + return release +} From b701f13b8ff94e0a58c09c689ae5b95d2a1e2106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 13:58:36 +0100 Subject: [PATCH 10/15] make new logic less defensive --- services/release/notes.go | 33 +++++---------------------------- services/release/notes_test.go | 2 +- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/services/release/notes.go b/services/release/notes.go index b617801560c67..971489803eafa 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -175,11 +175,6 @@ func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *g } func buildBaseSelectionForTag(gitRepo *git.Repository, tagName string) (*baseSelection, error) { - tagName = strings.TrimSpace(tagName) - if tagName == "" { - return nil, ErrReleaseNotesNoBaseTag{} - } - baseCommit, err := gitRepo.GetCommit(tagName) if err != nil { return nil, newErrReleaseNotesTagNotFound(tagName) @@ -192,11 +187,6 @@ func buildBaseSelectionForTag(gitRepo *git.Repository, tagName string) (*baseSel } func autoPreviousReleaseTag(ctx context.Context, repo *repo_model.Repository, tagName string) (string, error) { - tagName = strings.TrimSpace(tagName) - if tagName == "" { - return "", nil - } - currentRelease, err := repo_model.GetRelease(ctx, repo.ID, tagName) switch { case err == nil: @@ -210,11 +200,10 @@ func autoPreviousReleaseTag(ctx context.Context, repo *repo_model.Repository, ta rel, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) switch { case err == nil: - candidate := strings.TrimSpace(rel.TagName) - if candidate == "" || strings.EqualFold(candidate, tagName) { + if strings.EqualFold(rel.TagName, tagName) { return "", nil } - return candidate, nil + return rel.TagName, nil case repo_model.IsErrReleaseNotExist(err): return "", nil default: @@ -223,7 +212,6 @@ func autoPreviousReleaseTag(ctx context.Context, repo *repo_model.Repository, ta } func findPreviousPublishedReleaseTag(ctx context.Context, repo *repo_model.Repository, current *repo_model.Release) (string, error) { - target := strings.TrimSpace(current.TagName) prev, err := repo_model.GetPreviousPublishedRelease(ctx, repo.ID, current) switch { case err == nil: @@ -233,21 +221,13 @@ func findPreviousPublishedReleaseTag(ctx context.Context, repo *repo_model.Repos return "", fmt.Errorf("GetPreviousPublishedRelease: %w", err) } - candidate := strings.TrimSpace(prev.TagName) - if candidate == "" || strings.EqualFold(candidate, target) { - return "", nil - } - return candidate, nil + return prev.TagName, nil } func findPreviousTagName(tags []*git.Tag, target string) (string, bool) { - target = strings.TrimSpace(target) foundTarget := false for _, tag := range tags { name := strings.TrimSpace(tag.Name) - if name == "" { - continue - } if strings.EqualFold(name, target) { foundTarget = true continue @@ -256,11 +236,8 @@ func findPreviousTagName(tags []*git.Tag, target string) (string, bool) { return name, true } } - if !foundTarget && len(tags) > 0 { - name := strings.TrimSpace(tags[0].Name) - if name != "" { - return name, true - } + if len(tags) > 0 { + return strings.TrimSpace(tags[0].Name), true } return "", false } diff --git a/services/release/notes_test.go b/services/release/notes_test.go index 79db35dad3c2e..69a1045dd01e2 100644 --- a/services/release/notes_test.go +++ b/services/release/notes_test.go @@ -133,7 +133,7 @@ func TestFindPreviousTagName(t *testing.T) { require.True(t, ok) assert.Equal(t, "v2.0.0", prev) - _, ok = findPreviousTagName([]*git.Tag{{Name: ""}}, "v1.0.0") + _, ok = findPreviousTagName([]*git.Tag{}, "v1.0.0") assert.False(t, ok) } From 38f24b64f7f9e86b2dc17ac815e30d1c4ce8e672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 14:14:20 +0100 Subject: [PATCH 11/15] load shared data on edit (without it, tags list was empty on release edit) --- routers/web/repo/release.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index ec27f96905c8e..141bd8951f7e4 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -551,11 +551,13 @@ func NewReleasePost(ctx *context.Context) { // EditRelease render release edit page func EditRelease(ctx *context.Context) { + newReleaseCommon(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") - ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsEditRelease"] = true - ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled - upload.AddUploadContext(ctx, "release") tagName := ctx.PathParam("*") rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) @@ -596,8 +598,13 @@ func EditRelease(ctx *context.Context) { // EditReleasePost response for edit release func EditReleasePost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditReleaseForm) + + newReleaseCommon(ctx) + if ctx.Written() { + return + } + ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") - ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsEditRelease"] = true tagName := ctx.PathParam("*") From 16807428f4704d0b2a09b851ded27c6fb9025883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 15:10:24 +0100 Subject: [PATCH 12/15] cleanup changes --- models/repo/release.go | 5 ----- services/release/notes_test.go | 21 --------------------- web_src/js/features/repo-release.ts | 24 +++++++++--------------- 3 files changed, 9 insertions(+), 41 deletions(-) diff --git a/models/repo/release.go b/models/repo/release.go index 30eea51c31767..46d05cff9b1fa 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -6,7 +6,6 @@ package repo import ( "context" - "errors" "fmt" "html/template" "net/url" @@ -325,10 +324,6 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro // GetPreviousPublishedRelease returns the most recent published release created before the provided release. func GetPreviousPublishedRelease(ctx context.Context, repoID int64, current *Release) (*Release, error) { - if current == nil { - return nil, errors.New("current release must not be nil") - } - cond := builder.NewCond(). And(builder.Eq{"repo_id": repoID}). And(builder.Eq{"is_draft": false}). diff --git a/services/release/notes_test.go b/services/release/notes_test.go index 69a1045dd01e2..631b8e57fb39f 100644 --- a/services/release/notes_test.go +++ b/services/release/notes_test.go @@ -29,7 +29,6 @@ func TestGenerateReleaseNotes(t *testing.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) @@ -57,30 +56,14 @@ func TestGenerateReleaseNotes_NoReleaseFallsBackToTags(t *testing.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" createMergedPullRequest(t, repo, mergedCommit, 5) - 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 - } - ctx := context.Background() - _, err := db.GetEngine(ctx).Insert(&releases) - require.NoError(t, err) - }) result, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ TagName: "v1.2.0", @@ -200,10 +183,6 @@ func insertTestRelease(ctx context.Context, t *testing.T, repo *repo_model.Repos _, err := db.GetEngine(ctx).Insert(release) require.NoError(t, err) - t.Cleanup(func() { - _, err := db.GetEngine(context.Background()).ID(release.ID).Delete(new(repo_model.Release)) - require.NoError(t, err) - }) return release } diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index 8c4a4a28e8581..7b78178157b8a 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -62,21 +62,18 @@ function initGenerateReleaseNotes() { const generateUrl = button.getAttribute('data-generate-url'); button.addEventListener('click', async () => { - const tagName = tagNameInput?.value.trim(); + 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 || ''); + form.set('tag_target', targetInput.value || ''); + form.set('previous_tag', previousTagSelect.value || ''); button.classList.add('loading', 'disabled'); try { @@ -85,16 +82,13 @@ function initGenerateReleaseNotes() { }); const data = await resp.json(); if (!resp.ok) { - throw new Error(data.errorMessage || data.error || resp.statusText); + throw new Error(data.errorMessage || 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 || ''); - } + previousTagSelect.value = data.previous_tag; + previousTagSelect.dispatchEvent(new Event('change', {bubbles: true})); + + applyGeneratedReleaseNotes(data.content); } catch (error) { const message = error instanceof Error ? error.message : String(error); showErrorToast(message); From 7eebf1799a1e1cff846caa9b6c93151b638c8217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20G=C3=B3ra?= Date: Wed, 19 Nov 2025 15:18:53 +0100 Subject: [PATCH 13/15] remove unnecessary check --- services/release/notes.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/services/release/notes.go b/services/release/notes.go index 971489803eafa..e4da2ef77becb 100644 --- a/services/release/notes.go +++ b/services/release/notes.go @@ -140,10 +140,7 @@ type baseSelection struct { 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) { - return buildBaseSelectionForTag(gitRepo, requestedBase) - } - return nil, newErrReleaseNotesTagNotFound(requestedBase) + return buildBaseSelectionForTag(gitRepo, requestedBase) } candidate, err := autoPreviousReleaseTag(ctx, repo, tagName) From cb0a90008b489a5cba8e1a3f69fe35150d35efa5 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 20 Nov 2025 17:50:35 +0100 Subject: [PATCH 14/15] Update web_src/js/features/repo-release.ts Signed-off-by: silverwind --- web_src/js/features/repo-release.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index 7b78178157b8a..2945862077a4b 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -80,11 +80,12 @@ function initGenerateReleaseNotes() { const resp = await POST(generateUrl, { data: form, }); - const data = await resp.json(); + if (!resp.ok) { throw new Error(data.errorMessage || resp.statusText); } + const data = await resp.json(); previousTagSelect.value = data.previous_tag; previousTagSelect.dispatchEvent(new Event('change', {bubbles: true})); From 4f38868d2c5720526aa0c1ccc10a7bd569697ddd Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 20 Nov 2025 23:29:05 +0100 Subject: [PATCH 15/15] revert previous commit Signed-off-by: silverwind --- web_src/js/features/repo-release.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web_src/js/features/repo-release.ts b/web_src/js/features/repo-release.ts index 2945862077a4b..589289dbcf27c 100644 --- a/web_src/js/features/repo-release.ts +++ b/web_src/js/features/repo-release.ts @@ -81,11 +81,11 @@ function initGenerateReleaseNotes() { data: form, }); + const data = await resp.json(); + if (!resp.ok) { throw new Error(data.errorMessage || resp.statusText); } - - const data = await resp.json(); previousTagSelect.value = data.previous_tag; previousTagSelect.dispatchEvent(new Event('change', {bubbles: true}));