This commit is contained in:
@@ -10,12 +10,13 @@ import (
|
||||
"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"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// GenerateReleaseNotesOptions describes how to build release notes content.
|
||||
@@ -25,27 +26,32 @@ type GenerateReleaseNotesOptions struct {
|
||||
PreviousTag string
|
||||
}
|
||||
|
||||
// GenerateReleaseNotes builds the markdown snippet for release notes.
|
||||
// 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
|
||||
isFirstRelease, err := repoReleaseIsEmpty(ctx, repo.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("repoReleaseIsEmpty: %w", err)
|
||||
}
|
||||
|
||||
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
|
||||
if err != nil {
|
||||
var baseCommitID git.RefName
|
||||
if opts.PreviousTag != "" {
|
||||
baseCommit, err := gitRepo.GetCommit(opts.PreviousTag)
|
||||
if err != nil {
|
||||
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.PreviousTag)
|
||||
}
|
||||
baseCommitID = baseCommit.ID.RefName()
|
||||
} else if !isFirstRelease {
|
||||
return "", util.ErrorWrapTranslatable(util.ErrNotExist, "repo.release.generate_notes_tag_not_found", opts.TagName)
|
||||
}
|
||||
|
||||
commits, err := gitRepo.CommitsBetweenIDs(headCommit.ID.String(), baseCommit.ID.String())
|
||||
commits, err := gitRepo.CommitsBetween(headCommit.ID.RefName(), baseCommitID, -1)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("CommitsBetweenIDs: %w", err)
|
||||
return "", fmt.Errorf("CommitsBetween: %w", err)
|
||||
}
|
||||
|
||||
prs, err := collectPullRequestsFromCommits(ctx, repo.ID, commits)
|
||||
@@ -58,10 +64,27 @@ func GenerateReleaseNotes(ctx context.Context, repo *repo_model.Repository, gitR
|
||||
return "", err
|
||||
}
|
||||
|
||||
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors)
|
||||
fullChangelogURL := ""
|
||||
if isFirstRelease {
|
||||
// Keep the first-release changelog link aligned with GitHub, while collecting PRs from full history.
|
||||
fullChangelogURL = fmt.Sprintf("%s/commits/tag/%s", repo.HTMLURL(ctx), util.PathEscapeSegments(opts.TagName))
|
||||
}
|
||||
|
||||
content := buildReleaseNotesContent(ctx, repo, opts.TagName, opts.PreviousTag, prs, contributors, newContributors, fullChangelogURL)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func repoReleaseIsEmpty(ctx context.Context, repoID int64) (bool, error) {
|
||||
count, err := db.Count[repo_model.Release](ctx, repo_model.FindReleasesOptions{
|
||||
RepoID: repoID,
|
||||
IncludeDrafts: false,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count == 0, nil
|
||||
}
|
||||
|
||||
func resolveHeadCommit(gitRepo *git.Repository, tagName, tagTarget string) (*git.Commit, error) {
|
||||
ref := tagName
|
||||
if !gitRepo.IsTagExist(tagName) {
|
||||
@@ -107,7 +130,7 @@ func collectPullRequestsFromCommits(ctx context.Context, repoID int64, commits [
|
||||
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 {
|
||||
func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository, tagName, baseRef string, prs []*issues_model.PullRequest, contributors []*user_model.User, newContributors []*issues_model.PullRequest, fullChangelogURL string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("## What's Changed\n")
|
||||
|
||||
@@ -136,8 +159,12 @@ func buildReleaseNotesContent(ctx context.Context, repo *repo_model.Repository,
|
||||
}
|
||||
|
||||
builder.WriteString("**Full Changelog**: ")
|
||||
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
|
||||
fmt.Fprintf(&builder, "[%s...%s](%s)", baseRef, tagName, compareURL)
|
||||
if fullChangelogURL != "" {
|
||||
builder.WriteString(fullChangelogURL)
|
||||
} else {
|
||||
compareURL := fmt.Sprintf("%s/compare/%s...%s", repo.HTMLURL(ctx), util.PathEscapeSegments(baseRef), util.PathEscapeSegments(tagName))
|
||||
fmt.Fprintf(&builder, "[%s...%s](%s)", baseRef, tagName, compareURL)
|
||||
}
|
||||
builder.WriteByte('\n')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ 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"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -21,13 +21,14 @@ import (
|
||||
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) {
|
||||
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)
|
||||
createMergedPullRequest(t, repo, mergedCommit, 5, "Release notes test pull request")
|
||||
|
||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||
TagName: "v1.2.0",
|
||||
@@ -50,16 +51,51 @@ func TestGenerateReleaseNotes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("NoPreviousTag", func(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16})
|
||||
gitRepo, err := gitrepo.OpenRepository(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { gitRepo.Close() })
|
||||
|
||||
createMergedPullRequest(t, repo, "69554a64c1e6030f051e5c3f94bfbd773cd6a324", 5, "Initial tag PR 1")
|
||||
createMergedPullRequest(t, repo, "27566bd5738fc8b4e3fef3c5e72cce608537bd95", 4, "Initial tag PR 2")
|
||||
createMergedPullRequest(t, repo, "5099b81332712fe655e34e8dd63574f503f61811", 8, "Initial tag PR 3")
|
||||
|
||||
content, err := GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||
TagName: "v1.2.0",
|
||||
TagTarget: "DefaultBranch",
|
||||
TagName: "v0.1.0",
|
||||
TagTarget: repo.DefaultBranch,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "**Full Changelog**: https://try.gitea.io/user2/repo1/commits/tag/v1.2.0\n", content)
|
||||
|
||||
assert.Contains(t, content, "## What's Changed\n")
|
||||
assert.Contains(t, content, "* Initial tag PR 1 in [#")
|
||||
assert.Contains(t, content, "* Initial tag PR 2 in [#")
|
||||
assert.Contains(t, content, "* Initial tag PR 3 in [#")
|
||||
assert.Contains(t, content, "\n## Contributors\n")
|
||||
assert.Contains(t, content, "* @user5\n")
|
||||
assert.Contains(t, content, "* @user4\n")
|
||||
assert.Contains(t, content, "* @user8\n")
|
||||
assert.Contains(t, content, "\n## New Contributors\n")
|
||||
assert.Contains(t, content, "* @user5 made their first contribution in [#")
|
||||
assert.Contains(t, content, "* @user4 made their first contribution in [#")
|
||||
assert.Contains(t, content, "* @user8 made their first contribution in [#")
|
||||
assert.Contains(t, content, "**Full Changelog**: https://try.gitea.io/user2/repo16/commits/tag/v0.1.0\n")
|
||||
})
|
||||
|
||||
t.Run("EmptyPreviousTagWithExistingTags", func(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() })
|
||||
|
||||
_, err = GenerateReleaseNotes(t.Context(), repo, gitRepo, GenerateReleaseNotesOptions{
|
||||
TagName: "v1.2.0",
|
||||
TagTarget: repo.DefaultBranch,
|
||||
})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64) *issues_model.PullRequest {
|
||||
func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCommit string, posterID int64, title string) *issues_model.PullRequest {
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: posterID})
|
||||
|
||||
issue := &issues_model.Issue{
|
||||
@@ -67,7 +103,7 @@ func createMergedPullRequest(t *testing.T, repo *repo_model.Repository, mergeCom
|
||||
Repo: repo,
|
||||
Poster: user,
|
||||
PosterID: user.ID,
|
||||
Title: "Release notes test pull request",
|
||||
Title: title,
|
||||
Content: "content",
|
||||
}
|
||||
|
||||
|
||||
@@ -9,21 +9,23 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
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/git/gitcmd"
|
||||
"code.gitea.io/gitea/modules/gitrepo"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/repository"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
notify_service "code.gitea.io/gitea/services/notify"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/git/gitcmd"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/context/upload"
|
||||
notify_service "gitea.dev/services/notify"
|
||||
)
|
||||
|
||||
// ErrInvalidTagName represents a "InvalidTagName" kind of error.
|
||||
@@ -319,13 +321,17 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo
|
||||
}
|
||||
|
||||
for uuid, newName := range editAttachments {
|
||||
if !deletedUUIDs.Contains(uuid) {
|
||||
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
|
||||
UUID: uuid,
|
||||
Name: newName,
|
||||
}, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if deletedUUIDs.Contains(uuid) {
|
||||
continue
|
||||
}
|
||||
if err = upload.Verify(nil, newName, setting.Repository.Release.AllowedTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{
|
||||
UUID: uuid,
|
||||
Name: newName,
|
||||
}, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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/services/attachment"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/services/attachment"
|
||||
"gitea.dev/services/context/upload"
|
||||
|
||||
_ "code.gitea.io/gitea/models/actions"
|
||||
_ "gitea.dev/models/actions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -138,6 +142,12 @@ func TestRelease_Update(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Advance a mocked clock between create and update instead of sleeping, so the
|
||||
// timestamp-sensitive assertions below stay deterministic.
|
||||
fakeNow := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
defer timeutil.MockSet(fakeNow)()
|
||||
advance := func() { fakeNow = fakeNow.Add(time.Second); timeutil.MockSet(fakeNow) }
|
||||
|
||||
// Test a changed release
|
||||
assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
|
||||
RepoID: repo.ID,
|
||||
@@ -155,7 +165,7 @@ func TestRelease_Update(t *testing.T) {
|
||||
release, err := repo_model.GetRelease(t.Context(), repo.ID, "v1.1.1")
|
||||
assert.NoError(t, err)
|
||||
releaseCreatedUnix := release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Note = "Changed note"
|
||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
|
||||
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
|
||||
@@ -179,7 +189,7 @@ func TestRelease_Update(t *testing.T) {
|
||||
release, err = repo_model.GetRelease(t.Context(), repo.ID, "v1.2.1")
|
||||
assert.NoError(t, err)
|
||||
releaseCreatedUnix = release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Title = "Changed title"
|
||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
|
||||
release, err = repo_model.GetReleaseByID(t.Context(), release.ID)
|
||||
@@ -203,7 +213,7 @@ func TestRelease_Update(t *testing.T) {
|
||||
release, err = repo_model.GetRelease(t.Context(), repo.ID, "v1.3.1")
|
||||
assert.NoError(t, err)
|
||||
releaseCreatedUnix = release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Title = "Changed title"
|
||||
release.Note = "Changed note"
|
||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, nil))
|
||||
@@ -263,6 +273,17 @@ func TestRelease_Update(t *testing.T) {
|
||||
assert.Equal(t, release.ID, release.Attachments[0].ReleaseID)
|
||||
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
|
||||
|
||||
defer test.MockVariableValue(&setting.Repository.Release.AllowedTypes, ".zip")()
|
||||
err = UpdateRelease(t.Context(), user, gitRepo, release, nil, nil, map[string]string{
|
||||
attach.UUID: "test.exe",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
assert.True(t, upload.IsErrFileTypeForbidden(err))
|
||||
release.Attachments = nil
|
||||
assert.NoError(t, repo_model.GetReleaseAttachments(t.Context(), release))
|
||||
assert.Len(t, release.Attachments, 1)
|
||||
assert.Equal(t, "test2.txt", release.Attachments[0].Name)
|
||||
|
||||
// delete the attachment
|
||||
assert.NoError(t, UpdateRelease(t.Context(), user, gitRepo, release, nil, []string{attach.UUID}, nil))
|
||||
release.Attachments = nil
|
||||
@@ -280,6 +301,12 @@ func TestRelease_createTag(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer gitRepo.Close()
|
||||
|
||||
// Advance a mocked clock between create and update instead of sleeping, so the
|
||||
// timestamp-sensitive assertions below stay deterministic.
|
||||
fakeNow := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
defer timeutil.MockSet(fakeNow)()
|
||||
advance := func() { fakeNow = fakeNow.Add(time.Second); timeutil.MockSet(fakeNow) }
|
||||
|
||||
// Test a changed release
|
||||
release := &repo_model.Release{
|
||||
RepoID: repo.ID,
|
||||
@@ -298,7 +325,7 @@ func TestRelease_createTag(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, release.CreatedUnix)
|
||||
releaseCreatedUnix := release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Note = "Changed note"
|
||||
_, err = createTag(t.Context(), gitRepo, release, "")
|
||||
assert.NoError(t, err)
|
||||
@@ -321,7 +348,7 @@ func TestRelease_createTag(t *testing.T) {
|
||||
_, err = createTag(t.Context(), gitRepo, release, "")
|
||||
assert.NoError(t, err)
|
||||
releaseCreatedUnix = release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Title = "Changed title"
|
||||
_, err = createTag(t.Context(), gitRepo, release, "")
|
||||
assert.NoError(t, err)
|
||||
@@ -344,7 +371,7 @@ func TestRelease_createTag(t *testing.T) {
|
||||
_, err = createTag(t.Context(), gitRepo, release, "")
|
||||
assert.NoError(t, err)
|
||||
releaseCreatedUnix = release.CreatedUnix
|
||||
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
|
||||
advance()
|
||||
release.Title = "Changed title"
|
||||
release.Note = "Changed note"
|
||||
_, err = createTag(t.Context(), gitRepo, release, "")
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
"code.gitea.io/gitea/modules/graceful"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/queue"
|
||||
repo_module "code.gitea.io/gitea/modules/repository"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/queue"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user