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 }