gitea/services/release/notes.go

353 lines
9.8 KiB
Go

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