Skip to content

Commit 434a758

Browse files
committed
start on workflow run
1 parent 44ae7ae commit 434a758

File tree

3 files changed

+376
-0
lines changed

3 files changed

+376
-0
lines changed

pkg/cmd/workflow/run/run.go

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
package run
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io/ioutil"
10+
"net/http"
11+
"net/url"
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+
"github.com/spf13/pflag"
24+
"gopkg.in/yaml.v3"
25+
)
26+
27+
type RunOptions struct {
28+
HttpClient func() (*http.Client, error)
29+
IO *iostreams.IOStreams
30+
BaseRepo func() (ghrepo.Interface, error)
31+
32+
Selector string
33+
Ref string
34+
35+
InputArgs []string
36+
JSON string
37+
38+
Prompt bool
39+
}
40+
41+
func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
42+
opts := &RunOptions{
43+
IO: f.IOStreams,
44+
HttpClient: f.HttpClient,
45+
}
46+
47+
cmd := &cobra.Command{
48+
Use: "run [<workflow ID> | <workflow name>]",
49+
Short: "Create a dispatch event for a workflow, starting a run",
50+
Long: heredoc.Doc(`
51+
Create a workflow_dispatch event for a given workflow.
52+
53+
This command will trigger GitHub Actions to run a given workflow file. The given workflow file must
54+
support a workflow_dispatch 'on' trigger in order to be run in this way.
55+
56+
If the workflow file supports inputs, they can be specified in a few ways:
57+
58+
- Interactively
59+
- As command line arguments specified after --
60+
- As JSON, via STDIN
61+
- As JSON, via --json
62+
`),
63+
Example: heredoc.Doc(`
64+
# Have gh prompt you for what workflow you'd like to run
65+
$ gh workflow run
66+
67+
# Run the workflow file 'triage.yml' at the remote's default branch, interactively providing inputs
68+
$ gh workflow run triage.yml
69+
70+
# Run the workflow file 'triage.yml' at a specified ref, interactively providing inputs
71+
$ gh workflow run triage.yml --ref=myBranch
72+
73+
# Run the workflow file 'triage.yml' with command line inputs
74+
$ gh workflow run triage.yml -- --name=scully --greeting=hello
75+
76+
# Run the workflow file 'triage.yml' with JSON via STDIN
77+
$ echo '{"name":"scully", "greeting":"hello"}' | gh workflow run triage.yml
78+
79+
# Run the workflow file 'triage.yml' with JSON via --json
80+
$ gh workflow run triage.yml --json='{"name":"scully", "greeting":"hello"}'
81+
`),
82+
Args: func(cmd *cobra.Command, args []string) error {
83+
if cmd.ArgsLenAtDash() == 0 && len(args[1:]) > 0 {
84+
return cmdutil.FlagError{Err: fmt.Errorf("workflow argument required when passing input flags")}
85+
}
86+
return nil
87+
},
88+
Hidden: true,
89+
RunE: func(cmd *cobra.Command, args []string) error {
90+
// support `-R, --repo` override
91+
opts.BaseRepo = f.BaseRepo
92+
93+
if len(args) > 0 {
94+
opts.Selector = args[0]
95+
opts.InputArgs = args[1:]
96+
} else if !opts.IO.CanPrompt() {
97+
return &cmdutil.FlagError{Err: errors.New("workflow ID or name required when not running interactively")}
98+
} else {
99+
opts.Prompt = true
100+
}
101+
102+
if !opts.IO.IsStdinTTY() {
103+
jsonIn, err := ioutil.ReadAll(opts.IO.In)
104+
if err != nil {
105+
return errors.New("failed to read from STDIN")
106+
}
107+
opts.JSON = string(jsonIn)
108+
}
109+
110+
if opts.Selector == "" {
111+
if opts.JSON != "" {
112+
return &cmdutil.FlagError{Err: errors.New("workflow argument required when passing JSON")}
113+
}
114+
} else {
115+
if opts.JSON != "" && len(opts.InputArgs) > 0 {
116+
return &cmdutil.FlagError{Err: errors.New("only one of JSON or input arguments can be passed at a time")}
117+
}
118+
}
119+
120+
if runF != nil {
121+
return runF(opts)
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().StringVar(&opts.JSON, "json", "", "Provide workflow inputs as JSON")
128+
129+
return cmd
130+
}
131+
132+
type InputAnswer struct {
133+
providedInputs map[string]string
134+
}
135+
136+
func (ia *InputAnswer) WriteAnswer(name string, value interface{}) error {
137+
ia.providedInputs[name] = value.(string)
138+
return nil
139+
}
140+
141+
func runRun(opts *RunOptions) error {
142+
c, err := opts.HttpClient()
143+
if err != nil {
144+
return fmt.Errorf("could not build http client: %w", err)
145+
}
146+
client := api.NewClientFromHTTP(c)
147+
148+
repo, err := opts.BaseRepo()
149+
if err != nil {
150+
return fmt.Errorf("could not determine base repo: %w", err)
151+
}
152+
153+
states := []shared.WorkflowState{shared.Active}
154+
workflow, err := shared.ResolveWorkflow(
155+
opts.IO, client, repo, opts.Prompt, opts.Selector, states)
156+
if err != nil {
157+
var fae shared.FilteredAllError
158+
if errors.As(err, &fae) {
159+
return errors.New("no workflows are enabled on this repository")
160+
}
161+
return err
162+
}
163+
164+
// TODO decide on behavior if no args passed but inputs are all optional. In
165+
// other words, how to force non-interactive if you do not want to use
166+
// non-default inputs?
167+
168+
// TODO once end-to-end is working, circle back and see if running a local workflow remotely is feasible by doing git stuff automagically in a throwaway branch.
169+
ref := opts.Ref
170+
171+
if ref == "" {
172+
ref, err = api.RepoDefaultBranch(client, repo)
173+
if err != nil {
174+
return fmt.Errorf("unable to determine default branch for %s: %w", ghrepo.FullName(repo), err)
175+
}
176+
}
177+
178+
yamlContent, err := getWorkflowContent(client, repo, workflow, ref)
179+
if err != nil {
180+
return fmt.Errorf("unable to fetch workflow file content: %w", err)
181+
}
182+
183+
inputs, err := findInputs(yamlContent)
184+
if err != nil {
185+
return err
186+
}
187+
188+
providedInputs := map[string]string{}
189+
190+
if opts.Prompt {
191+
qs := []*survey.Question{}
192+
for inputName, input := range inputs {
193+
q := &survey.Question{
194+
Name: inputName,
195+
Prompt: &survey.Input{
196+
Message: inputName,
197+
Default: input.Default,
198+
},
199+
}
200+
if input.Required {
201+
q.Validate = survey.Required
202+
}
203+
qs = append(qs, q)
204+
}
205+
inputAnswer := InputAnswer{
206+
providedInputs: providedInputs,
207+
}
208+
err = prompt.SurveyAsk(qs, &inputAnswer)
209+
if err != nil {
210+
return err
211+
}
212+
} else {
213+
if opts.JSON != "" {
214+
err := json.Unmarshal([]byte(opts.JSON), &providedInputs)
215+
if err != nil {
216+
return fmt.Errorf("could not parse provided JSON: %w", err)
217+
}
218+
}
219+
220+
if len(opts.InputArgs) > 0 {
221+
fs := pflag.FlagSet{}
222+
for inputName, input := range inputs {
223+
fs.String(inputName, input.Default, input.Description)
224+
}
225+
err = fs.Parse(opts.InputArgs)
226+
if err != nil {
227+
return fmt.Errorf("could not parse input args: %w", err)
228+
}
229+
for inputName := range inputs {
230+
// TODO error handling
231+
providedValue, _ := fs.GetString(inputName)
232+
providedInputs[inputName] = providedValue
233+
}
234+
}
235+
}
236+
237+
for inputName, inputValue := range providedInputs {
238+
if inputs[inputName].Required && inputValue == "" {
239+
return fmt.Errorf("missing required input '%s'", inputName)
240+
}
241+
}
242+
243+
path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches",
244+
ghrepo.FullName(repo), workflow.ID)
245+
246+
requestByte, err := json.Marshal(struct {
247+
Ref string `json:"ref"`
248+
Inputs map[string]string `json:"inputs"`
249+
}{
250+
Ref: ref,
251+
Inputs: providedInputs,
252+
})
253+
if err != nil {
254+
return fmt.Errorf("failed to serialize workflow inputs: %w", err)
255+
}
256+
257+
body := bytes.NewReader(requestByte)
258+
259+
err = client.REST(repo.RepoHost(), "POST", path, body, nil)
260+
if err != nil {
261+
return fmt.Errorf("could not create workflow dispatch event: %w", err)
262+
}
263+
264+
if opts.IO.CanPrompt() {
265+
out := opts.IO.Out
266+
cs := opts.IO.ColorScheme()
267+
fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n",
268+
cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref))
269+
270+
fmt.Fprintln(out)
271+
272+
fmt.Fprintf(out, "To see runs for this workflow, try: %s\n",
273+
cs.Boldf("gh run list --workflow=%s", workflow.Base()))
274+
}
275+
276+
return nil
277+
}
278+
279+
type WorkflowInput struct {
280+
Required bool
281+
Default string
282+
Description string
283+
}
284+
285+
func findInputs(yamlContent []byte) (map[string]WorkflowInput, error) {
286+
var rootNode yaml.Node
287+
err := yaml.Unmarshal(yamlContent, &rootNode)
288+
if err != nil {
289+
return nil, fmt.Errorf("unable to parse workflow YAML: %w", err)
290+
}
291+
292+
if len(rootNode.Content) != 1 {
293+
return nil, errors.New("invalid yaml file")
294+
}
295+
296+
var onKeyNode *yaml.Node
297+
var dispatchKeyNode *yaml.Node
298+
var inputsKeyNode *yaml.Node
299+
var inputsMapNode *yaml.Node
300+
301+
// TODO this is pretty hideous
302+
for _, node := range rootNode.Content[0].Content {
303+
if onKeyNode != nil {
304+
for _, node := range node.Content {
305+
if dispatchKeyNode != nil {
306+
for _, node := range node.Content {
307+
if inputsKeyNode != nil {
308+
inputsMapNode = node
309+
break
310+
}
311+
if node.Value == "inputs" {
312+
inputsKeyNode = node
313+
}
314+
}
315+
break
316+
}
317+
if node.Value == "workflow_dispatch" {
318+
dispatchKeyNode = node
319+
}
320+
}
321+
break
322+
}
323+
if strings.EqualFold(node.Value, "on") {
324+
onKeyNode = node
325+
}
326+
}
327+
328+
if onKeyNode == nil {
329+
return nil, errors.New("invalid workflow: no 'on' key")
330+
}
331+
332+
if dispatchKeyNode == nil {
333+
return nil, errors.New("unable to manually run a workflow without a workflow_dispatch event")
334+
}
335+
336+
out := map[string]WorkflowInput{}
337+
338+
if inputsKeyNode == nil || inputsMapNode == nil {
339+
return out, nil
340+
}
341+
342+
err = inputsMapNode.Decode(&out)
343+
if err != nil {
344+
return nil, fmt.Errorf("could not decode workflow inputs: %w", err)
345+
}
346+
347+
return out, nil
348+
}
349+
350+
func getWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow *shared.Workflow, ref string) ([]byte, error) {
351+
path := fmt.Sprintf("repos/%s/contents/%s?ref=%s", ghrepo.FullName(repo), workflow.Path, url.QueryEscape(ref))
352+
353+
type Result struct {
354+
Content string
355+
}
356+
357+
var result Result
358+
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
359+
if err != nil {
360+
return nil, err
361+
}
362+
363+
decoded, err := base64.StdEncoding.DecodeString(result.Content)
364+
if err != nil {
365+
return nil, fmt.Errorf("failed to decode workflow file: %w", err)
366+
}
367+
368+
return decoded, nil
369+
}

pkg/cmd/workflow/run/run_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package run
2+
3+
// TODO arg tests
4+
5+
// TODO execution tests

pkg/cmd/workflow/workflow.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
cmdDisable "github.com/cli/cli/pkg/cmd/workflow/disable"
55
cmdEnable "github.com/cli/cli/pkg/cmd/workflow/enable"
66
cmdList "github.com/cli/cli/pkg/cmd/workflow/list"
7+
cmdRun "github.com/cli/cli/pkg/cmd/workflow/run"
78
"github.com/cli/cli/pkg/cmdutil"
89
"github.com/spf13/cobra"
910
)
@@ -22,6 +23,7 @@ func NewCmdWorkflow(f *cmdutil.Factory) *cobra.Command {
2223
cmd.AddCommand(cmdList.NewCmdList(f, nil))
2324
cmd.AddCommand(cmdEnable.NewCmdEnable(f, nil))
2425
cmd.AddCommand(cmdDisable.NewCmdDisable(f, nil))
26+
cmd.AddCommand(cmdRun.NewCmdRun(f, nil))
2527

2628
return cmd
2729
}

0 commit comments

Comments
 (0)