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 }