github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/workflow/run/run.go (about)

     1  package run
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"reflect"
    11  	"sort"
    12  	"strings"
    13  
    14  	"github.com/AlecAivazis/survey/v2"
    15  	"github.com/MakeNowJust/heredoc"
    16  	"github.com/ungtb10d/cli/v2/api"
    17  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmd/workflow/shared"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/ungtb10d/cli/v2/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.FlagErrorf("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.FlagErrorf("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 := io.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.FlagErrorf("--json specified but nothing on STDIN")
   107  			}
   108  
   109  			if opts.Selector == "" {
   110  				if opts.JSONInput != "" {
   111  					return cmdutil.FlagErrorf("workflow argument required when passing JSON")
   112  				}
   113  			} else {
   114  				if opts.JSON && inputFieldsPassed {
   115  					return cmdutil.FlagErrorf("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  	//nolint:staticcheck // SA1019: prompt.SurveyAsk is deprecated: use Prompter
   234  	err = prompt.SurveyAsk(qs, &inputAnswer)
   235  	if err != nil {
   236  		return nil, err
   237  	}
   238  
   239  	return providedInputs, nil
   240  }
   241  
   242  func runRun(opts *RunOptions) error {
   243  	c, err := opts.HttpClient()
   244  	if err != nil {
   245  		return fmt.Errorf("could not build http client: %w", err)
   246  	}
   247  	client := api.NewClientFromHTTP(c)
   248  
   249  	repo, err := opts.BaseRepo()
   250  	if err != nil {
   251  		return fmt.Errorf("could not determine base repo: %w", err)
   252  	}
   253  
   254  	ref := opts.Ref
   255  
   256  	if ref == "" {
   257  		ref, err = api.RepoDefaultBranch(client, repo)
   258  		if err != nil {
   259  			return fmt.Errorf("unable to determine default branch for %s: %w", ghrepo.FullName(repo), err)
   260  		}
   261  	}
   262  
   263  	states := []shared.WorkflowState{shared.Active}
   264  	workflow, err := shared.ResolveWorkflow(
   265  		opts.IO, client, repo, opts.Prompt, opts.Selector, states)
   266  	if err != nil {
   267  		var fae shared.FilteredAllError
   268  		if errors.As(err, &fae) {
   269  			return errors.New("no workflows are enabled on this repository")
   270  		}
   271  		return err
   272  	}
   273  
   274  	providedInputs := map[string]string{}
   275  
   276  	if len(opts.MagicFields)+len(opts.RawFields) > 0 {
   277  		providedInputs, err = parseFields(*opts)
   278  		if err != nil {
   279  			return err
   280  		}
   281  	} else if opts.JSONInput != "" {
   282  		err := json.Unmarshal([]byte(opts.JSONInput), &providedInputs)
   283  		if err != nil {
   284  			return fmt.Errorf("could not parse provided JSON: %w", err)
   285  		}
   286  	} else if opts.Prompt {
   287  		yamlContent, err := shared.GetWorkflowContent(client, repo, *workflow, ref)
   288  		if err != nil {
   289  			return fmt.Errorf("unable to fetch workflow file content: %w", err)
   290  		}
   291  		providedInputs, err = collectInputs(yamlContent)
   292  		if err != nil {
   293  			return err
   294  		}
   295  	}
   296  
   297  	path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches",
   298  		ghrepo.FullName(repo), workflow.ID)
   299  
   300  	requestByte, err := json.Marshal(map[string]interface{}{
   301  		"ref":    ref,
   302  		"inputs": providedInputs,
   303  	})
   304  	if err != nil {
   305  		return fmt.Errorf("failed to serialize workflow inputs: %w", err)
   306  	}
   307  
   308  	body := bytes.NewReader(requestByte)
   309  
   310  	err = client.REST(repo.RepoHost(), "POST", path, body, nil)
   311  	if err != nil {
   312  		return fmt.Errorf("could not create workflow dispatch event: %w", err)
   313  	}
   314  
   315  	if opts.IO.IsStdoutTTY() {
   316  		out := opts.IO.Out
   317  		cs := opts.IO.ColorScheme()
   318  		fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n",
   319  			cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref))
   320  
   321  		fmt.Fprintln(out)
   322  
   323  		fmt.Fprintf(out, "To see runs for this workflow, try: %s\n",
   324  			cs.Boldf("gh run list --workflow=%s", workflow.Base()))
   325  	}
   326  
   327  	return nil
   328  }
   329  
   330  type WorkflowInput struct {
   331  	Required    bool
   332  	Default     string
   333  	Description string
   334  }
   335  
   336  func findInputs(yamlContent []byte) (map[string]WorkflowInput, error) {
   337  	var rootNode yaml.Node
   338  	err := yaml.Unmarshal(yamlContent, &rootNode)
   339  	if err != nil {
   340  		return nil, fmt.Errorf("unable to parse workflow YAML: %w", err)
   341  	}
   342  
   343  	if len(rootNode.Content) != 1 {
   344  		return nil, errors.New("invalid YAML file")
   345  	}
   346  
   347  	var onKeyNode *yaml.Node
   348  	var dispatchKeyNode *yaml.Node
   349  	var inputsKeyNode *yaml.Node
   350  	var inputsMapNode *yaml.Node
   351  
   352  	// TODO this is pretty hideous
   353  	for _, node := range rootNode.Content[0].Content {
   354  		if onKeyNode != nil {
   355  			if node.Kind == yaml.MappingNode {
   356  				for _, node := range node.Content {
   357  					if dispatchKeyNode != nil {
   358  						for _, node := range node.Content {
   359  							if inputsKeyNode != nil {
   360  								inputsMapNode = node
   361  								break
   362  							}
   363  							if node.Value == "inputs" {
   364  								inputsKeyNode = node
   365  							}
   366  						}
   367  						break
   368  					}
   369  					if node.Value == "workflow_dispatch" {
   370  						dispatchKeyNode = node
   371  					}
   372  				}
   373  			} else if node.Kind == yaml.SequenceNode {
   374  				for _, node := range node.Content {
   375  					if node.Value == "workflow_dispatch" {
   376  						dispatchKeyNode = node
   377  						break
   378  					}
   379  				}
   380  			} else if node.Kind == yaml.ScalarNode {
   381  				if node.Value == "workflow_dispatch" {
   382  					dispatchKeyNode = node
   383  					break
   384  				}
   385  			}
   386  			break
   387  		}
   388  		if strings.EqualFold(node.Value, "on") {
   389  			onKeyNode = node
   390  		}
   391  	}
   392  
   393  	if onKeyNode == nil {
   394  		return nil, errors.New("invalid workflow: no 'on' key")
   395  	}
   396  
   397  	if dispatchKeyNode == nil {
   398  		return nil, errors.New("unable to manually run a workflow without a workflow_dispatch event")
   399  	}
   400  
   401  	out := map[string]WorkflowInput{}
   402  
   403  	if inputsKeyNode == nil || inputsMapNode == nil {
   404  		return out, nil
   405  	}
   406  
   407  	err = inputsMapNode.Decode(&out)
   408  	if err != nil {
   409  		return nil, fmt.Errorf("could not decode workflow inputs: %w", err)
   410  	}
   411  
   412  	return out, nil
   413  }