mirror of https://github.com/go-gitea/gitea.git
Browse Source
Similar to GitHub, release notes can now be generated automatically. The generator is server-side and gathers the merged PRs and contributors and returns the corresponding Markdown text. --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>pull/32557/merge
17 changed files with 629 additions and 173 deletions
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
// 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" |
||||
) |
||||
|
||||
// GenerateReleaseNotesOptions describes how to build release notes content.
|
||||
type GenerateReleaseNotesOptions struct { |
||||
TagName string |
||||
TagTarget string |
||||
PreviousTag string |
||||
} |
||||
|
||||
// GenerateReleaseNotes builds the markdown snippet for release notes.
|
||||
func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, opts GenerateReleaseNotesOptions) (string, error) { |
||||
headCommit, err := resolveHeadCommit(gitRepo, opts.TagName, opts.TagTarget) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
if opts.PreviousTag == "" { |
||||
// no previous tag, usually due to there is no tag in the repo, use the same content as GitHub
|
||||
content := fmt.Sprintf("**Full Changelog**: %s/commits/tag/%s\n", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName)) |
||||
return content, nil |
||||
} |
||||
|
||||
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag) |
||||
if err != nil { |
||||
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName) |
||||
} |
||||
|
||||
commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String()) |
||||
if err != nil { |
||||
return "", fmt.Errorf("CommitsBetweenIDs: %w", err) |
||||
} |
||||
|
||||
prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
contributors, newContributors, err := collectContributors(ctx, repo.ID, prs) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors) |
||||
return content, nil |
||||
} |
||||
|
||||
func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) { |
||||
ref := tagName |
||||
if !gitRepo.IsTagExist(tagName) { |
||||
ref = tagTarget |
||||
} |
||||
|
||||
commit, err := gitRepo.GetCommit(ref) |
||||
if err != nil { |
||||
return nil, util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_target_not_found", ref) |
||||
} |
||||
return commit, 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)) |
||||
builder.WriteByte('\n') |
||||
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 |
||||
} |
||||
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package release |
||||
|
||||
import ( |
||||
"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.Run("ChangeLogsWithPRs", func(t *testing.T) { |
||||
mergedCommit := "90c1019714259b24fb81711d4416ac0f18667dfa" |
||||
createMergedPullRequest(t, repo, mergedCommit, 5) |
||||
|
||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ |
||||
TagName: "v1.2.0", |
||||
TagTarget: "DefaultBranch", |
||||
PreviousTag: "v1.1", |
||||
}) |
||||
require.NoError(t, err) |
||||
|
||||
assert.Equal(t, `## What's Changed |
||||
* Release notes test pull request in [#6](https://try.gitea.io/user2/repo1/pulls/6)
|
||||
|
||||
## Contributors |
||||
* @user5 |
||||
|
||||
## New Contributors |
||||
* @user5 made their first contribution in [#6](https://try.gitea.io/user2/repo1/pulls/6)
|
||||
|
||||
**Full Changelog**: [v1.1...v1.2.0](https://try.gitea.io/user2/repo1/compare/v1.1...v1.2.0)
|
||||
`, content) |
||||
}) |
||||
|
||||
t.Run("NoPreviousTag", func(t *testing.T) { |
||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{ |
||||
TagName: "v1.2.0", |
||||
TagTarget: "DefaultBranch", |
||||
}) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, "**Full Changelog**: https://try.gitea.io/user2/repo1/commits/tag/v1.2.0\n", content) |
||||
}) |
||||
} |
||||
|
||||
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 |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
import {guessPreviousReleaseTag} from './repo-release.ts'; |
||||
|
||||
test('guessPreviousReleaseTag', async () => { |
||||
expect(guessPreviousReleaseTag('v0.9', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe(''); |
||||
expect(guessPreviousReleaseTag('1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2'); |
||||
expect(guessPreviousReleaseTag('rel/1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2'); |
||||
expect(guessPreviousReleaseTag('v1.3', ['rel/1.0', 'rel/1.2', 'rel/1.4', 'rel/1.6'])).toBe('rel/1.2'); |
||||
expect(guessPreviousReleaseTag('v2.0', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.6'); |
||||
}); |
||||
Loading…
Reference in new issue