github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/workflow/run/run.go (about)

     1  package run
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io/ioutil"
     9  	"net/http"
    10  	"reflect"
    11  	"sort"
    12  	"strings"
    13  
    14  	"github.com/AlecAivazis/survey/v2"
    15  	"github.com/MakeNowJust/heredoc"
    16  	"github.com/cli/cli/api"
    17  	"github.com/cli/cli/internal/ghrepo"
    18  	"github.com/cli/cli/pkg/cmd/workflow/shared"
    19  	"github.com/cli/cli/pkg/cmdutil"
    20  	"github.com/cli/cli/pkg/iostreams"
    21  	"github.com/cli/cli/pkg/prompt"
    22  	"github.com/spf13/cobra"
    23  	"gopkg.in/yaml.v3"
    24  )
    25  
    26  type RunOptions struct {
    27  	HttpClient func() (*http.Client, error)
    28  	IO         *iostreams.IOStreams
    29  	BaseRepo   func() (ghrepo.Interface, error)
    30  
    31  	Selector  string
    32  	Ref       string
    33  	JSONInput string
    34  	JSON      bool
    35  
    36  	MagicFields []string
    37  	RawFields   []string
    38  
    39  	Prompt bool
    40  }
    41  
    42  func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
    43  	opts := &RunOptions{
    44  		IO:         f.IOStreams,
    45  		HttpClient: f.HttpClient,
    46  	}
    47  
    48  	cmd := &cobra.Command{
    49  		Use:   "run [<workflow-id> | <workflow-name>]",
    50  		Short: "Run a workflow by creating a workflow_dispatch event",
    51  		Long: heredoc.Doc(`
    52  			Create a workflow_dispatch event for a given workflow.
    53  
    54  			This command will trigger GitHub Actions to run a given workflow file.  The given workflow file must
    55  			support a workflow_dispatch 'on' trigger in order to be run in this way.
    56  
    57  			If the workflow file supports inputs, they can be specified in a few ways:
    58  
    59  			- Interactively
    60  			- via -f or -F flags
    61  			- As JSON, via STDIN
    62      `),
    63  		Example: heredoc.Doc(`
    64  			# Have gh prompt you for what workflow you'd like to run and interactively collect inputs
    65  			$ gh workflow run
    66  
    67  			# Run the workflow file 'triage.yml' at the remote's default branch
    68  			$ gh workflow run triage.yml
    69  
    70  			# Run the workflow file 'triage.yml' at a specified ref
    71  			$ gh workflow run triage.yml --ref my-branch
    72  
    73  			# Run the workflow file 'triage.yml' with command line inputs
    74  			$ gh workflow run triage.yml -f name=scully -f greeting=hello
    75  
    76  			# Run the workflow file 'triage.yml' with JSON via standard input
    77  			$ echo '{"name":"scully", "greeting":"hello"}' | gh workflow run triage.yml --json
    78  		`),
    79  		Args: func(cmd *cobra.Command, args []string) error {
    80  			if len(opts.MagicFields)+len(opts.RawFields) > 0 && len(args) == 0 {
    81  				return cmdutil.FlagError{Err: fmt.Errorf("workflow argument required when passing -f or -F")}
    82  			}
    83  			return nil
    84  		},
    85  		RunE: func(cmd *cobra.Command, args []string) error {
    86  			// support `-R, --repo` override
    87  			opts.BaseRepo = f.BaseRepo
    88  
    89  			inputFieldsPassed := len(opts.MagicFields)+len(opts.RawFields) > 0
    90  
    91  			if len(args) > 0 {
    92  				opts.Selector = args[0]
    93  			} else if !opts.IO.CanPrompt() {
    94  				return &cmdutil.FlagError{Err: errors.New("workflow ID, name, or filename required when not running interactively")}
    95  			} else {
    96  				opts.Prompt = true
    97  			}
    98  
    99  			if opts.JSON && !opts.IO.IsStdinTTY() {
   100  				jsonIn, err := ioutil.ReadAll(opts.IO.In)
   101  				if err != nil {
   102  					return errors.New("failed to read from STDIN")
   103  				}
   104  				opts.JSONInput = string(jsonIn)
   105  			} else if opts.JSON {
   106  				return cmdutil.FlagError{Err: errors.New("--json specified but nothing on STDIN")}
   107  			}
   108  
   109  			if opts.Selector == "" {
   110  				if opts.JSONInput != "" {
   111  					return &cmdutil.FlagError{Err: errors.New("workflow argument required when passing JSON")}
   112  				}
   113  			} else {
   114  				if opts.JSON && inputFieldsPassed {
   115  					return &cmdutil.FlagError{Err: errors.New("only one of STDIN or -f/-F can be passed")}
   116  				}
   117  			}
   118  
   119  			if runF != nil {
   120  				return runF(opts)
   121  			}
   122  
   123  			return runRun(opts)
   124  		},
   125  	}
   126  	cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to run")
   127  	cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a string parameter in `key=value` format, respecting @ syntax")
   128  	cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
   129  	cmd.Flags().BoolVar(&opts.JSON, "json", false, "Read workflow inputs as JSON via STDIN")
   130  
   131  	return cmd
   132  }
   133  
   134  // TODO copypasta from gh api
   135  func parseField(f string) (string, string, error) {
   136  	idx := strings.IndexRune(f, '=')
   137  	if idx == -1 {
   138  		return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f)
   139  	}
   140  	return f[0:idx], f[idx+1:], nil
   141  }
   142  
   143  // TODO MODIFIED copypasta from gh api
   144  func magicFieldValue(v string, opts RunOptions) (string, error) {
   145  	if strings.HasPrefix(v, "@") {
   146  		fileBytes, err := opts.IO.ReadUserFile(v[1:])
   147  		if err != nil {
   148  			return "", err
   149  		}
   150  		return string(fileBytes), nil
   151  	}
   152  
   153  	return v, nil
   154  }
   155  
   156  // TODO copypasta from gh api
   157  func parseFields(opts RunOptions) (map[string]string, error) {
   158  	params := make(map[string]string)
   159  	for _, f := range opts.RawFields {
   160  		key, value, err := parseField(f)
   161  		if err != nil {
   162  			return params, err
   163  		}
   164  		params[key] = value
   165  	}
   166  	for _, f := range opts.MagicFields {
   167  		key, strValue, err := parseField(f)
   168  		if err != nil {
   169  			return params, err
   170  		}
   171  		value, err := magicFieldValue(strValue, opts)
   172  		if err != nil {
   173  			return params, fmt.Errorf("error parsing %q value: %w", key, err)
   174  		}
   175  		params[key] = value
   176  	}
   177  	return params, nil
   178  }
   179  
   180  type InputAnswer struct {
   181  	providedInputs map[string]string
   182  }
   183  
   184  func (ia *InputAnswer) WriteAnswer(name string, value interface{}) error {
   185  	if s, ok := value.(string); ok {
   186  		ia.providedInputs[name] = s
   187  		return nil
   188  	}
   189  
   190  	// TODO i hate this; this is to make tests work:
   191  	if rv, ok := value.(reflect.Value); ok {
   192  		ia.providedInputs[name] = rv.String()
   193  		return nil
   194  	}
   195  
   196  	return fmt.Errorf("unexpected value type: %v", value)
   197  }
   198  
   199  func collectInputs(yamlContent []byte) (map[string]string, error) {
   200  	inputs, err := findInputs(yamlContent)
   201  	if err != nil {
   202  		return nil, err
   203  	}
   204  
   205  	providedInputs := map[string]string{}
   206  
   207  	if len(inputs) == 0 {
   208  		return providedInputs, nil
   209  	}
   210  
   211  	qs := []*survey.Question{}
   212  	for inputName, input := range inputs {
   213  		q := &survey.Question{
   214  			Name: inputName,
   215  			Prompt: &survey.Input{
   216  				Message: inputName,
   217  				Default: input.Default,
   218  			},
   219  		}
   220  		if input.Required {
   221  			q.Validate = survey.Required
   222  		}
   223  		qs = append(qs, q)
   224  	}
   225  
   226  	sort.Slice(qs, func(i, j int) bool {
   227  		return qs[i].Name < qs[j].Name
   228  	})
   229  
   230  	inputAnswer := InputAnswer{
   231  		providedInputs: providedInputs,
   232  	}
   233  	err = prompt.SurveyAsk(qs, &inputAnswer)
   234  	if err != nil {
   235  		return nil, err
   236  	}
   237  
   238  	return providedInputs, nil
   239  }
   240  
   241  func runRun(opts *RunOptions) error {
   242  	c, err := opts.HttpClient()
   243  	if err != nil {
   244  		return fmt.Errorf("could not build http client: %w", err)
   245  	}
   246  	client := api.NewClientFromHTTP(c)
   247  
   248  	repo, err := opts.BaseRepo()
   249  	if err != nil {
   250  		return fmt.Errorf("could not determine base repo: %w", err)
   251  	}
   252  
   253  	ref := opts.Ref
   254  
   255  	if ref == "" {
   256  		ref, err = api.RepoDefaultBranch(client, repo)
   257  		if err != nil {
   258  			return fmt.Errorf("unable to determine default branch for %s: %w", ghrepo.FullName(repo), err)
   259  		}
   260  	}
   261  
   262  	states := []shared.WorkflowState{shared.Active}
   263  	workflow, err := shared.ResolveWorkflow(
   264  		opts.IO, client, repo, opts.Prompt, opts.Selector, states)
   265  	if err != nil {
   266  		var fae shared.FilteredAllError
   267  		if errors.As(err, &fae) {
   268  			return errors.New("no workflows are enabled on this repository")
   269  		}
   270  		return err
   271  	}
   272  
   273  	providedInputs := map[string]string{}
   274  
   275  	if len(opts.MagicFields)+len(opts.RawFields) > 0 {
   276  		providedInputs, err = parseFields(*opts)
   277  		if err != nil {
   278  			return err
   279  		}
   280  	} else if opts.JSONInput != "" {
   281  		err := json.Unmarshal([]byte(opts.JSONInput), &providedInputs)
   282  		if err != nil {
   283  			return fmt.Errorf("could not parse provided JSON: %w", err)
   284  		}
   285  	} else if opts.Prompt {
   286  		yamlContent, err := shared.GetWorkflowContent(client, repo, *workflow, ref)
   287  		if err != nil {
   288  			return fmt.Errorf("unable to fetch workflow file content: %w", err)
   289  		}
   290  		providedInputs, err = collectInputs(yamlContent)
   291  		if err != nil {
   292  			return err
   293  		}
   294  	}
   295  
   296  	path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches",
   297  		ghrepo.FullName(repo), workflow.ID)
   298  
   299  	requestByte, err := json.Marshal(map[string]interface{}{
   300  		"ref":    ref,
   301  		"inputs": providedInputs,
   302  	})
   303  	if err != nil {
   304  		return fmt.Errorf("failed to serialize workflow inputs: %w", err)
   305  	}
   306  
   307  	body := bytes.NewReader(requestByte)
   308  
   309  	err = client.REST(repo.RepoHost(), "POST", path, body, nil)
   310  	if err != nil {
   311  		return fmt.Errorf("could not create workflow dispatch event: %w", err)
   312  	}
   313  
   314  	if opts.IO.IsStdoutTTY() {
   315  		out := opts.IO.Out
   316  		cs := opts.IO.ColorScheme()
   317  		fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n",
   318  			cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref))
   319  
   320  		fmt.Fprintln(out)
   321  
   322  		fmt.Fprintf(out, "To see runs for this workflow, try: %s\n",
   323  			cs.Boldf("gh run list --workflow=%s", workflow.Base()))
   324  	}
   325  
   326  	return nil
   327  }
   328  
   329  type WorkflowInput struct {
   330  	Required    bool
   331  	Default     string
   332  	Description string
   333  }
   334  
   335  func findInputs(yamlContent []byte) (map[string]WorkflowInput, error) {
   336  	var rootNode yaml.Node
   337  	err := yaml.Unmarshal(yamlContent, &rootNode)
   338  	if err != nil {
   339  		return nil, fmt.Errorf("unable to parse workflow YAML: %w", err)
   340  	}
   341  
   342  	if len(rootNode.Content) != 1 {
   343  		return nil, errors.New("invalid YAML file")
   344  	}
   345  
   346  	var onKeyNode *yaml.Node
   347  	var dispatchKeyNode *yaml.Node
   348  	var inputsKeyNode *yaml.Node
   349  	var inputsMapNode *yaml.Node
   350  
   351  	// TODO this is pretty hideous
   352  	for _, node := range rootNode.Content[0].Content {
   353  		if onKeyNode != nil {
   354  			if node.Kind == yaml.MappingNode {
   355  				for _, node := range node.Content {
   356  					if dispatchKeyNode != nil {
   357  						for _, node := range node.Content {
   358  							if inputsKeyNode != nil {
   359  								inputsMapNode = node
   360  								break
   361  							}
   362  							if node.Value == "inputs" {
   363  								inputsKeyNode = node
   364  							}
   365  						}
   366  						break
   367  					}
   368  					if node.Value == "workflow_dispatch" {
   369  						dispatchKeyNode = node
   370  					}
   371  				}
   372  			} else if node.Kind == yaml.SequenceNode {
   373  				for _, node := range node.Content {
   374  					if node.Value == "workflow_dispatch" {
   375  						dispatchKeyNode = node
   376  						break
   377  					}
   378  				}
   379  			} else if node.Kind == yaml.ScalarNode {
   380  				if node.Value == "workflow_dispatch" {
   381  					dispatchKeyNode = node
   382  					break
   383  				}
   384  			}
   385  			break
   386  		}
   387  		if strings.EqualFold(node.Value, "on") {
   388  			onKeyNode = node
   389  		}
   390  	}
   391  
   392  	if onKeyNode == nil {
   393  		return nil, errors.New("invalid workflow: no 'on' key")
   394  	}
   395  
   396  	if dispatchKeyNode == nil {
   397  		return nil, errors.New("unable to manually run a workflow without a workflow_dispatch event")
   398  	}
   399  
   400  	out := map[string]WorkflowInput{}
   401  
   402  	if inputsKeyNode == nil || inputsMapNode == nil {
   403  		return out, nil
   404  	}
   405  
   406  	err = inputsMapNode.Decode(&out)
   407  	if err != nil {
   408  		return nil, fmt.Errorf("could not decode workflow inputs: %w", err)
   409  	}
   410  
   411  	return out, nil
   412  }