mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-17 22:09:30 +01:00
Automatic generation of release notes (#35977)
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>
This commit is contained in:
parent
14911d4293
commit
0e916c67cc
@ -14,6 +14,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
@ -324,12 +325,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 int64, beforeUnix timeutil.TimeStamp, 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))
|
||||
|
||||
@ -1294,6 +1294,7 @@ commit = Commit
|
||||
release = Release
|
||||
releases = Releases
|
||||
tag = Tag
|
||||
git_tag = Git Tag
|
||||
released_this = released this
|
||||
tagged_this = tagged this
|
||||
file.title = %s at %s
|
||||
@ -2755,6 +2756,13 @@ 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.notes = Release notes
|
||||
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.generate_notes_tag_not_found = Tag "%s" does not exist in this repository.
|
||||
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.
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"clippie": "4.1.9",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "1.6.2",
|
||||
"css-loader": "7.1.2",
|
||||
"dayjs": "1.11.19",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -86,6 +86,9 @@ importers:
|
||||
clippie:
|
||||
specifier: 4.1.9
|
||||
version: 4.1.9
|
||||
compare-versions:
|
||||
specifier: 6.1.1
|
||||
version: 6.1.1
|
||||
cropperjs:
|
||||
specifier: 1.6.2
|
||||
version: 1.6.2
|
||||
@ -1870,6 +1873,9 @@ packages:
|
||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
compare-versions@6.1.1:
|
||||
resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@ -5704,6 +5710,8 @@ snapshots:
|
||||
|
||||
comment-parser@1.4.1: {}
|
||||
|
||||
compare-versions@6.1.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
confbox@0.1.8: {}
|
||||
|
||||
@ -392,6 +392,32 @@ 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
|
||||
}
|
||||
|
||||
content, err := release_service.GenerateReleaseNotes(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, release_service.GenerateReleaseNotesOptions{
|
||||
TagName: form.TagName,
|
||||
TagTarget: form.TagTarget,
|
||||
PreviousTag: form.PreviousTag,
|
||||
})
|
||||
if err != nil {
|
||||
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
|
||||
ctx.JSONError(errTr.Translate(ctx.Locale))
|
||||
} else {
|
||||
ctx.ServerError("GenerateReleaseNotes", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, map[string]any{"content": content})
|
||||
}
|
||||
|
||||
// NewReleasePost response for creating a release
|
||||
func NewReleasePost(ctx *context.Context) {
|
||||
newReleaseCommon(ctx)
|
||||
@ -520,11 +546,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)
|
||||
@ -565,8 +593,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("*")
|
||||
|
||||
@ -1403,6 +1403,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)
|
||||
|
||||
@ -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)"`
|
||||
TagTarget 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)"`
|
||||
|
||||
188
services/release/notes.go
Normal file
188
services/release/notes.go
Normal file
@ -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
|
||||
}
|
||||
97
services/release/notes_test.go
Normal file
97
services/release/notes_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -12,17 +12,25 @@
|
||||
{{end}}
|
||||
</h2>
|
||||
{{template "base/alert" .}}
|
||||
<form class="ui form" action="{{.Link}}" method="post">
|
||||
|
||||
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
|
||||
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
|
||||
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
|
||||
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
|
||||
data-tag-helper-existing="{{ctx.Locale.Tr "repo.release.tag_helper_existing"}}"
|
||||
>
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="ui seven wide target">
|
||||
<div class="inline field {{if .Err_TagName}}error{{end}}">
|
||||
<label class="tw-block tw-mb-1"><b>{{ctx.Locale.Tr "repo.git_tag"}}</b></label>
|
||||
{{if .PageIsEditRelease}}
|
||||
<b>{{.tag_name}}</b><span class="at">@</span><strong>{{.tag_target}}</strong>
|
||||
<input type="hidden" name="tag_name" value="{{.tag_name}}">
|
||||
<input type="hidden" name="tag_target" value="{{.tag_target}}">
|
||||
<span>{{.tag_name}} @ {{.tag_target}}</span>
|
||||
{{else}}
|
||||
<input id="tag-name" name="tag_name" value="{{.tag_name}}" aria-label="{{ctx.Locale.Tr "repo.release.tag_name"}}" placeholder="{{ctx.Locale.Tr "repo.release.tag_name"}}" autofocus required maxlength="255">
|
||||
<input id="tag-name-editor" type="hidden" data-existing-tags="{{JsonUtils.EncodeToString .Tags}}" data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}" data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}" data-tag-helper-existing="{{ctx.Locale.Tr "repo.release.tag_helper_existing"}}">
|
||||
<div id="tag-target-selector" class="tw-inline-block">
|
||||
<span class="at">@</span>
|
||||
<div class="flex-text-block tw-flex-wrap">
|
||||
<input type="text" class="tw-w-auto" name="tag_name" value="{{.tag_name}}" aria-label="{{ctx.Locale.Tr "repo.release.tag_name"}}" placeholder="{{ctx.Locale.Tr "repo.release.tag_name"}}" autofocus required maxlength="255">
|
||||
<div class="tag-target-selector tw-contents">
|
||||
<span>@</span>
|
||||
<div class="ui selection dropdown">
|
||||
<input type="hidden" name="tag_target" value="{{.tag_target}}">
|
||||
{{svg "octicon-git-branch"}}
|
||||
@ -38,16 +46,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span id="tag-helper" class="help tw-mt-2 tw-pb-0">{{ctx.Locale.Tr "repo.release.tag_helper"}}</span>
|
||||
<span class="help tag-name-helper tw-pb-0">{{ctx.Locale.Tr "repo.release.tag_helper"}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="eleven wide tw-pt-0">
|
||||
|
||||
<div class="field {{if .Err_Title}}error{{end}}">
|
||||
<label><b>{{ctx.Locale.Tr "repo.release.title"}}</b></label>
|
||||
<input name="title" aria-label="{{ctx.Locale.Tr "repo.release.title"}}" placeholder="{{ctx.Locale.Tr "repo.release.title"}}" value="{{.title}}" autofocus maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label><b>{{ctx.Locale.Tr "repo.release.notes"}}</b></label>
|
||||
<button type="button" class="ui small compact button generate-release-notes"
|
||||
data-generate-url="{{.RepoLink}}/releases/generate-notes"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.release.generate_notes_desc"}}"
|
||||
data-text-missing-tag="{{ctx.Locale.Tr "repo.release.generate_notes_missing_tag"}}"
|
||||
>
|
||||
{{ctx.Locale.Tr "repo.release.generate_notes"}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
{{template "shared/combomarkdowneditor" (dict
|
||||
"MarkdownPreviewInRepo" $.Repository
|
||||
@ -58,17 +78,18 @@
|
||||
"DropzoneParentContainer" "form"
|
||||
)}}
|
||||
</div>
|
||||
|
||||
{{range .attachments}}
|
||||
<div class="field flex-text-block" id="attachment-{{.ID}}">
|
||||
<div class="flex-text-inline tw-flex-1">
|
||||
<input name="attachment-edit-{{.UUID}}" class="attachment_edit" required value="{{.Name}}">
|
||||
<div class="flex-text-block tw-flex-1">
|
||||
<input name="attachment-edit-{{.UUID}}" class="tw-max-w-[48em]" required value="{{.Name}}">
|
||||
<input name="attachment-del-{{.UUID}}" type="hidden" value="false">
|
||||
<span class="ui text grey tw-whitespace-nowrap">{{.Size | FileSize}}</span>
|
||||
<span class="text grey tw-flex-shrink-0">{{.Size | FileSize}}</span>
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber .DownloadCount)}}">
|
||||
{{svg "octicon-info"}}
|
||||
</span>
|
||||
</div>
|
||||
<a class="ui mini compact red button remove-rel-attach" data-id="{{.ID}}" data-uuid="{{.UUID}}">
|
||||
<a class="ui mini compact red button" data-global-click="onReleaseEditAttachmentDelete" data-id="{{.ID}}" data-uuid="{{.UUID}}">
|
||||
{{ctx.Locale.Tr "remove"}}
|
||||
</a>
|
||||
</div>
|
||||
@ -78,12 +99,9 @@
|
||||
{{template "repo/upload" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="ui">
|
||||
<div>
|
||||
|
||||
{{if not .PageIsEditRelease}}
|
||||
<div class="tag-message field">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="add_tag_msg">
|
||||
<label><strong>{{ctx.Locale.Tr "repo.release.add_tag_msg"}}</strong></label>
|
||||
@ -92,20 +110,23 @@
|
||||
{{else}}
|
||||
<input type="hidden" name="add_tag_msg" value="false">
|
||||
{{end}}
|
||||
<div class="prerelease field">
|
||||
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="prerelease" {{if .prerelease}}checked{{end}}>
|
||||
<label><strong>{{ctx.Locale.Tr "repo.release.prerelease_desc"}}</strong></label>
|
||||
</div>
|
||||
<div class="help tw-block tw-ml-[21px]">{{ctx.Locale.Tr "repo.release.prerelease_helper"}}</div>
|
||||
</div>
|
||||
<span class="help">{{ctx.Locale.Tr "repo.release.prerelease_helper"}}</span>
|
||||
<div class="divider tw-mt-0"></div>
|
||||
|
||||
<div class="flex-text-block tw-justify-end">
|
||||
{{if .PageIsEditRelease}}
|
||||
<a class="ui small button" href="{{.RepoLink}}/releases">
|
||||
{{ctx.Locale.Tr "repo.release.cancel"}}
|
||||
</a>
|
||||
<a class="ui small red button link-action" data-modal-confirm="#repo-release-delete-modal" data-url="{{$.RepoLink}}/releases/delete?id={{.ID}}">
|
||||
<a class="ui small red button link-action"
|
||||
data-modal-confirm-header="{{ctx.Locale.Tr "repo.release.deletion"}}" data-modal-confirm-content="{{ctx.Locale.Tr "repo.release.deletion_desc"}}"
|
||||
data-url="{{$.RepoLink}}/releases/delete?id={{.ID}}">
|
||||
{{ctx.Locale.Tr "repo.release.delete_release"}}
|
||||
</a>
|
||||
{{if .IsDraft}}
|
||||
@ -122,18 +143,21 @@
|
||||
<button class="ui small primary button">{{ctx.Locale.Tr "repo.release.publish"}}</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .PageIsEditRelease}}
|
||||
<div class="ui small modal" id="repo-release-delete-modal">
|
||||
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.release.deletion"}}</div>
|
||||
<div class="content"><p>{{ctx.Locale.Tr "repo.release.deletion_desc"}}</p></div>
|
||||
{{template "base/modal_actions_confirm" .}}
|
||||
<div id="generate-release-notes-modal" class="ui mini modal">
|
||||
<div class="content ui form">
|
||||
<p>{{ctx.Locale.Tr "repo.release.generate_notes_desc"}}</p>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "repo.release.previous_tag"}}</label>
|
||||
<select name="previous_tag" class="ui selection dropdown"></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ui primary ok button">{{ctx.Locale.Tr "repo.release.generate_notes"}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "base/footer" .}}
|
||||
|
||||
@ -95,46 +95,3 @@
|
||||
font-size: 18px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.repository.new.release .target {
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.repository.new.release .target #tag-name {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.repository.new.release .target .at {
|
||||
margin-left: -5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.repository.new.release .target .selection.dropdown {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.repository.new.release .prerelease.field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 438px) {
|
||||
.repository.new.release .field button,
|
||||
.repository.new.release .field input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.repository.new.release .field button {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.repository.new.release .field .attachment_edit {
|
||||
max-width: 48em;
|
||||
}
|
||||
|
||||
.repository.new.release .markup {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
@ -410,7 +410,7 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
}
|
||||
|
||||
export function getComboMarkdownEditor(el: any) {
|
||||
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
|
||||
if (!el) return null;
|
||||
if (el.length) el = el[0];
|
||||
return el._giteaComboMarkdownEditor;
|
||||
|
||||
@ -84,7 +84,7 @@ async function tryOnEditContent(e: Event) {
|
||||
showElem(editContentZone);
|
||||
hideElem(renderContent);
|
||||
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
|
||||
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
|
||||
if (!comboMarkdownEditor) {
|
||||
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template')!.innerHTML;
|
||||
const form = editContentZone.querySelector('form')!;
|
||||
@ -139,7 +139,7 @@ async function tryOnQuoteReply(e: Event) {
|
||||
editor = await handleReply(replyBtn);
|
||||
} else {
|
||||
// for normal issue/comment page
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
|
||||
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
|
||||
}
|
||||
|
||||
if (editor.value()) {
|
||||
|
||||
9
web_src/js/features/repo-release.test.ts
Normal file
9
web_src/js/features/repo-release.test.ts
Normal file
@ -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');
|
||||
});
|
||||
@ -1,43 +1,47 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
|
||||
export function initRepoRelease() {
|
||||
document.addEventListener('click', (e: Event) => {
|
||||
if ((e.target as HTMLElement).matches('.remove-rel-attach')) {
|
||||
const uuid = (e.target as HTMLElement).getAttribute('data-uuid');
|
||||
const id = (e.target as HTMLElement).getAttribute('data-id');
|
||||
export function initRepoReleaseNew() {
|
||||
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
|
||||
const uuid = el.getAttribute('data-uuid')!;
|
||||
const id = el.getAttribute('data-id')!;
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
}
|
||||
});
|
||||
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
|
||||
initTagNameEditor(elForm);
|
||||
initGenerateReleaseNotes(elForm);
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoReleaseNew() {
|
||||
if (!document.querySelector('.repository.new.release')) return;
|
||||
|
||||
initTagNameEditor();
|
||||
function getReleaseFormExistingTags(elForm: HTMLFormElement): Array<string> {
|
||||
return JSON.parse(elForm.getAttribute('data-existing-tags')!);
|
||||
}
|
||||
|
||||
function initTagNameEditor() {
|
||||
const el = document.querySelector('#tag-name-editor');
|
||||
if (!el) return;
|
||||
function initTagNameEditor(elForm: HTMLFormElement) {
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[type=text][name=tag_name]');
|
||||
if (!tagNameInput) return; // only init if tag name input exists (the tag name is editable)
|
||||
|
||||
const existingTags = JSON.parse(el.getAttribute('data-existing-tags')!);
|
||||
if (!Array.isArray(existingTags)) return;
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const defaultTagHelperText = elForm.getAttribute('data-tag-helper');
|
||||
const newTagHelperText = elForm.getAttribute('data-tag-helper-new');
|
||||
const existingTagHelperText = elForm.getAttribute('data-tag-helper-existing');
|
||||
|
||||
const defaultTagHelperText = el.getAttribute('data-tag-helper');
|
||||
const newTagHelperText = el.getAttribute('data-tag-helper-new');
|
||||
const existingTagHelperText = el.getAttribute('data-tag-helper-existing');
|
||||
|
||||
const tagNameInput = document.querySelector<HTMLInputElement>('#tag-name')!;
|
||||
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
|
||||
const value = tagNameInput.value;
|
||||
const tagHelper = document.querySelector('#tag-helper')!;
|
||||
const tagHelper = elForm.querySelector('.tag-name-helper')!;
|
||||
if (existingTags.includes(value)) {
|
||||
// If the tag already exists, hide the target branch selector.
|
||||
hideElem('#tag-target-selector');
|
||||
hideElem(elForm.querySelectorAll('.tag-target-selector'));
|
||||
tagHelper.textContent = existingTagHelperText;
|
||||
} else {
|
||||
showElem('#tag-target-selector');
|
||||
showElem(elForm.querySelectorAll('.tag-target-selector'));
|
||||
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
|
||||
}
|
||||
};
|
||||
@ -46,3 +50,102 @@ function initTagNameEditor() {
|
||||
hideTargetInput(e.target as HTMLInputElement);
|
||||
});
|
||||
}
|
||||
|
||||
export function guessPreviousReleaseTag(tagName: string, existingTags: Array<string>): string {
|
||||
let guessedPreviousTag = '', guessedPreviousVer = '';
|
||||
|
||||
const cleanup = (s: string) => {
|
||||
const pos = s.lastIndexOf('/');
|
||||
if (pos >= 0) s = s.substring(pos + 1);
|
||||
if (s.substring(0, 1).toLowerCase() === 'v') s = s.substring(1);
|
||||
return s;
|
||||
};
|
||||
|
||||
const newVer = cleanup(tagName);
|
||||
for (const s of existingTags) {
|
||||
const existingVer = cleanup(s);
|
||||
try {
|
||||
if (compareVersions(existingVer, newVer) >= 0) continue;
|
||||
if (!guessedPreviousTag || compareVersions(existingVer, guessedPreviousVer) > 0) {
|
||||
guessedPreviousTag = s;
|
||||
guessedPreviousVer = existingVer;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return guessedPreviousTag;
|
||||
}
|
||||
|
||||
function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
const buttonShowModal = elForm.querySelector<HTMLButtonElement>('.button.generate-release-notes')!;
|
||||
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[name=tag_name]')!;
|
||||
const targetInput = elForm.querySelector<HTMLInputElement>('input[name=tag_target]')!;
|
||||
|
||||
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
|
||||
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
|
||||
|
||||
const elModal = document.querySelector('#generate-release-notes-modal')!;
|
||||
|
||||
const doSubmit = async (tagName: string) => {
|
||||
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
|
||||
const comboEditor = getComboMarkdownEditor(elForm.querySelector<HTMLElement>('.combo-markdown-editor'))!;
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.set('tag_name', tagName);
|
||||
form.set('tag_target', targetInput.value);
|
||||
form.set('previous_tag', elPreviousTag.value);
|
||||
|
||||
elModal.classList.add('loading', 'disabled');
|
||||
try {
|
||||
const resp = await POST(generateUrl, {data: form});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
showErrorToast(data.errorMessage || resp.statusText);
|
||||
return;
|
||||
}
|
||||
const oldValue = comboEditor.value().trim();
|
||||
if (oldValue) {
|
||||
// Don't overwrite existing content. Maybe in the future we can let users decide: overwrite or append or copy-to-clipboard
|
||||
// GitHub just disables the button if the content is not empty
|
||||
comboEditor.value(`${oldValue}\n\n${data.content}`);
|
||||
} else {
|
||||
comboEditor.value(data.content);
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
|
||||
let inited = false;
|
||||
const doShowModal = () => {
|
||||
hideToastsAll();
|
||||
const tagName = tagNameInput.value.trim();
|
||||
if (!tagName) {
|
||||
showErrorToast(textMissingTag, {duration: 3000});
|
||||
tagNameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTags = getReleaseFormExistingTags(elForm);
|
||||
const $dropdown = fomanticQuery(elModal.querySelector('[name=previous_tag]')!);
|
||||
if (!inited) {
|
||||
inited = true;
|
||||
const values = [];
|
||||
for (const tagName of existingTags) {
|
||||
values.push({name: htmlEscape(tagName), value: tagName}); // ATTENTION: dropdown takes the "name" input as raw HTML
|
||||
}
|
||||
$dropdown.dropdown('change values', values);
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
fomanticQuery(elModal).modal({
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||
import {initOrgTeam} from './features/org-team.ts';
|
||||
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||
import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.ts';
|
||||
import {initRepoReleaseNew} from './features/repo-release.ts';
|
||||
import {initRepoEditor} from './features/repo-editor.ts';
|
||||
import {initCompSearchUserBox} from './features/comp/SearchUserBox.ts';
|
||||
import {initInstall} from './features/install.ts';
|
||||
@ -133,7 +133,6 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initRepoProject,
|
||||
initRepoPullRequestAllowMaintainerEdit,
|
||||
initRepoPullRequestReview,
|
||||
initRepoRelease,
|
||||
initRepoReleaseNew,
|
||||
initRepoTopicBar,
|
||||
initRepoViewFileTree,
|
||||
|
||||
@ -5,7 +5,7 @@ export function initAriaFormFieldPatch() {
|
||||
for (const el of document.querySelectorAll('.ui.form .field')) {
|
||||
if (el.hasAttribute('data-field-patched')) continue;
|
||||
const label = el.querySelector(':scope > label');
|
||||
const input = el.querySelector(':scope > input');
|
||||
const input = el.querySelector(':scope > input, :scope > select');
|
||||
if (!label || !input) continue;
|
||||
linkLabelAndInput(label, input);
|
||||
el.setAttribute('data-field-patched', 'true');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user