forked from lunny/changelog
Changelog Overhaul 2 (#19)
Default gitea service Signed-off-by: jolheiser <john.olheiser@gmail.com> Reviewed-on: https://gitea.com/gitea/changelog/pulls/19 Reviewed-by: 6543 <6543@noreply.gitea.io> Reviewed-by: Guillermo Prandi <guillep2k@noreply.gitea.io>
This commit is contained in:
parent
56048d3f35
commit
747f3cb162
@ -1,3 +1,7 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
// +build ignore
|
// +build ignore
|
||||||
|
|
||||||
package main
|
package main
|
||||||
@ -10,8 +14,12 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
exampleFile = "changelog.example.yml"
|
exampleFile = "changelog.example.yml"
|
||||||
writeFile = "config_default.go"
|
writeFile = "config/config_default.go"
|
||||||
tmpl = `package main
|
tmpl = `// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
defaultConfig = []byte(` + "`" + `%s` + "`" + `)
|
defaultConfig = []byte(` + "`" + `%s` + "`" + `)
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
# The full repository name
|
# The full repository name
|
||||||
repo: go-gitea/gitea
|
repo: go-gitea/gitea
|
||||||
|
|
||||||
|
# Service type (gitea or github)
|
||||||
|
service: github
|
||||||
|
|
||||||
|
# Base URL for Gitea instance if using gitea service type (optional)
|
||||||
|
# Default: https://gitea.com
|
||||||
|
base-url:
|
||||||
|
|
||||||
# Changelog groups and which labeled PRs to add to each group
|
# Changelog groups and which labeled PRs to add to each group
|
||||||
groups:
|
groups:
|
||||||
-
|
-
|
||||||
|
13
cmd/cmd.go
Normal file
13
cmd/cmd.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
var (
|
||||||
|
MilestoneFlag string
|
||||||
|
ConfigPathFlag string
|
||||||
|
TokenFlag string
|
||||||
|
DetailsFlag bool
|
||||||
|
AfterFlag int64
|
||||||
|
)
|
46
cmd/contributors.go
Normal file
46
cmd/contributors.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Copyright 2018 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"code.gitea.io/changelog/config"
|
||||||
|
"code.gitea.io/changelog/service"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Contributors = &cli.Command{
|
||||||
|
Name: "contributors",
|
||||||
|
Usage: "Generates a contributors list",
|
||||||
|
Action: runContributors,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runContributors(cmd *cli.Context) error {
|
||||||
|
cfg, err := config.New(ConfigPathFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := service.New(cfg.Service, cfg.Repo, cfg.BaseURL, MilestoneFlag, TokenFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
contributors, err := s.Contributors()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(contributors)
|
||||||
|
|
||||||
|
for _, contributor := range contributors {
|
||||||
|
fmt.Printf("* [@%s](%s)\n", contributor.Name, contributor.Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
101
cmd/generate.go
Normal file
101
cmd/generate.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"code.gitea.io/changelog/config"
|
||||||
|
"code.gitea.io/changelog/service"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Generate = &cli.Command{
|
||||||
|
Name: "generate",
|
||||||
|
Usage: "Generates a changelog",
|
||||||
|
Action: runGenerate,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGenerate(cmd *cli.Context) error {
|
||||||
|
cfg, err := config.New(ConfigPathFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := make(map[string]string)
|
||||||
|
entries := make(map[string][]service.PullRequest)
|
||||||
|
var defaultGroup string
|
||||||
|
for _, g := range cfg.Groups {
|
||||||
|
entries[g.Name] = []service.PullRequest{}
|
||||||
|
for _, l := range g.Labels {
|
||||||
|
labels[l] = g.Name
|
||||||
|
}
|
||||||
|
if g.Default {
|
||||||
|
defaultGroup = g.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultGroup == "" {
|
||||||
|
defaultGroup = cfg.Groups[len(cfg.Groups)-1].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := service.New(cfg.Service, cfg.Repo, cfg.BaseURL, MilestoneFlag, TokenFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
title, prs, err := s.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
PRLoop: // labels in Go, let's get old school
|
||||||
|
for _, pr := range prs {
|
||||||
|
if pr.Index < AfterFlag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var label string
|
||||||
|
for _, lb := range pr.Labels {
|
||||||
|
if cfg.SkipRegex != nil && cfg.SkipRegex.MatchString(lb.Name) {
|
||||||
|
continue PRLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
if g, ok := labels[lb.Name]; ok && len(label) == 0 {
|
||||||
|
label = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(label) > 0 {
|
||||||
|
entries[label] = append(entries[label], pr)
|
||||||
|
} else {
|
||||||
|
entries[defaultGroup] = append(entries[defaultGroup], pr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(title)
|
||||||
|
for _, g := range cfg.Groups {
|
||||||
|
if len(entries[g.Name]) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if DetailsFlag {
|
||||||
|
fmt.Println("<details><summary>" + g.Name + "</summary>")
|
||||||
|
fmt.Println()
|
||||||
|
for _, entry := range entries[g.Name] {
|
||||||
|
fmt.Printf("* %s (#%d)\n", entry.Title, entry.Index)
|
||||||
|
}
|
||||||
|
fmt.Println("</details>")
|
||||||
|
} else {
|
||||||
|
fmt.Println("* " + g.Name)
|
||||||
|
for _, entry := range entries[g.Name] {
|
||||||
|
fmt.Printf(" * %s (#%d)\n", entry.Title, entry.Index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
54
config.go
54
config.go
@ -1,54 +0,0 @@
|
|||||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
//go:generate go run changelog.example.go
|
|
||||||
//go:generate go fmt ./...
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io/ioutil"
|
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultConfig []byte
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Repo string `yaml:"repo"`
|
|
||||||
Groups []struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Labels []string `yaml:"labels"`
|
|
||||||
Default bool `yaml:"default"`
|
|
||||||
} `yaml:"groups"`
|
|
||||||
SkipLabels string `yaml:"skip-labels"`
|
|
||||||
SkipRegex *regexp.Regexp `yaml:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig() (*Config, error) {
|
|
||||||
var err error
|
|
||||||
var configContent []byte
|
|
||||||
if len(configPath) == 0 {
|
|
||||||
configContent = defaultConfig
|
|
||||||
} else {
|
|
||||||
configContent, err = ioutil.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var config *Config
|
|
||||||
if err = yaml.Unmarshal(configContent, &config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(config.SkipLabels) > 0 {
|
|
||||||
if config.SkipRegex, err = regexp.Compile(config.SkipLabels); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
58
config/config.go
Normal file
58
config/config.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultConfig []byte
|
||||||
|
|
||||||
|
// Group is a grouping of PRs
|
||||||
|
type Group struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Labels []string `yaml:"labels"`
|
||||||
|
Default bool `yaml:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the changelog settings
|
||||||
|
type Config struct {
|
||||||
|
Repo string `yaml:"repo"`
|
||||||
|
Service string `yaml:"service"`
|
||||||
|
BaseURL string `yaml:"base-url"`
|
||||||
|
Groups []Group `yaml:"groups"`
|
||||||
|
SkipLabels string `yaml:"skip-labels"`
|
||||||
|
SkipRegex *regexp.Regexp `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a config from a path, defaulting to changelog.example.yml
|
||||||
|
func New(configPath string) (*Config, error) {
|
||||||
|
var err error
|
||||||
|
var configContent []byte
|
||||||
|
if len(configPath) == 0 {
|
||||||
|
configContent = defaultConfig
|
||||||
|
} else {
|
||||||
|
configContent, err = ioutil.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *Config
|
||||||
|
if err = yaml.Unmarshal(configContent, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.SkipLabels) > 0 {
|
||||||
|
if cfg.SkipRegex, err = regexp.Compile(cfg.SkipLabels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
@ -1,9 +1,20 @@
|
|||||||
package main
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
defaultConfig = []byte(`# The full repository name
|
defaultConfig = []byte(`# The full repository name
|
||||||
repo: go-gitea/gitea
|
repo: go-gitea/gitea
|
||||||
|
|
||||||
|
# Service type (gitea or github)
|
||||||
|
service: github
|
||||||
|
|
||||||
|
# Base URL for Gitea instance if using gitea service type (optional)
|
||||||
|
# Default: https://gitea.com
|
||||||
|
base-url:
|
||||||
|
|
||||||
# Changelog groups and which labeled PRs to add to each group
|
# Changelog groups and which labeled PRs to add to each group
|
||||||
groups:
|
groups:
|
||||||
-
|
-
|
@ -1,70 +0,0 @@
|
|||||||
// Copyright 2018 The Gitea Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"github.com/google/go-github/github"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cmdContributors = &cli.Command{
|
|
||||||
Name: "contributors",
|
|
||||||
Usage: "generate contributors list",
|
|
||||||
Description: "generate contributors list",
|
|
||||||
Action: runContributors,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runContributors(cmd *cli.Context) error {
|
|
||||||
config, err := LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := github.NewClient(nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
contributorsMap := make(map[string]bool)
|
|
||||||
query := fmt.Sprintf(`repo:%s is:merged milestone:"%s"`, config.Repo, milestone)
|
|
||||||
p := 1
|
|
||||||
perPage := 100
|
|
||||||
for {
|
|
||||||
result, _, err := client.Search.Issues(ctx, query, &github.SearchOptions{
|
|
||||||
ListOptions: github.ListOptions{
|
|
||||||
Page: p,
|
|
||||||
PerPage: perPage,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
p++
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pr := range result.Issues {
|
|
||||||
contributorsMap[*pr.User.Login] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Issues) != perPage {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contributors := make([]string, 0, len(contributorsMap))
|
|
||||||
for contributor, _ := range contributorsMap {
|
|
||||||
contributors = append(contributors, contributor)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(contributors)
|
|
||||||
|
|
||||||
for _, contributor := range contributors {
|
|
||||||
fmt.Printf("* [@%s](https://github.com/%s)\n", contributor, contributor)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
103
generate.go
103
generate.go
@ -1,103 +0,0 @@
|
|||||||
// Copyright 2020 The Gitea Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a MIT-style
|
|
||||||
// license that can be found in the LICENSE file.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/go-github/github"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cmdGenerate = &cli.Command{
|
|
||||||
Name: "generate",
|
|
||||||
Usage: "generate changelog",
|
|
||||||
Description: "generate changelog",
|
|
||||||
Action: runGenerate,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runGenerate(cmd *cli.Context) error {
|
|
||||||
config, err := LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := github.NewClient(nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
labels := make(map[string]string)
|
|
||||||
changelogs := make(map[string][]github.Issue)
|
|
||||||
var defaultGroup string
|
|
||||||
for _, g := range config.Groups {
|
|
||||||
changelogs[g.Name] = []github.Issue{}
|
|
||||||
for _, l := range g.Labels {
|
|
||||||
labels[l] = g.Name
|
|
||||||
}
|
|
||||||
if g.Default {
|
|
||||||
defaultGroup = g.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if defaultGroup == "" {
|
|
||||||
defaultGroup = config.Groups[len(config.Groups)-1].Name
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`repo:%s is:merged milestone:"%s"`, config.Repo, milestone)
|
|
||||||
p := 1
|
|
||||||
perPage := 100
|
|
||||||
for {
|
|
||||||
result, _, err := client.Search.Issues(ctx, query, &github.SearchOptions{
|
|
||||||
ListOptions: github.ListOptions{
|
|
||||||
Page: p,
|
|
||||||
PerPage: perPage,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
p++
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
PRLoop: // labels in Go, let's get old school
|
|
||||||
for _, pr := range result.Issues {
|
|
||||||
var label string
|
|
||||||
for _, lb := range pr.Labels {
|
|
||||||
if config.SkipRegex != nil && config.SkipRegex.MatchString(lb.GetName()) {
|
|
||||||
continue PRLoop
|
|
||||||
}
|
|
||||||
|
|
||||||
if g, ok := labels[lb.GetName()]; ok && len(label) == 0 {
|
|
||||||
label = g
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(label) > 0 {
|
|
||||||
changelogs[label] = append(changelogs[label], pr)
|
|
||||||
} else {
|
|
||||||
changelogs[defaultGroup] = append(changelogs[defaultGroup], pr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Issues) != perPage {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("## [%s](https://github.com/%s/releases/tag/v%s) - %s\n", milestone, config.Repo, milestone, time.Now().Format("2006-01-02"))
|
|
||||||
for _, g := range config.Groups {
|
|
||||||
if len(changelogs[g.Name]) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("* " + g.Name)
|
|
||||||
for _, pr := range changelogs[g.Name] {
|
|
||||||
fmt.Printf(" * %s (#%d)\n", *pr.Title, *pr.Number)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
1
go.mod
1
go.mod
@ -3,6 +3,7 @@ module code.gitea.io/changelog
|
|||||||
go 1.13
|
go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
code.gitea.io/sdk/gitea v0.0.0-20200116035226-b24cfd841cda
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||||
github.com/google/go-github v17.0.0+incompatible
|
github.com/google/go-github v17.0.0+incompatible
|
||||||
github.com/google/go-querystring v1.0.0 // indirect
|
github.com/google/go-querystring v1.0.0 // indirect
|
||||||
|
7
go.sum
7
go.sum
@ -1,7 +1,11 @@
|
|||||||
|
code.gitea.io/sdk/gitea v0.0.0-20200116035226-b24cfd841cda h1:J+qDCjmjcewNcPNfHIex5z726cgv/URXK0MnXHTIo1U=
|
||||||
|
code.gitea.io/sdk/gitea v0.0.0-20200116035226-b24cfd841cda/go.mod h1:SXOCD/+QP5txLJQ2bPkgHGSQs1YQ4s1ep1ZpI6ItO4A=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
|
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
|
||||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||||
@ -12,6 +16,9 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0
|
|||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
||||||
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
35
main.go
35
main.go
@ -4,10 +4,14 @@
|
|||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
//go:generate go run changelog.example.go
|
||||||
|
//go:generate go fmt ./...
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"code.gitea.io/changelog/cmd"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -16,11 +20,6 @@ const (
|
|||||||
Version = "0.2"
|
Version = "0.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
milestone string
|
|
||||||
configPath string
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "changelog",
|
Name: "changelog",
|
||||||
@ -32,18 +31,36 @@ func main() {
|
|||||||
Aliases: []string{"m"},
|
Aliases: []string{"m"},
|
||||||
Usage: "Targeted milestone",
|
Usage: "Targeted milestone",
|
||||||
Required: true,
|
Required: true,
|
||||||
Destination: &milestone,
|
Destination: &cmd.MilestoneFlag,
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "config",
|
Name: "config",
|
||||||
Aliases: []string{"c"},
|
Aliases: []string{"c"},
|
||||||
Usage: "Specify a config file",
|
Usage: "Specify a config file",
|
||||||
Destination: &configPath,
|
Destination: &cmd.ConfigPathFlag,
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "token",
|
||||||
|
Aliases: []string{"t"},
|
||||||
|
Usage: "Access token for private repositories/instances",
|
||||||
|
Destination: &cmd.TokenFlag,
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "details",
|
||||||
|
Aliases: []string{"d"},
|
||||||
|
Usage: "Generate detail lists instead of long lists",
|
||||||
|
Destination: &cmd.DetailsFlag,
|
||||||
|
},
|
||||||
|
&cli.Int64Flag{
|
||||||
|
Name: "after",
|
||||||
|
Aliases: []string{"a"},
|
||||||
|
Usage: "Only select PRs after a given index (continuing a previous changelog)",
|
||||||
|
Destination: &cmd.AfterFlag,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
cmdGenerate,
|
cmd.Generate,
|
||||||
cmdContributors,
|
cmd.Contributors,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
138
service/gitea.go
Normal file
138
service/gitea.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Gitea defines a Gitea service
|
||||||
|
type Gitea struct {
|
||||||
|
Milestone string
|
||||||
|
Token string
|
||||||
|
BaseURL string
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a Gitea changelog
|
||||||
|
func (ge *Gitea) Generate() (string, []PullRequest, error) {
|
||||||
|
client := gitea.NewClient(ge.BaseURL, ge.Token)
|
||||||
|
|
||||||
|
prs := make([]PullRequest, 0)
|
||||||
|
|
||||||
|
milestoneID, err := ge.milestoneID(client)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagURL := fmt.Sprintf("## [%s](%s/%s/%s/pulls?q=&type=all&state=closed&milestone=%d) - %s", ge.Milestone, ge.BaseURL, ge.Owner, ge.Repo, milestoneID, time.Now().Format("2006-01-02"))
|
||||||
|
|
||||||
|
p := 1
|
||||||
|
// https://github.com/go-gitea/gitea/blob/d92781bf941972761177ac9e07441f8893758fd3/models/repo.go#L63
|
||||||
|
// https://github.com/go-gitea/gitea/blob/e3c3b33ea7a5a223e22688c3f0eb2d3dab9f991c/models/pull_list.go#L104
|
||||||
|
// FIXME Gitea has this hard-coded at 40
|
||||||
|
perPage := 40
|
||||||
|
for {
|
||||||
|
results, err := client.ListRepoPullRequests(ge.Owner, ge.Repo, gitea.ListPullRequestsOptions{
|
||||||
|
Page: p,
|
||||||
|
State: "closed",
|
||||||
|
Milestone: milestoneID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
|
||||||
|
for _, pr := range results {
|
||||||
|
if pr != nil && pr.HasMerged {
|
||||||
|
p := PullRequest{
|
||||||
|
Title: pr.Title,
|
||||||
|
Index: pr.Index,
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := make([]Label, len(pr.Labels))
|
||||||
|
for idx, lbl := range pr.Labels {
|
||||||
|
labels[idx] = Label{
|
||||||
|
Name: lbl.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Labels = labels
|
||||||
|
|
||||||
|
prs = append(prs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != perPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagURL, prs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributors returns a list of contributors from Gitea
|
||||||
|
func (ge *Gitea) Contributors() (ContributorList, error) {
|
||||||
|
client := gitea.NewClient(ge.BaseURL, ge.Token)
|
||||||
|
|
||||||
|
contributorsMap := make(map[string]bool)
|
||||||
|
|
||||||
|
milestoneID, err := ge.milestoneID(client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p := 1
|
||||||
|
perPage := 100
|
||||||
|
for {
|
||||||
|
results, err := client.ListRepoPullRequests(ge.Owner, ge.Repo, gitea.ListPullRequestsOptions{
|
||||||
|
Page: p,
|
||||||
|
State: "closed",
|
||||||
|
Milestone: milestoneID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
|
||||||
|
for _, pr := range results {
|
||||||
|
if pr != nil && pr.HasMerged {
|
||||||
|
contributorsMap[pr.Poster.UserName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != perPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contributors := make(ContributorList, 0, len(contributorsMap))
|
||||||
|
for contributor, _ := range contributorsMap {
|
||||||
|
contributors = append(contributors, Contributor{
|
||||||
|
Name: contributor,
|
||||||
|
Profile: fmt.Sprintf("%s/%s", ge.BaseURL, contributor),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contributors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ge *Gitea) milestoneID(client *gitea.Client) (int64, error) {
|
||||||
|
milestones, err := client.ListRepoMilestones(ge.Owner, ge.Repo)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ms := range milestones {
|
||||||
|
if ms.Title == ge.Milestone {
|
||||||
|
return ms.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("no milestone found for %s", ge.Milestone)
|
||||||
|
}
|
112
service/github.go
Normal file
112
service/github.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-github/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitHub defines a GitHub service
|
||||||
|
type GitHub struct {
|
||||||
|
Milestone string
|
||||||
|
Token string
|
||||||
|
Repo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a GitHub changelog
|
||||||
|
func (gh *GitHub) Generate() (string, []PullRequest, error) {
|
||||||
|
tagURL := fmt.Sprintf("## [%s](https://github.com/%s/releases/tag/v%s) - %s", gh.Milestone, gh.Repo, gh.Milestone, time.Now().Format("2006-01-02"))
|
||||||
|
|
||||||
|
client := github.NewClient(nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
prs := make([]PullRequest, 0)
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`repo:%s is:merged milestone:"%s"`, gh.Repo, gh.Milestone)
|
||||||
|
p := 1
|
||||||
|
perPage := 100
|
||||||
|
for {
|
||||||
|
result, _, err := client.Search.Issues(ctx, query, &github.SearchOptions{
|
||||||
|
ListOptions: github.ListOptions{
|
||||||
|
Page: p,
|
||||||
|
PerPage: perPage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
|
||||||
|
for _, pr := range result.Issues {
|
||||||
|
if pr.IsPullRequest() {
|
||||||
|
p := PullRequest{
|
||||||
|
Title: pr.GetTitle(),
|
||||||
|
Index: int64(pr.GetNumber()),
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := make([]Label, len(pr.Labels))
|
||||||
|
for idx, lbl := range pr.Labels {
|
||||||
|
labels[idx] = Label{
|
||||||
|
Name: lbl.GetName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Labels = labels
|
||||||
|
|
||||||
|
prs = append(prs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Issues) != perPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagURL, prs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributors returns a list of contributors from GitHub
|
||||||
|
func (gh *GitHub) Contributors() (ContributorList, error) {
|
||||||
|
client := github.NewClient(nil)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
contributorsMap := make(map[string]bool)
|
||||||
|
query := fmt.Sprintf(`repo:%s is:merged milestone:"%s"`, gh.Repo, gh.Milestone)
|
||||||
|
p := 1
|
||||||
|
perPage := 100
|
||||||
|
for {
|
||||||
|
result, _, err := client.Search.Issues(ctx, query, &github.SearchOptions{
|
||||||
|
ListOptions: github.ListOptions{
|
||||||
|
Page: p,
|
||||||
|
PerPage: perPage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p++
|
||||||
|
|
||||||
|
for _, pr := range result.Issues {
|
||||||
|
contributorsMap[pr.GetUser().GetLogin()] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Issues) != perPage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contributors := make(ContributorList, 0, len(contributorsMap))
|
||||||
|
for contributor, _ := range contributorsMap {
|
||||||
|
contributors = append(contributors, Contributor{
|
||||||
|
Name: contributor,
|
||||||
|
Profile: fmt.Sprintf("https://github.com/%s", contributor),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contributors, nil
|
||||||
|
}
|
38
service/github_test.go
Normal file
38
service/github_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
var gh = &GitHub{
|
||||||
|
Milestone: "1.1.0", // https://github.com/go-gitea/test_repo/milestone/2?closed=1
|
||||||
|
Repo: "go-gitea/test_repo",
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubGenerate(t *testing.T) {
|
||||||
|
_, entries, err := gh.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Logf("Expected 1 changelog entry, but got %d", len(entries))
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitHubContributors(t *testing.T) {
|
||||||
|
contributors, err := gh.Contributors()
|
||||||
|
if err != nil {
|
||||||
|
t.Log(err)
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(contributors) != 1 {
|
||||||
|
t.Logf("Expected 1 contributor, but got %d", len(contributors))
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
80
service/service.go
Normal file
80
service/service.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultGitea = "https://gitea.com"
|
||||||
|
|
||||||
|
// Load returns a service from a string
|
||||||
|
func New(serviceType, repo, baseURL, milestone, token string) (Service, error) {
|
||||||
|
switch strings.ToLower(serviceType) {
|
||||||
|
case "github":
|
||||||
|
return &GitHub{
|
||||||
|
Milestone: milestone,
|
||||||
|
Token: token,
|
||||||
|
Repo: repo,
|
||||||
|
}, nil
|
||||||
|
case "gitea":
|
||||||
|
ownerRepo := strings.Split(repo, "/")
|
||||||
|
if strings.TrimSpace(baseURL) == "" {
|
||||||
|
baseURL = defaultGitea
|
||||||
|
}
|
||||||
|
return &Gitea{
|
||||||
|
Milestone: milestone,
|
||||||
|
Token: token,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
Owner: ownerRepo[0],
|
||||||
|
Repo: ownerRepo[1],
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown service type %s", serviceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service defines how a struct can be a Changelog Service
|
||||||
|
type Service interface {
|
||||||
|
Generate() (string, []PullRequest, error)
|
||||||
|
Contributors() (ContributorList, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label is the minimum information needed for a PR label
|
||||||
|
type Label struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullRequest is the minimum information needed to make a changelog entry
|
||||||
|
type PullRequest struct {
|
||||||
|
Title string
|
||||||
|
Index int64
|
||||||
|
Labels []Label
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributor is a project contributor
|
||||||
|
type Contributor struct {
|
||||||
|
Name string
|
||||||
|
Profile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContributorList is a slice of Contributors that can be sorted
|
||||||
|
type ContributorList []Contributor
|
||||||
|
|
||||||
|
// Len is the length of the ContributorList
|
||||||
|
func (cl ContributorList) Len() int {
|
||||||
|
return len(cl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less determines whether a Contributor comes before another Contributor
|
||||||
|
func (cl ContributorList) Less(i, j int) bool {
|
||||||
|
return cl[i].Name < cl[j].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap swaps Contributors in a ContributorList
|
||||||
|
func (cl ContributorList) Swap(i, j int) {
|
||||||
|
cl[i], cl[j] = cl[j], cl[i]
|
||||||
|
}
|
14
service/service_test.go
Normal file
14
service/service_test.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2020 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user