Release/1.0 #1
@@ -3,6 +3,12 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`--verbose` flag** — prints configuration table (each key, value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and version-bump decision, and explains the final version choice; output goes to stderr so it never pollutes scripts that capture stdout
|
||||||
|
|
||||||
## [1.1.0] - 2026-07-07
|
## [1.1.0] - 2026-07-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# releaser
|
# releaser
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
||||||
|
|
||||||
@@ -64,6 +64,9 @@ releaser --branch release/1.2
|
|||||||
# Write changelog to a custom file
|
# Write changelog to a custom file
|
||||||
releaser --changelog-file CHANGES.md
|
releaser --changelog-file CHANGES.md
|
||||||
|
|
||||||
|
# Show configuration sources, commit list, and version decision
|
||||||
|
releaser --verbose --dry-run
|
||||||
|
|
||||||
# Target a specific pom.xml
|
# Target a specific pom.xml
|
||||||
releaser --pom path/to/pom.xml
|
releaser --pom path/to/pom.xml
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos)
|
- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos)
|
||||||
- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64)
|
- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64)
|
||||||
|
- [x] ~~`--verbose` flag~~ — ✓ shipped v1.1.1 (shows config sources, commit analysis, version decision)
|
||||||
- [ ] Documentation site
|
- [ ] Documentation site
|
||||||
|
|
||||||
## Future / backlog
|
## Future / backlog
|
||||||
|
|||||||
+102
-2
@@ -72,6 +72,7 @@ var exitFn = os.Exit
|
|||||||
func newRootCmd() *cobra.Command {
|
func newRootCmd() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
init_ bool
|
init_ bool
|
||||||
|
verbose bool
|
||||||
dryRun bool
|
dryRun bool
|
||||||
noPush bool
|
noPush bool
|
||||||
noRelease bool
|
noRelease bool
|
||||||
@@ -97,6 +98,7 @@ func newRootCmd() *cobra.Command {
|
|||||||
patternSet = cmd.Flags().Changed("branch-pattern")
|
patternSet = cmd.Flags().Changed("branch-pattern")
|
||||||
return run(options{
|
return run(options{
|
||||||
init: init_,
|
init: init_,
|
||||||
|
verbose: verbose,
|
||||||
repoPath: repoPath,
|
repoPath: repoPath,
|
||||||
branchOverride: branchOverride,
|
branchOverride: branchOverride,
|
||||||
pomOverride: pomOverride,
|
pomOverride: pomOverride,
|
||||||
@@ -115,6 +117,7 @@ func newRootCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
|
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
|
||||||
|
root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision")
|
||||||
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
|
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
|
||||||
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
|
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
|
||||||
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
|
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
|
||||||
@@ -142,6 +145,7 @@ func main() {
|
|||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
init bool
|
init bool
|
||||||
|
verbose bool
|
||||||
repoPath string
|
repoPath string
|
||||||
branchOverride string
|
branchOverride string
|
||||||
pomOverride string
|
pomOverride string
|
||||||
@@ -157,6 +161,40 @@ type options struct {
|
|||||||
tagOnly bool
|
tagOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vlog prints a verbose line to stderr, prefixed with "[verbose] ", when verbose is true.
|
||||||
|
func vlog(verbose bool, format string, args ...any) {
|
||||||
|
if verbose {
|
||||||
|
fmt.Fprintf(os.Stderr, "[verbose] "+format+"\n", args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVerboseConfig(cfg config.Config, src config.Sources) {
|
||||||
|
fmt.Fprintln(os.Stderr, "[verbose] configuration:")
|
||||||
|
rows := []struct{ key, val string }{
|
||||||
|
{"git.tag_prefix", cfg.Git.TagPrefix},
|
||||||
|
{"git.branch_pattern", cfg.Git.BranchPattern},
|
||||||
|
{"git.commit_message", cfg.Git.CommitMessage},
|
||||||
|
{"git.author_name", cfg.Git.AuthorName},
|
||||||
|
{"git.author_email", cfg.Git.AuthorEmail},
|
||||||
|
{"maven.pom_path", cfg.Maven.PomPath},
|
||||||
|
{"gitlab.url", cfg.GitLab.URL},
|
||||||
|
{"gitlab.token", func() string {
|
||||||
|
if cfg.GitLab.Token != "" {
|
||||||
|
return "(set)"
|
||||||
|
}
|
||||||
|
return "(not set)"
|
||||||
|
}()},
|
||||||
|
{"gitlab.project", cfg.GitLab.Project},
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
source := src[r.key]
|
||||||
|
if source == "" {
|
||||||
|
source = "default"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, " %-25s = %-40s [%s]\n", r.key, r.val, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initConfig(absRepo string) error {
|
func initConfig(absRepo string) error {
|
||||||
path := filepath.Join(absRepo, ".releaser.yml")
|
path := filepath.Join(absRepo, ".releaser.yml")
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
@@ -177,24 +215,46 @@ func run(o options) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if o.init {
|
if o.init {
|
||||||
|
vlog(o.verbose, "creating .releaser.yml in %s", absRepo)
|
||||||
return initConfig(absRepo)
|
return initConfig(absRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(absRepo)
|
var (
|
||||||
|
cfg config.Config
|
||||||
|
src config.Sources
|
||||||
|
)
|
||||||
|
if o.verbose {
|
||||||
|
cfg, src, err = config.LoadWithSources(absRepo)
|
||||||
|
} else {
|
||||||
|
cfg, err = config.Load(absRepo)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg.ApplyEnv()
|
cfg.ApplyEnvWithSources(src)
|
||||||
|
|
||||||
// CLI flags take precedence over config file and env vars
|
// CLI flags take precedence over config file and env vars
|
||||||
if o.tagPrefixSet {
|
if o.tagPrefixSet {
|
||||||
cfg.Git.TagPrefix = o.tagPrefixFlag
|
cfg.Git.TagPrefix = o.tagPrefixFlag
|
||||||
|
if src != nil {
|
||||||
|
src["git.tag_prefix"] = "flag: --tag-prefix"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if o.pomOverride != "" {
|
if o.pomOverride != "" {
|
||||||
cfg.Maven.PomPath = o.pomOverride
|
cfg.Maven.PomPath = o.pomOverride
|
||||||
|
if src != nil {
|
||||||
|
src["maven.pom_path"] = "flag: --pom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if o.patternSet {
|
if o.patternSet {
|
||||||
cfg.Git.BranchPattern = o.patternFlag
|
cfg.Git.BranchPattern = o.patternFlag
|
||||||
|
if src != nil {
|
||||||
|
src["git.branch_pattern"] = "flag: --branch-pattern"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if o.verbose {
|
||||||
|
printVerboseConfig(cfg, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Git ---
|
// --- Git ---
|
||||||
@@ -217,6 +277,8 @@ func run(o options) error {
|
|||||||
}
|
}
|
||||||
info.TagPrefix = cfg.Git.TagPrefix
|
info.TagPrefix = cfg.Git.TagPrefix
|
||||||
|
|
||||||
|
vlog(o.verbose, "branch: %s → major=%d, minor=%d (pinned by branch)", branchName, info.Major, info.Minor)
|
||||||
|
|
||||||
// --- Dirty check (before any changes) ---
|
// --- Dirty check (before any changes) ---
|
||||||
// Skipped in --no-commit mode: the user intentionally has changes in flight.
|
// Skipped in --no-commit mode: the user intentionally has changes in flight.
|
||||||
if !o.dryRun && !o.noCommit {
|
if !o.dryRun && !o.noCommit {
|
||||||
@@ -235,6 +297,14 @@ func run(o options) error {
|
|||||||
return fmt.Errorf("find latest tag: %w", err)
|
return fmt.Errorf("find latest tag: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o.verbose {
|
||||||
|
if lastTag == "" {
|
||||||
|
vlog(true, "last tag: none — scanning all commits")
|
||||||
|
} else {
|
||||||
|
vlog(true, "last tag: %s → current patch=%d", lastTag, currentPatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Commit range ---
|
// --- Commit range ---
|
||||||
var messages []string
|
var messages []string
|
||||||
if lastTag == "" {
|
if lastTag == "" {
|
||||||
@@ -256,6 +326,26 @@ func run(o options) error {
|
|||||||
types[i] = commits.Parse(msg)
|
types[i] = commits.Parse(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o.verbose {
|
||||||
|
vlog(true, "commits analyzed (%d):", len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
|
||||||
|
t := types[i]
|
||||||
|
var decision string
|
||||||
|
switch t {
|
||||||
|
case commits.TypeBreaking:
|
||||||
|
decision = "breaking → patch bump"
|
||||||
|
case commits.TypeFeat:
|
||||||
|
decision = "feat → patch bump"
|
||||||
|
case commits.TypeFix:
|
||||||
|
decision = "fix → patch bump"
|
||||||
|
default:
|
||||||
|
decision = "ignored"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, " %-60s %s\n", first, decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
|
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
|
||||||
if !ok {
|
if !ok {
|
||||||
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
|
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
|
||||||
@@ -264,6 +354,16 @@ func run(o options) error {
|
|||||||
|
|
||||||
nextTag := info.TagName(nextVersion)
|
nextTag := info.TagName(nextVersion)
|
||||||
|
|
||||||
|
if o.verbose {
|
||||||
|
highestType := commits.TypeNone
|
||||||
|
for _, t := range types {
|
||||||
|
if t > highestType {
|
||||||
|
highestType = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vlog(true, "version decision: highest type=%s → next=%s (tag: %s)", highestType, nextVersion, nextTag)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
|
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
|
||||||
|
|
||||||
if o.dryRun {
|
if o.dryRun {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -656,3 +657,46 @@ func TestRunChangelogFile(t *testing.T) {
|
|||||||
t.Error("expected CHANGES.md to be created")
|
t.Error("expected CHANGES.md to be created")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunVerbose(t *testing.T) {
|
||||||
|
_, dir := setupRepo(t)
|
||||||
|
addFile(t, dir, "x.go", "// feat")
|
||||||
|
repo, _ := gogit.PlainOpen(dir)
|
||||||
|
w, _ := repo.Worktree()
|
||||||
|
w.Add("x.go")
|
||||||
|
w.Commit("feat: add new thing", &gogit.CommitOptions{Author: testSig()})
|
||||||
|
|
||||||
|
// Capture stderr output by redirecting it temporarily.
|
||||||
|
old := os.Stderr
|
||||||
|
r, wPipe, _ := os.Pipe()
|
||||||
|
os.Stderr = wPipe
|
||||||
|
|
||||||
|
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
|
||||||
|
|
||||||
|
wPipe.Close()
|
||||||
|
os.Stderr = old
|
||||||
|
|
||||||
|
rawBytes, _ := io.ReadAll(r)
|
||||||
|
output := string(rawBytes)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("--verbose: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := []string{
|
||||||
|
"configuration:",
|
||||||
|
"git.tag_prefix",
|
||||||
|
"[default]",
|
||||||
|
"branch: release/1.2",
|
||||||
|
"major=1, minor=2",
|
||||||
|
"commits analyzed",
|
||||||
|
"feat: add new thing",
|
||||||
|
"feat → patch bump",
|
||||||
|
"version decision:",
|
||||||
|
}
|
||||||
|
for _, want := range checks {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,42 +50,123 @@ func defaults() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sources records where each config value came from.
|
||||||
|
// Keys are "section.field" (e.g. "git.tag_prefix").
|
||||||
|
// Values are one of: "default", "config file", "env: VARNAME", "flag: --flag-name".
|
||||||
|
type Sources map[string]string
|
||||||
|
|
||||||
|
func defaultSources() Sources {
|
||||||
|
return Sources{
|
||||||
|
"git.tag_prefix": "default",
|
||||||
|
"git.branch_pattern": "default",
|
||||||
|
"git.commit_message": "default",
|
||||||
|
"git.author_name": "default",
|
||||||
|
"git.author_email": "default",
|
||||||
|
"maven.pom_path": "default",
|
||||||
|
"gitlab.url": "default",
|
||||||
|
"gitlab.token": "default",
|
||||||
|
"gitlab.project": "default",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads .releaser.yml from dir and merges it over the defaults.
|
// Load reads .releaser.yml from dir and merges it over the defaults.
|
||||||
// Missing file is not an error — defaults are returned as-is.
|
// Missing file is not an error — defaults are returned as-is.
|
||||||
func Load(dir string) (Config, error) {
|
func Load(dir string) (Config, error) {
|
||||||
|
cfg, _, err := LoadWithSources(dir)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithSources is like Load but also returns a Sources map recording where each
|
||||||
|
// value came from ("default" or "config file").
|
||||||
|
func LoadWithSources(dir string) (Config, Sources, error) {
|
||||||
cfg := defaults()
|
cfg := defaults()
|
||||||
|
src := defaultSources()
|
||||||
|
|
||||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return cfg, nil
|
return cfg, src, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg, fmt.Errorf("read %s: %w", filename, err)
|
return cfg, src, fmt.Errorf("read %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unmarshal into cfg (merges over defaults).
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return cfg, fmt.Errorf("parse %s: %w", filename, err)
|
return cfg, src, fmt.Errorf("parse %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
// Detect which fields the file explicitly set by unmarshaling into a zero overlay.
|
||||||
|
var overlay Config
|
||||||
|
_ = yaml.Unmarshal(data, &overlay)
|
||||||
|
if overlay.Git.TagPrefix != "" {
|
||||||
|
src["git.tag_prefix"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.Git.BranchPattern != "" {
|
||||||
|
src["git.branch_pattern"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.Git.CommitMessage != "" {
|
||||||
|
src["git.commit_message"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.Git.AuthorName != "" {
|
||||||
|
src["git.author_name"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.Git.AuthorEmail != "" {
|
||||||
|
src["git.author_email"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.Maven.PomPath != "" {
|
||||||
|
src["maven.pom_path"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.GitLab.URL != "" {
|
||||||
|
src["gitlab.url"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.GitLab.Token != "" {
|
||||||
|
src["gitlab.token"] = "config file"
|
||||||
|
}
|
||||||
|
if overlay.GitLab.Project != "" {
|
||||||
|
src["gitlab.project"] = "config file"
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, src, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
|
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
|
||||||
// Values already set in the config file are never overwritten.
|
// Values already set in the config file are never overwritten.
|
||||||
func (c *Config) ApplyEnv() {
|
func (c *Config) ApplyEnv() {
|
||||||
|
c.ApplyEnvWithSources(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each
|
||||||
|
// field it fills. src may be nil.
|
||||||
|
func (c *Config) ApplyEnvWithSources(src Sources) {
|
||||||
if c.GitLab.Token == "" {
|
if c.GitLab.Token == "" {
|
||||||
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
|
if v := os.Getenv("GITLAB_TOKEN"); v != "" {
|
||||||
|
c.GitLab.Token = v
|
||||||
|
if src != nil {
|
||||||
|
src["gitlab.token"] = "env: GITLAB_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if c.GitLab.URL == "" {
|
if c.GitLab.URL == "" {
|
||||||
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
|
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
|
||||||
c.GitLab.URL = os.Getenv("CI_SERVER_URL")
|
if v := os.Getenv("CI_SERVER_URL"); v != "" {
|
||||||
|
c.GitLab.URL = v
|
||||||
|
if src != nil {
|
||||||
|
src["gitlab.url"] = "env: CI_SERVER_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if c.GitLab.Project == "" {
|
if c.GitLab.Project == "" {
|
||||||
// Prefer numeric ID; fall back to namespace/project path
|
// Prefer numeric ID; fall back to namespace/project path
|
||||||
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
|
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
|
||||||
c.GitLab.Project = id
|
c.GitLab.Project = id
|
||||||
} else {
|
if src != nil {
|
||||||
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
|
src["gitlab.project"] = "env: CI_PROJECT_ID"
|
||||||
|
}
|
||||||
|
} else if p := os.Getenv("CI_PROJECT_PATH"); p != "" {
|
||||||
|
c.GitLab.Project = p
|
||||||
|
if src != nil {
|
||||||
|
src["gitlab.project"] = "env: CI_PROJECT_PATH"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user