You've already forked goreleaser
mirror of
https://github.com/goreleaser/goreleaser.git
synced 2025-07-07 01:07:10 +02:00
feat(github): allow to PR cross-repo (#4053)
This allows to open pull requests across repositories on nix, brew, krew and scoop. closes #4048 --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a3bc051933
commit
773cb91a7a
@ -73,7 +73,7 @@ type ReleaseNotesGenerator interface {
|
|||||||
|
|
||||||
// PullRequestOpener can open pull requests.
|
// PullRequestOpener can open pull requests.
|
||||||
type PullRequestOpener interface {
|
type PullRequestOpener interface {
|
||||||
OpenPullRequest(ctx *context.Context, repo Repo, head, title string) error
|
OpenPullRequest(ctx *context.Context, base, head Repo, title string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new client depending on the token type.
|
// New creates a new client depending on the token type.
|
||||||
|
@ -158,23 +158,35 @@ func (c *githubClient) CloseMilestone(ctx *context.Context, repo Repo, title str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func headString(base, head Repo) string {
|
||||||
|
return strings.Join([]string{
|
||||||
|
firstNonEmpty(head.Owner, base.Owner),
|
||||||
|
firstNonEmpty(head.Name, base.Name),
|
||||||
|
firstNonEmpty(head.Branch, base.Branch),
|
||||||
|
}, ":")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *githubClient) OpenPullRequest(
|
func (c *githubClient) OpenPullRequest(
|
||||||
ctx *context.Context,
|
ctx *context.Context,
|
||||||
repo Repo,
|
base, head Repo,
|
||||||
base, title string,
|
title string,
|
||||||
) error {
|
) error {
|
||||||
c.checkRateLimit(ctx)
|
c.checkRateLimit(ctx)
|
||||||
if base == "" {
|
if base.Branch == "" {
|
||||||
def, err := c.getDefaultBranch(ctx, repo)
|
def, err := c.getDefaultBranch(ctx, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
base = def
|
base.Branch = def
|
||||||
}
|
}
|
||||||
pr, res, err := c.client.PullRequests.Create(ctx, repo.Owner, repo.Name, &github.NewPullRequest{
|
log := log.
|
||||||
|
WithField("base", headString(base, Repo{})).
|
||||||
|
WithField("head", headString(base, head))
|
||||||
|
log.Info("opening pull request")
|
||||||
|
pr, res, err := c.client.PullRequests.Create(ctx, base.Owner, base.Name, &github.NewPullRequest{
|
||||||
Title: github.String(title),
|
Title: github.String(title),
|
||||||
Head: github.String(repo.Branch),
|
Base: github.String(base.Branch),
|
||||||
Base: github.String(base),
|
Head: github.String(headString(base, head)),
|
||||||
Body: github.String("Automatically generated by [GoReleaser](https://goreleaser.com)"),
|
Body: github.String("Automatically generated by [GoReleaser](https://goreleaser.com)"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -224,8 +236,7 @@ func (c *githubClient) CreateFile(
|
|||||||
|
|
||||||
log.
|
log.
|
||||||
WithField("repository", repo.String()).
|
WithField("repository", repo.String()).
|
||||||
WithField("name", repo.Name).
|
WithField("branch", repo.Branch).
|
||||||
WithField("name", repo.Name).
|
|
||||||
Info("pushing")
|
Info("pushing")
|
||||||
|
|
||||||
if defBranch != branch && branch != "" {
|
if defBranch != branch && branch != "" {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -404,6 +405,54 @@ func TestCloseMilestone(t *testing.T) {
|
|||||||
require.NoError(t, client.CloseMilestone(ctx, repo, "v1.13.0"))
|
require.NoError(t, client.CloseMilestone(ctx, repo, "v1.13.0"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenPullRequestCrossRepo(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if r.URL.Path == "/repos/someone/something/pulls" {
|
||||||
|
got, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var pr github.NewPullRequest
|
||||||
|
require.NoError(t, json.Unmarshal(got, &pr))
|
||||||
|
require.Equal(t, "main", pr.GetBase())
|
||||||
|
require.Equal(t, "someoneelse:something:foo", pr.GetHead())
|
||||||
|
r, err := os.Open("testdata/github/pull.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == "/rate_limit" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"resources":{"core":{"remaining":120}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Error("unhandled request: " + r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ctx := testctx.NewWithCfg(config.Project{
|
||||||
|
GitHubURLs: config.GitHubURLs{
|
||||||
|
API: srv.URL + "/",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
client, err := newGitHub(ctx, "test-token")
|
||||||
|
require.NoError(t, err)
|
||||||
|
base := Repo{
|
||||||
|
Owner: "someone",
|
||||||
|
Name: "something",
|
||||||
|
Branch: "main",
|
||||||
|
}
|
||||||
|
head := Repo{
|
||||||
|
Owner: "someoneelse",
|
||||||
|
Name: "something",
|
||||||
|
Branch: "foo",
|
||||||
|
}
|
||||||
|
require.NoError(t, client.OpenPullRequest(ctx, base, head, "some title"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenPullRequestHappyPath(t *testing.T) {
|
func TestOpenPullRequestHappyPath(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
@ -436,9 +485,62 @@ func TestOpenPullRequestHappyPath(t *testing.T) {
|
|||||||
repo := Repo{
|
repo := Repo{
|
||||||
Owner: "someone",
|
Owner: "someone",
|
||||||
Name: "something",
|
Name: "something",
|
||||||
|
Branch: "main",
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, client.OpenPullRequest(ctx, repo, "main", "some title"))
|
require.NoError(t, client.OpenPullRequest(ctx, repo, Repo{}, "some title"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenPullRequestNoBaseBranch(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
if r.URL.Path == "/repos/someone/something/pulls" {
|
||||||
|
got, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var pr github.NewPullRequest
|
||||||
|
require.NoError(t, json.Unmarshal(got, &pr))
|
||||||
|
require.Equal(t, "main", pr.GetBase())
|
||||||
|
require.Equal(t, "someone:something:foo", pr.GetHead())
|
||||||
|
|
||||||
|
r, err := os.Open("testdata/github/pull.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.Copy(w, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == "/repos/someone/something" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"default_branch": "main"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Path == "/rate_limit" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{"resources":{"core":{"remaining":120}}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Error("unhandled request: " + r.URL.Path)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ctx := testctx.NewWithCfg(config.Project{
|
||||||
|
GitHubURLs: config.GitHubURLs{
|
||||||
|
API: srv.URL + "/",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
client, err := newGitHub(ctx, "test-token")
|
||||||
|
require.NoError(t, err)
|
||||||
|
repo := Repo{
|
||||||
|
Owner: "someone",
|
||||||
|
Name: "something",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, client.OpenPullRequest(ctx, repo, Repo{
|
||||||
|
Branch: "foo",
|
||||||
|
}, "some title"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenPullRequestPRExists(t *testing.T) {
|
func TestOpenPullRequestPRExists(t *testing.T) {
|
||||||
@ -474,9 +576,10 @@ func TestOpenPullRequestPRExists(t *testing.T) {
|
|||||||
repo := Repo{
|
repo := Repo{
|
||||||
Owner: "someone",
|
Owner: "someone",
|
||||||
Name: "something",
|
Name: "something",
|
||||||
|
Branch: "main",
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, client.OpenPullRequest(ctx, repo, "main", "some title"))
|
require.NoError(t, client.OpenPullRequest(ctx, repo, Repo{}, "some title"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenPullRequestBaseEmpty(t *testing.T) {
|
func TestOpenPullRequestBaseEmpty(t *testing.T) {
|
||||||
@ -517,9 +620,10 @@ func TestOpenPullRequestBaseEmpty(t *testing.T) {
|
|||||||
repo := Repo{
|
repo := Repo{
|
||||||
Owner: "someone",
|
Owner: "someone",
|
||||||
Name: "something",
|
Name: "something",
|
||||||
|
Branch: "main",
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, client.OpenPullRequest(ctx, repo, "", "some title"))
|
require.NoError(t, client.OpenPullRequest(ctx, repo, Repo{}, "some title"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitHubCreateFileHappyPathCreate(t *testing.T) {
|
func TestGitHubCreateFileHappyPathCreate(t *testing.T) {
|
||||||
|
@ -42,7 +42,7 @@ type Mock struct {
|
|||||||
OpenedPullRequest bool
|
OpenedPullRequest bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Mock) OpenPullRequest(_ *context.Context, _ Repo, _, _ string) error {
|
func (c *Mock) OpenPullRequest(_ *context.Context, _, _ Repo, _ string) error {
|
||||||
c.OpenedPullRequest = true
|
c.OpenedPullRequest = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -171,7 +171,11 @@ func doPublish(ctx *context.Context, formula *artifact.Artifact, cl client.Clien
|
|||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
||||||
return pcl.OpenPullRequest(ctx, repo, brew.Tap.PullRequest.Base, title)
|
return pcl.OpenPullRequest(ctx, client.Repo{
|
||||||
|
Name: brew.Tap.PullRequest.Base.Name,
|
||||||
|
Owner: brew.Tap.PullRequest.Base.Owner,
|
||||||
|
Branch: brew.Tap.PullRequest.Base.Branch,
|
||||||
|
}, repo, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doRun(ctx *context.Context, brew config.Homebrew, cl client.ReleaserURLTemplater) error {
|
func doRun(ctx *context.Context, brew config.Homebrew, cl client.ReleaserURLTemplater) error {
|
||||||
|
@ -354,7 +354,11 @@ func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Clie
|
|||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
||||||
return pcl.OpenPullRequest(ctx, repo, cfg.Index.PullRequest.Base, title)
|
return pcl.OpenPullRequest(ctx, client.Repo{
|
||||||
|
Name: cfg.Index.PullRequest.Base.Name,
|
||||||
|
Owner: cfg.Index.PullRequest.Base.Owner,
|
||||||
|
Branch: cfg.Index.PullRequest.Base.Branch,
|
||||||
|
}, repo, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildManifestPath(folder, filename string) string {
|
func buildManifestPath(folder, filename string) string {
|
||||||
|
@ -363,7 +363,11 @@ func doPublish(ctx *context.Context, prefetcher shaPrefetcher, cl client.Client,
|
|||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
||||||
return pcl.OpenPullRequest(ctx, repo, nix.Repository.PullRequest.Base, title)
|
return pcl.OpenPullRequest(ctx, client.Repo{
|
||||||
|
Name: nix.Repository.PullRequest.Base.Name,
|
||||||
|
Owner: nix.Repository.PullRequest.Base.Owner,
|
||||||
|
Branch: nix.Repository.PullRequest.Base.Branch,
|
||||||
|
}, repo, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func doBuildPkg(ctx *context.Context, data templateData) (string, error) {
|
func doBuildPkg(ctx *context.Context, data templateData) (string, error) {
|
||||||
|
@ -270,7 +270,11 @@ func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Clie
|
|||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
title := fmt.Sprintf("Updated %s to %s", ctx.Config.ProjectName, ctx.Version)
|
||||||
return pcl.OpenPullRequest(ctx, repo, scoop.Bucket.PullRequest.Base, title)
|
return pcl.OpenPullRequest(ctx, client.Repo{
|
||||||
|
Name: scoop.Bucket.PullRequest.Base.Name,
|
||||||
|
Owner: scoop.Bucket.PullRequest.Base.Owner,
|
||||||
|
Branch: scoop.Bucket.PullRequest.Base.Branch,
|
||||||
|
}, repo, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest represents a scoop.sh App Manifest.
|
// Manifest represents a scoop.sh App Manifest.
|
||||||
|
@ -95,9 +95,53 @@ type GitRepoRef struct {
|
|||||||
PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"`
|
PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PullRequestBase struct {
|
||||||
|
Owner string `yaml:"owner,omitempty" json:"owner,omitempty"`
|
||||||
|
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||||
|
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type alias to prevent stack overflowing in the custom unmarshaler.
|
||||||
|
type pullRequestBase PullRequestBase
|
||||||
|
|
||||||
|
// UnmarshalYAML is a custom unmarshaler that accept brew deps in both the old and new format.
|
||||||
|
func (a *PullRequestBase) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
var str string
|
||||||
|
if err := unmarshal(&str); err == nil {
|
||||||
|
a.Branch = str
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var base pullRequestBase
|
||||||
|
if err := unmarshal(&base); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.Branch = base.Branch
|
||||||
|
a.Owner = base.Owner
|
||||||
|
a.Name = base.Name
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a PullRequestBase) JSONSchema() *jsonschema.Schema {
|
||||||
|
reflector := jsonschema.Reflector{
|
||||||
|
ExpandedStruct: true,
|
||||||
|
}
|
||||||
|
schema := reflector.Reflect(&pullRequestBase{})
|
||||||
|
return &jsonschema.Schema{
|
||||||
|
OneOf: []*jsonschema.Schema{
|
||||||
|
{
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
schema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PullRequest struct {
|
type PullRequest struct {
|
||||||
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||||
Base string `yaml:"base,omitempty" json:"base,omitempty"`
|
Base PullRequestBase `yaml:"base,omitempty" json:"base,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HomebrewDependency represents Homebrew dependency.
|
// HomebrewDependency represents Homebrew dependency.
|
||||||
|
@ -78,10 +78,20 @@ brews:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Base branch of the PR.
|
# Base branch of the PR.
|
||||||
|
# If base is a string, the PR will be opened into the same repository.
|
||||||
#
|
#
|
||||||
# Default: default repository branch.
|
# Default: default repository branch.
|
||||||
base: main
|
base: main
|
||||||
|
|
||||||
|
# Base can also be another repository, in which case the owner and name
|
||||||
|
# above will be used as HEAD, allowing cross-repository pull requests.
|
||||||
|
#
|
||||||
|
# Since: v1.19
|
||||||
|
base:
|
||||||
|
owner: org
|
||||||
|
name: nur
|
||||||
|
branch: main
|
||||||
|
|
||||||
# Clone, create the file, commit and push, to a regular Git repository.
|
# Clone, create the file, commit and push, to a regular Git repository.
|
||||||
#
|
#
|
||||||
# Notice that this will only have any effect if the given URL is not
|
# Notice that this will only have any effect if the given URL is not
|
||||||
|
@ -73,10 +73,20 @@ krews:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Base branch of the PR.
|
# Base branch of the PR.
|
||||||
|
# If base is a string, the PR will be opened into the same repository.
|
||||||
#
|
#
|
||||||
# Default: default repository branch.
|
# Default: default repository branch.
|
||||||
base: main
|
base: main
|
||||||
|
|
||||||
|
# Base can also be another repository, in which case the owner and name
|
||||||
|
# above will be used as HEAD, allowing cross-repository pull requests.
|
||||||
|
#
|
||||||
|
# Since: v1.19
|
||||||
|
base:
|
||||||
|
owner: org
|
||||||
|
name: nur
|
||||||
|
branch: main
|
||||||
|
|
||||||
# Clone, create the file, commit and push, to a regular Git repository.
|
# Clone, create the file, commit and push, to a regular Git repository.
|
||||||
#
|
#
|
||||||
# Notice that this will only have any effect if the given URL is not
|
# Notice that this will only have any effect if the given URL is not
|
||||||
|
@ -47,7 +47,7 @@ nix:
|
|||||||
#
|
#
|
||||||
# Default: default repository branch.
|
# Default: default repository branch.
|
||||||
# Templates: allowed
|
# Templates: allowed
|
||||||
branch: main
|
branch: foo
|
||||||
|
|
||||||
# Optionally a token can be provided, if it differs from the token
|
# Optionally a token can be provided, if it differs from the token
|
||||||
# provided to GoReleaser
|
# provided to GoReleaser
|
||||||
@ -63,10 +63,20 @@ nix:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Base branch of the PR.
|
# Base branch of the PR.
|
||||||
|
# If base is a string, the PR will be opened into the same repository.
|
||||||
#
|
#
|
||||||
# Default: default repository branch.
|
# Default: default repository branch.
|
||||||
base: main
|
base: main
|
||||||
|
|
||||||
|
# Base can also be another repository, in which case the owner and name
|
||||||
|
# above will be used as HEAD, allowing cross-repository pull requests.
|
||||||
|
#
|
||||||
|
# Since: v1.19
|
||||||
|
base:
|
||||||
|
owner: org
|
||||||
|
name: nur
|
||||||
|
branch: main
|
||||||
|
|
||||||
# Clone, create the file, commit and push, to a regular Git repository.
|
# Clone, create the file, commit and push, to a regular Git repository.
|
||||||
#
|
#
|
||||||
# Notice that this will only have any effect if the given URL is not
|
# Notice that this will only have any effect if the given URL is not
|
||||||
|
@ -60,10 +60,20 @@ scoops:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Base branch of the PR.
|
# Base branch of the PR.
|
||||||
|
# If base is a string, the PR will be opened into the same repository.
|
||||||
#
|
#
|
||||||
# Default: default repository branch.
|
# Default: default repository branch.
|
||||||
base: main
|
base: main
|
||||||
|
|
||||||
|
# Base can also be another repository, in which case the owner and name
|
||||||
|
# above will be used as HEAD, allowing cross-repository pull requests.
|
||||||
|
#
|
||||||
|
# Since: v1.19
|
||||||
|
base:
|
||||||
|
owner: org
|
||||||
|
name: nur
|
||||||
|
branch: main
|
||||||
|
|
||||||
# Clone, create the file, commit and push, to a regular Git repository.
|
# Clone, create the file, commit and push, to a regular Git repository.
|
||||||
#
|
#
|
||||||
# Notice that this will only have any effect if the given URL is not
|
# Notice that this will only have any effect if the given URL is not
|
||||||
|
Reference in New Issue
Block a user