// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package release import ( "cmp" "context" "fmt" "slices" "strings" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/util" version "github.com/hashicorp/go-version" ) // GenerateReleaseNotesOptions describes how to build release notes content. type GenerateReleaseNotesOptions struct { TagName string Target string PreviousTag string } // GenerateReleaseNotesResult holds the rendered notes and the base tag used. type GenerateReleaseNotesResult struct { Content string PreviousTag string } func newErrReleaseNotesTagNotFound(tagName string) error { return util.ErrorWrapTranslatable(util.NewNotExistErrorf("tag %q not found", tagName), "repo.release.generate_notes_tag_not_found", tagName) } func newErrReleaseNotesTargetNotFound(ref string) error { return util.ErrorWrapTranslatable(util.NewNotExistErrorf("release target %q not found", ref), "repo.release.generate_notes_target_not_found", ref) } // GenerateReleaseNotes builds the markdown snippet for release notes. func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (*GenerateReleaseNotesResult, error) { tagName := strings.TrimSpace(opts.TagName) if tagName == "" { return nil, util.NewInvalidArgumentErrorf("empty target tag name for release notes") } headCommit, err := resolveHeadCommit(repo, gitRepo, tagName, opts.Target) if err != nil { return nil, err } baseSelection, err := resolveBaseTag(ctx, repo, gitRepo, headCommit, tagName, opts.PreviousTag) if err != nil { return nil, err } commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseSelection.Commit.ID.String()) if err != nil { return nil, fmt.Errorf("CommitsBetweenIDs: %w", err) } prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits) if err != nil { return nil, err } contributors, newContributors, err := collectContributors(ctx, repo.ID, prs) if err != nil { return nil, err } content := buildReleaseNotesContent(ctx, repo, tagName, baseSelection.CompareBase, prs, contributors, newContributors) return &GenerateReleaseNotesResult{ Content: content, PreviousTag: baseSelection.PreviousTag, }, nil } func resolveHeadCommit(repo *repo_model.Repository, gitRepo *git.Repository, tagName, target string) (*git.Commit, error) { ref := tagName if !gitRepo.IsTagExist(tagName) { ref = strings.TrimSpace(target) if ref == "" { ref = repo.DefaultBranch } } commit, err := gitRepo.GetCommit(ref) if err != nil { return nil, newErrReleaseNotesTargetNotFound(ref) } return commit, nil } type baseSelection struct { CompareBase string PreviousTag string Commit *git.Commit } func resolveBaseTag(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, headCommit *git.Commit, tagName, requestedBase string) (*baseSelection, error) { requestedBase = strings.TrimSpace(requestedBase) if requestedBase != "" { return buildBaseSelectionForTag(gitRepo, requestedBase) } candidate, err := autoPreviousReleaseTag(ctx, repo, tagName) if err != nil { return nil, err } if candidate != "" { return buildBaseSelectionForTag(gitRepo, candidate) } tagInfos, _, err := gitRepo.GetTagInfos(0, 0) if err != nil { return nil, fmt.Errorf("GetTagInfos: %w", err) } if previousTag, ok := findPreviousTagName(tagInfos, tagName); ok { return buildBaseSelectionForTag(gitRepo, previousTag) } initialCommit, err := findInitialCommit(headCommit) if err != nil { return nil, err } return &baseSelection{ CompareBase: initialCommit.ID.String(), PreviousTag: "", Commit: initialCommit, }, nil } func buildBaseSelectionForTag(gitRepo *git.Repository, tagName string) (*baseSelection, error) { baseCommit, err := gitRepo.GetCommit(tagName) if err != nil { return nil, newErrReleaseNotesTagNotFound(tagName) } return &baseSelection{ CompareBase: tagName, PreviousTag: tagName, Commit: baseCommit, }, nil } func autoPreviousReleaseTag(ctx context.Context, repo *repo_model.Repository, tagName string) (string, error) { currentRelease, err := repo_model.GetRelease(ctx, repo.ID, tagName) switch { case err == nil: return findPreviousPublishedReleaseTag(ctx, repo, currentRelease) case repo_model.IsErrReleaseNotExist(err): // this tag has no stored release, fall back to latest release below default: return "", fmt.Errorf("GetRelease: %w", err) } rel, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID) switch { case err == nil: if strings.EqualFold(rel.TagName, tagName) { return "", nil } return rel.TagName, nil case repo_model.IsErrReleaseNotExist(err): return "", nil default: return "", fmt.Errorf("GetLatestReleaseByRepoID: %w", err) } } func findPreviousPublishedReleaseTag(ctx context.Context, repo *repo_model.Repository, current *repo_model.Release) (string, error) { prev, err := repo_model.GetPreviousPublishedRelease(ctx, repo.ID, current) switch { case err == nil: case repo_model.IsErrReleaseNotExist(err): return "", nil default: return "", fmt.Errorf("GetPreviousPublishedRelease: %w", err) } return prev.TagName, nil } func findPreviousTagName(tags []*git.Tag, target string) (string, bool) { foundTarget := false targetVersion := parseSemanticVersion(target) for _, tag := range tags { name := strings.TrimSpace(tag.Name) if strings.EqualFold(name, target) { foundTarget = true continue } if foundTarget { if targetVersion != nil { if candidateVersion := parseSemanticVersion(name); candidateVersion != nil && candidateVersion.GreaterThan(targetVersion) { continue } } return name, true } } if len(tags) > 0 { return strings.TrimSpace(tags[0].Name), true } return "", false } func parseSemanticVersion(tag string) *version.Version { tag = strings.TrimSpace(tag) tag = strings.TrimPrefix(tag, "v") tag = strings.TrimPrefix(tag, "V") v, err := version.NewVersion(tag) if err != nil { return nil } return v } func findInitialCommit(commit *git.Commit) (*git.Commit, error) { current := commit for current.ParentCount() > 0 { parent, err := current.Parent(0) if err != nil { return nil, fmt.Errorf("Parent: %w", err) } current = parent } return current, nil } func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits []*git.Commit) ([]*issues_model.PullRequest, error) { prs := make([]*issues_model.PullRequest, 0, len(commits)) for _, commit := range commits { pr, err := issues_model.GetPullRequestByMergedCommit(ctx, repoID, commit.ID.String()) if err != nil { if issues_model.IsErrPullRequestNotExist(err) { continue } return nil, fmt.Errorf("GetPullRequestByMergedCommit: %w", err) } if err = pr.LoadIssue(ctx); err != nil { return nil, fmt.Errorf("LoadIssue: %w", err) } if err = pr.Issue.LoadAttributes(ctx); err != nil { return nil, fmt.Errorf("LoadIssueAttributes: %w", err) } prs = append(prs, pr) } slices.SortFunc(prs, func(a, b *issues_model.PullRequest) int { if cmpRes := cmp.Compare(b.MergedUnix, a.MergedUnix); cmpRes != 0 { return cmpRes } return cmp.Compare(b.Issue.Index, a.Issue.Index) }) return prs, nil } func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest) string { var builder strings.Builder builder.WriteString("## What's Changed\n") for _, pr := range prs { prURL := pr.Issue.HTMLURL(ctx) builder.WriteString(fmt.Sprintf("* %s in [#%d](%s)\n", pr.Issue.Title, pr.Issue.Index, prURL)) } builder.WriteString("\n") if len(contributors) > 0 { builder.WriteString("## Contributors\n") for _, contributor := range contributors { builder.WriteString(fmt.Sprintf("* @%s\n", contributor.Name)) } builder.WriteString("\n") } if len(newContributors) > 0 { builder.WriteString("## New Contributors\n") for _, contributor := range newContributors { prURL := contributor.Issue.HTMLURL(ctx) builder.WriteString(fmt.Sprintf("* @%s made their first contribution in [#%d](%s)\n", contributor.Issue.Poster.Name, contributor.Issue.Index, prURL)) } builder.WriteString("\n") } builder.WriteString("**Full Changelog**: ") compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName)) builder.WriteString(fmt.Sprintf("[%s...%s](%s)", baseRef, tagName, compareURL)) return builder.String() } func collectContributors(ctx context.Context, repoID int64, prs []*issues_model.PullRequest) ([]*user_model.User, []*issues_model.PullRequest, error) { contributors := make([]*user_model.User, 0, len(prs)) newContributors := make([]*issues_model.PullRequest, 0, len(prs)) seenContributors := container.Set[int64]{} seenNew := container.Set[int64]{} for _, pr := range prs { poster := pr.Issue.Poster posterID := poster.ID if posterID == 0 { // Migrated PRs may not have a linked local user (PosterID == 0). Skip them for now. continue } if !seenContributors.Contains(posterID) { contributors = append(contributors, poster) seenContributors.Add(posterID) } if seenNew.Contains(posterID) { continue } isFirst, err := isFirstContribution(ctx, repoID, posterID, pr) if err != nil { return nil, nil, err } if isFirst { seenNew.Add(posterID) newContributors = append(newContributors, pr) } } return contributors, newContributors, nil } func isFirstContribution(ctx context.Context, repoID, posterID int64, pr *issues_model.PullRequest) (bool, error) { hasMergedBefore, err := issues_model.HasMergedPullRequestInRepoBefore(ctx, repoID, posterID, pr.MergedUnix, pr.ID) if err != nil { return false, fmt.Errorf("check merged PRs for contributor: %w", err) } return !hasMergedBefore, nil }