Compare commits
4 Commits
v0.261.3-1
...
9924aea786
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9924aea786 | ||
|
|
65c232c4a5 | ||
|
|
5da4954b65 | ||
|
|
ec091ad269 |
@@ -46,7 +46,6 @@ type Input struct {
|
|||||||
artifactServerPort string
|
artifactServerPort string
|
||||||
noCacheServer bool
|
noCacheServer bool
|
||||||
cacheServerPath string
|
cacheServerPath string
|
||||||
cacheServerAdvertiseURL string
|
|
||||||
cacheServerAddr string
|
cacheServerAddr string
|
||||||
cacheServerPort uint16
|
cacheServerPort uint16
|
||||||
jsonLogger bool
|
jsonLogger bool
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ func Execute(ctx context.Context, version string) {
|
|||||||
rootCmd.PersistentFlags().BoolVarP(&input.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
rootCmd.PersistentFlags().BoolVarP(&input.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
||||||
rootCmd.PersistentFlags().BoolVarP(&input.noCacheServer, "no-cache-server", "", false, "Disable cache server")
|
rootCmd.PersistentFlags().BoolVarP(&input.noCacheServer, "no-cache-server", "", false, "Disable cache server")
|
||||||
rootCmd.PersistentFlags().StringVarP(&input.cacheServerPath, "cache-server-path", "", filepath.Join(CacheHomeDir, "actcache"), "Defines the path where the cache server stores caches.")
|
rootCmd.PersistentFlags().StringVarP(&input.cacheServerPath, "cache-server-path", "", filepath.Join(CacheHomeDir, "actcache"), "Defines the path where the cache server stores caches.")
|
||||||
rootCmd.PersistentFlags().StringVarP(&input.cacheServerAdvertiseURL, "cache-server-advertise-url", "", "", "Defines the URL for advertising the cache server behind a proxy. e.g.: https://act-cache-server.example.com")
|
|
||||||
rootCmd.PersistentFlags().StringVarP(&input.cacheServerAddr, "cache-server-addr", "", common.GetOutboundIP().String(), "Defines the address to which the cache server binds.")
|
rootCmd.PersistentFlags().StringVarP(&input.cacheServerAddr, "cache-server-addr", "", common.GetOutboundIP().String(), "Defines the address to which the cache server binds.")
|
||||||
rootCmd.PersistentFlags().Uint16VarP(&input.cacheServerPort, "cache-server-port", "", 0, "Defines the port where the artifact server listens. 0 means a randomly available port.")
|
rootCmd.PersistentFlags().Uint16VarP(&input.cacheServerPort, "cache-server-port", "", 0, "Defines the port where the artifact server listens. 0 means a randomly available port.")
|
||||||
rootCmd.PersistentFlags().StringVarP(&input.actionCachePath, "action-cache-path", "", filepath.Join(CacheHomeDir, "act"), "Defines the path where the actions get cached and host workspaces created.")
|
rootCmd.PersistentFlags().StringVarP(&input.actionCachePath, "action-cache-path", "", filepath.Join(CacheHomeDir, "act"), "Defines the path where the actions get cached and host workspaces created.")
|
||||||
@@ -599,7 +598,7 @@ func newRunCommand(ctx context.Context, input *Input) func(*cobra.Command, []str
|
|||||||
var cacheHandler *artifactcache.Handler
|
var cacheHandler *artifactcache.Handler
|
||||||
if !input.noCacheServer && envs[cacheURLKey] == "" {
|
if !input.noCacheServer && envs[cacheURLKey] == "" {
|
||||||
var err error
|
var err error
|
||||||
cacheHandler, err = artifactcache.StartHandler(input.cacheServerPath, input.cacheServerAdvertiseURL, input.cacheServerAddr, input.cacheServerPort, common.Logger(ctx))
|
cacheHandler, err = artifactcache.StartHandler(input.cacheServerPath, input.cacheServerAddr, input.cacheServerPort, common.Logger(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,10 @@ type Handler struct {
|
|||||||
gcing atomic.Bool
|
gcing atomic.Bool
|
||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
|
|
||||||
outboundIP string
|
outboundIP string
|
||||||
advertiseURL string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartHandler(dir, advertiseURL, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
|
func StartHandler(dir, outboundIP string, port uint16, logger logrus.FieldLogger) (*Handler, error) {
|
||||||
h := &Handler{}
|
h := &Handler{}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
@@ -72,8 +71,6 @@ func StartHandler(dir, advertiseURL, outboundIP string, port uint16, logger logr
|
|||||||
}
|
}
|
||||||
h.storage = storage
|
h.storage = storage
|
||||||
|
|
||||||
h.advertiseURL = advertiseURL
|
|
||||||
|
|
||||||
if outboundIP != "" {
|
if outboundIP != "" {
|
||||||
h.outboundIP = outboundIP
|
h.outboundIP = outboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
} else if ip := common.GetOutboundIP(); ip == nil {
|
||||||
@@ -114,13 +111,10 @@ func StartHandler(dir, advertiseURL, outboundIP string, port uint16, logger logr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
if h.advertiseURL != "" {
|
// TODO: make the external url configurable if necessary
|
||||||
return h.advertiseURL
|
return fmt.Sprintf("http://%s:%d",
|
||||||
} else {
|
h.outboundIP,
|
||||||
return fmt.Sprintf("http://%s:%d",
|
h.listener.Addr().(*net.TCPAddr).Port)
|
||||||
h.outboundIP,
|
|
||||||
h.listener.Addr().(*net.TCPAddr).Port)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Close() error {
|
func (h *Handler) Close() error {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
|
|
||||||
func TestHandler(t *testing.T) {
|
func TestHandler(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", "", 0, nil)
|
handler, err := StartHandler(dir, "", 0, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
|
base := fmt.Sprintf("%s%s", handler.ExternalURL(), urlBase)
|
||||||
@@ -589,7 +589,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
|
|||||||
|
|
||||||
func TestHandler_gcCache(t *testing.T) {
|
func TestHandler_gcCache(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "artifactcache")
|
dir := filepath.Join(t.TempDir(), "artifactcache")
|
||||||
handler, err := StartHandler(dir, "", "", 0, nil)
|
handler, err := StartHandler(dir, "", 0, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func NewInterpeter(
|
|||||||
gitCtx *model.GithubContext,
|
gitCtx *model.GithubContext,
|
||||||
results map[string]*JobResult,
|
results map[string]*JobResult,
|
||||||
vars map[string]string,
|
vars map[string]string,
|
||||||
|
inputs map[string]interface{},
|
||||||
) exprparser.Interpreter {
|
) exprparser.Interpreter {
|
||||||
strategy := make(map[string]interface{})
|
strategy := make(map[string]interface{})
|
||||||
if job.Strategy != nil {
|
if job.Strategy != nil {
|
||||||
@@ -62,7 +63,7 @@ func NewInterpeter(
|
|||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
Needs: using,
|
Needs: using,
|
||||||
Inputs: nil, // not supported yet
|
Inputs: inputs,
|
||||||
Vars: vars,
|
Vars: vars,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/nektos/act/pkg/exprparser"
|
||||||
"github.com/nektos/act/pkg/model"
|
"github.com/nektos/act/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,10 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid jobs: %w", err)
|
return nil, fmt.Errorf("invalid jobs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars}, exprparser.Config{}))
|
||||||
|
workflow.RunName = evaluator.Interpolate(workflow.RunName)
|
||||||
|
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
job := jobs[i]
|
job := jobs[i]
|
||||||
matricxes, err := getMatrixes(origin.GetJob(id))
|
matricxes, err := getMatrixes(origin.GetJob(id))
|
||||||
@@ -52,7 +57,7 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
job.Name = id
|
job.Name = id
|
||||||
}
|
}
|
||||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||||
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars))
|
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, nil))
|
||||||
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
||||||
runsOn := origin.GetJob(id).RunsOn()
|
runsOn := origin.GetJob(id).RunsOn()
|
||||||
for i, v := range runsOn {
|
for i, v := range runsOn {
|
||||||
@@ -60,10 +65,12 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
|||||||
}
|
}
|
||||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||||
swf := &SingleWorkflow{
|
swf := &SingleWorkflow{
|
||||||
Name: workflow.Name,
|
Name: workflow.Name,
|
||||||
RawOn: workflow.RawOn,
|
RawOn: workflow.RawOn,
|
||||||
Env: workflow.Env,
|
Env: workflow.Env,
|
||||||
Defaults: workflow.Defaults,
|
Defaults: workflow.Defaults,
|
||||||
|
RawPermissions: workflow.RawPermissions,
|
||||||
|
RunName: workflow.RunName,
|
||||||
}
|
}
|
||||||
if err := swf.SetJob(id, job); err != nil {
|
if err := swf.SetJob(id, job); err != nil {
|
||||||
return nil, fmt.Errorf("SetJob: %w", err)
|
return nil, fmt.Errorf("SetJob: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package jobparser
|
package jobparser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/nektos/act/pkg/model"
|
"github.com/nektos/act/pkg/model"
|
||||||
@@ -9,11 +10,13 @@ import (
|
|||||||
|
|
||||||
// SingleWorkflow is a workflow with single job and single matrix
|
// SingleWorkflow is a workflow with single job and single matrix
|
||||||
type SingleWorkflow struct {
|
type SingleWorkflow struct {
|
||||||
Name string `yaml:"name,omitempty"`
|
Name string `yaml:"name,omitempty"`
|
||||||
RawOn yaml.Node `yaml:"on,omitempty"`
|
RawOn yaml.Node `yaml:"on,omitempty"`
|
||||||
Env map[string]string `yaml:"env,omitempty"`
|
Env map[string]string `yaml:"env,omitempty"`
|
||||||
RawJobs yaml.Node `yaml:"jobs,omitempty"`
|
RawJobs yaml.Node `yaml:"jobs,omitempty"`
|
||||||
Defaults Defaults `yaml:"defaults,omitempty"`
|
Defaults Defaults `yaml:"defaults,omitempty"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
|
||||||
|
RunName string `yaml:"run-name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *SingleWorkflow) Job() (string, *Job) {
|
func (w *SingleWorkflow) Job() (string, *Job) {
|
||||||
@@ -82,6 +85,8 @@ type Job struct {
|
|||||||
Uses string `yaml:"uses,omitempty"`
|
Uses string `yaml:"uses,omitempty"`
|
||||||
With map[string]interface{} `yaml:"with,omitempty"`
|
With map[string]interface{} `yaml:"with,omitempty"`
|
||||||
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
|
RawSecrets yaml.Node `yaml:"secrets,omitempty"`
|
||||||
|
RawConcurrency *model.RawConcurrency `yaml:"concurrency,omitempty"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *Job) Clone() *Job {
|
func (j *Job) Clone() *Job {
|
||||||
@@ -104,6 +109,8 @@ func (j *Job) Clone() *Job {
|
|||||||
Uses: j.Uses,
|
Uses: j.Uses,
|
||||||
With: j.With,
|
With: j.With,
|
||||||
RawSecrets: j.RawSecrets,
|
RawSecrets: j.RawSecrets,
|
||||||
|
RawConcurrency: j.RawConcurrency,
|
||||||
|
RawPermissions: j.RawPermissions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +248,73 @@ func parseWorkflowDispatchInputs(inputs map[string]interface{}) ([]WorkflowDispa
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
|
||||||
|
w := new(model.Workflow)
|
||||||
|
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
|
||||||
|
return w.RawConcurrency, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
|
||||||
|
actJob := &model.Job{}
|
||||||
|
if job != nil {
|
||||||
|
actJob.Strategy = &model.Strategy{
|
||||||
|
FailFastString: job.Strategy.FailFastString,
|
||||||
|
MaxParallelString: job.Strategy.MaxParallelString,
|
||||||
|
RawMatrix: job.Strategy.RawMatrix,
|
||||||
|
}
|
||||||
|
actJob.Strategy.FailFast = actJob.Strategy.GetFailFast()
|
||||||
|
actJob.Strategy.MaxParallel = actJob.Strategy.GetMaxParallel()
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix := make(map[string]any)
|
||||||
|
matrixes, err := actJob.GetMatrixes()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if len(matrixes) > 0 {
|
||||||
|
matrix = matrixes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||||
|
group := evaluator.Interpolate(rc.Group)
|
||||||
|
cancelInProgress := evaluator.Interpolate(rc.CancelInProgress)
|
||||||
|
return group, cancelInProgress == "true", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toGitContext(input map[string]any) *model.GithubContext {
|
||||||
|
gitContext := &model.GithubContext{
|
||||||
|
EventPath: asString(input["event_path"]),
|
||||||
|
Workflow: asString(input["workflow"]),
|
||||||
|
RunID: asString(input["run_id"]),
|
||||||
|
RunNumber: asString(input["run_number"]),
|
||||||
|
Actor: asString(input["actor"]),
|
||||||
|
Repository: asString(input["repository"]),
|
||||||
|
EventName: asString(input["event_name"]),
|
||||||
|
Sha: asString(input["sha"]),
|
||||||
|
Ref: asString(input["ref"]),
|
||||||
|
RefName: asString(input["ref_name"]),
|
||||||
|
RefType: asString(input["ref_type"]),
|
||||||
|
HeadRef: asString(input["head_ref"]),
|
||||||
|
BaseRef: asString(input["base_ref"]),
|
||||||
|
Token: asString(input["token"]),
|
||||||
|
Workspace: asString(input["workspace"]),
|
||||||
|
Action: asString(input["action"]),
|
||||||
|
ActionPath: asString(input["action_path"]),
|
||||||
|
ActionRef: asString(input["action_ref"]),
|
||||||
|
ActionRepository: asString(input["action_repository"]),
|
||||||
|
Job: asString(input["job"]),
|
||||||
|
RepositoryOwner: asString(input["repository_owner"]),
|
||||||
|
RetentionDays: asString(input["retention_days"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
event, ok := input["event"].(map[string]any)
|
||||||
|
if ok {
|
||||||
|
gitContext.Event = event
|
||||||
|
}
|
||||||
|
|
||||||
|
return gitContext
|
||||||
|
}
|
||||||
|
|
||||||
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
||||||
switch rawOn.Kind {
|
switch rawOn.Kind {
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
@@ -335,6 +409,13 @@ func ParseRawOn(rawOn *yaml.Node) ([]*Event, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
acts[act] = t
|
acts[act] = t
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
var t string
|
||||||
|
err := content.Decode(&t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
acts[act] = []string{t}
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
if k != "workflow_dispatch" || act != "inputs" {
|
if k != "workflow_dispatch" || act != "inputs" {
|
||||||
return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content)
|
return nil, fmt.Errorf("map should only for workflow_dispatch but %s: %#v", act, content)
|
||||||
@@ -422,3 +503,12 @@ func parseMappingNode[T any](node *yaml.Node) ([]string, []T, error) {
|
|||||||
|
|
||||||
return scalars, datas, nil
|
return scalars, datas, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func asString(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
} else if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,19 @@ func TestParseRawOn(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
input: "on:\n push:\n branches: main",
|
||||||
|
result: []*Event{
|
||||||
|
{
|
||||||
|
Name: "push",
|
||||||
|
acts: map[string][]string{
|
||||||
|
"branches": {
|
||||||
|
"main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
input: "on:\n branch_protection_rule:\n types: [created, deleted]",
|
||||||
result: []*Event{
|
result: []*Event{
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import (
|
|||||||
|
|
||||||
// Workflow is the structure of the files in .github/workflows
|
// Workflow is the structure of the files in .github/workflows
|
||||||
type Workflow struct {
|
type Workflow struct {
|
||||||
File string
|
File string
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
RawOn yaml.Node `yaml:"on"`
|
RawOn yaml.Node `yaml:"on"`
|
||||||
Env map[string]string `yaml:"env"`
|
Env map[string]string `yaml:"env"`
|
||||||
Jobs map[string]*Job `yaml:"jobs"`
|
Jobs map[string]*Job `yaml:"jobs"`
|
||||||
Defaults Defaults `yaml:"defaults"`
|
Defaults Defaults `yaml:"defaults"`
|
||||||
|
RawConcurrency *RawConcurrency `yaml:"concurrency"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// On events for the workflow
|
// On events for the workflow
|
||||||
@@ -199,6 +201,7 @@ type Job struct {
|
|||||||
Uses string `yaml:"uses"`
|
Uses string `yaml:"uses"`
|
||||||
With map[string]interface{} `yaml:"with"`
|
With map[string]interface{} `yaml:"with"`
|
||||||
RawSecrets yaml.Node `yaml:"secrets"`
|
RawSecrets yaml.Node `yaml:"secrets"`
|
||||||
|
RawPermissions yaml.Node `yaml:"permissions"`
|
||||||
Result string
|
Result string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,3 +772,10 @@ func decodeNode(node yaml.Node, out interface{}) bool {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// RawConcurrency represents a workflow concurrency or a job concurrency with uninterpolated options
|
||||||
|
type RawConcurrency struct {
|
||||||
|
Group string `yaml:"group,omitempty"`
|
||||||
|
CancelInProgress string `yaml:"cancel-in-progress,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user