From 248ee424f2a2e14f7167ad0586a2583efa103caf Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 12 Mar 2021 13:02:59 -0600 Subject: [PATCH] gh workflow {enable, disable} --- pkg/cmd/workflow/disable/disable.go | 93 +++++++ pkg/cmd/workflow/disable/disable_test.go | 298 ++++++++++++++++++++++ pkg/cmd/workflow/enable/enable.go | 93 +++++++ pkg/cmd/workflow/enable/enable_test.go | 300 +++++++++++++++++++++++ pkg/cmd/workflow/list/list.go | 62 +---- pkg/cmd/workflow/list/list_test.go | 25 +- pkg/cmd/workflow/shared/shared.go | 218 ++++++++++++++++ pkg/cmd/workflow/shared/test.go | 45 ++++ pkg/cmd/workflow/workflow.go | 4 + pkg/markdown/markdown.go | 18 ++ 10 files changed, 1085 insertions(+), 71 deletions(-) create mode 100644 pkg/cmd/workflow/disable/disable.go create mode 100644 pkg/cmd/workflow/disable/disable_test.go create mode 100644 pkg/cmd/workflow/enable/enable.go create mode 100644 pkg/cmd/workflow/enable/enable_test.go create mode 100644 pkg/cmd/workflow/shared/shared.go create mode 100644 pkg/cmd/workflow/shared/test.go diff --git a/pkg/cmd/workflow/disable/disable.go b/pkg/cmd/workflow/disable/disable.go new file mode 100644 index 000000000..9e9f4fb40 --- /dev/null +++ b/pkg/cmd/workflow/disable/disable.go @@ -0,0 +1,93 @@ +package disable + +import ( + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DisableOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + Selector string + Prompt bool +} + +func NewCmdDisable(f *cmdutil.Factory, runF func(*DisableOptions) error) *cobra.Command { + opts := &DisableOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "disable [ | ]", + Short: "Disable a workflow", + Args: cobra.MaximumNArgs(1), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.Selector = args[0] + } else if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("workflow ID or name required when not running interactively")} + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + return runDisable(opts) + }, + } + + return cmd +} + +func runDisable(opts *DisableOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not build http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + + states := []shared.WorkflowState{shared.Active} + workflow, err := shared.ResolveWorkflow( + opts.IO, client, repo, opts.Prompt, opts.Selector, states) + if err != nil { + var fae shared.FilteredAllError + if errors.As(err, &fae) { + return errors.New("there are no enabled workflows to disable") + } + return err + } + + path := fmt.Sprintf("repos/%s/actions/workflows/%d/disable", ghrepo.FullName(repo), workflow.ID) + err = client.REST(repo.RepoHost(), "PUT", path, nil, nil) + if err != nil { + return fmt.Errorf("failed to disable workflow: %w", err) + } + + if opts.IO.CanPrompt() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Disabled %s\n", cs.SuccessIcon(), cs.Bold(workflow.Name)) + } + + return nil +} diff --git a/pkg/cmd/workflow/disable/disable_test.go b/pkg/cmd/workflow/disable/disable_test.go new file mode 100644 index 000000000..98ba36c14 --- /dev/null +++ b/pkg/cmd/workflow/disable/disable_test.go @@ -0,0 +1,298 @@ +package disable + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdDisable(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants DisableOptions + wantsErr bool + }{ + { + name: "blank tty", + tty: true, + wants: DisableOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "arg tty", + cli: "123", + tty: true, + wants: DisableOptions{ + Selector: "123", + }, + }, + { + name: "arg nontty", + cli: "123", + wants: DisableOptions{ + Selector: "123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *DisableOptions + cmd := NewCmdDisable(f, func(opts *DisableOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) + }) + } +} + +func TestDisableRun(t *testing.T) { + tests := []struct { + name string + opts *DisableOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + tty bool + wantOut string + wantErrOut string + wantErr bool + }{ + { + name: "tty no arg", + opts: &DisableOptions{ + Prompt: true, + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/789/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "✓ Disabled another workflow\n", + }, + { + name: "tty name arg", + opts: &DisableOptions{ + Selector: "a workflow", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/a workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/123/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + wantOut: "✓ Disabled a workflow\n", + }, + { + name: "tty name arg nonunique", + opts: &DisableOptions{ + Selector: "another workflow", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/another workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.YetAnotherWorkflow, + shared.AnotherDisabledWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1011/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "✓ Disabled another workflow\n", + }, + { + name: "tty ID arg", + opts: &DisableOptions{ + Selector: "123", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.AWorkflow)) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/123/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + wantOut: "✓ Disabled a workflow\n", + }, + { + name: "nontty ID arg", + opts: &DisableOptions{ + Selector: "123", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.AWorkflow)) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/123/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "nontty name arg", + opts: &DisableOptions{ + Selector: "a workflow", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/a workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.AnotherDisabledWorkflow, + shared.UniqueDisabledWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/123/disable"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "nontty name arg nonunique", + opts: &DisableOptions{ + Selector: "another workflow", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/another workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.YetAnotherWorkflow, + shared.AnotherDisabledWorkflow, + shared.UniqueDisabledWorkflow, + }, + })) + }, + wantErr: true, + wantErrOut: "could not resolve to a unique workflow; found: another.yml yetanother.yml", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + io.SetStdinTTY(tt.tty) + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + t.Run(tt.name, func(t *testing.T) { + err := runDisable(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.wantErrOut, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/workflow/enable/enable.go b/pkg/cmd/workflow/enable/enable.go new file mode 100644 index 000000000..5554b5fd1 --- /dev/null +++ b/pkg/cmd/workflow/enable/enable.go @@ -0,0 +1,93 @@ +package enable + +import ( + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type EnableOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + Selector string + Prompt bool +} + +func NewCmdEnable(f *cmdutil.Factory, runF func(*EnableOptions) error) *cobra.Command { + opts := &EnableOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "enable [ | ]", + Short: "Enable a workflow", + Args: cobra.MaximumNArgs(1), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.Selector = args[0] + } else if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("workflow ID or name required when not running interactively")} + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + return runEnable(opts) + }, + } + + return cmd +} + +func runEnable(opts *EnableOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not build http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + + states := []shared.WorkflowState{shared.DisabledManually} + workflow, err := shared.ResolveWorkflow( + opts.IO, client, repo, opts.Prompt, opts.Selector, states) + if err != nil { + var fae shared.FilteredAllError + if errors.As(err, &fae) { + return errors.New("there are no disabled workflows to enable") + } + return err + } + + path := fmt.Sprintf("repos/%s/actions/workflows/%d/enable", ghrepo.FullName(repo), workflow.ID) + err = client.REST(repo.RepoHost(), "PUT", path, nil, nil) + if err != nil { + return fmt.Errorf("failed to enable workflow: %w", err) + } + + if opts.IO.CanPrompt() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Enabled %s\n", cs.SuccessIcon(), cs.Bold(workflow.Name)) + } + + return nil +} diff --git a/pkg/cmd/workflow/enable/enable_test.go b/pkg/cmd/workflow/enable/enable_test.go new file mode 100644 index 000000000..5da4d8660 --- /dev/null +++ b/pkg/cmd/workflow/enable/enable_test.go @@ -0,0 +1,300 @@ +package enable + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdEnable(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants EnableOptions + wantsErr bool + }{ + { + name: "blank tty", + tty: true, + wants: EnableOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "arg tty", + cli: "123", + tty: true, + wants: EnableOptions{ + Selector: "123", + }, + }, + { + name: "arg nontty", + cli: "123", + wants: EnableOptions{ + Selector: "123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *EnableOptions + cmd := NewCmdEnable(f, func(opts *EnableOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Selector, gotOpts.Selector) + assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) + }) + } +} + +func TestEnableRun(t *testing.T) { + tests := []struct { + name string + opts *EnableOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + tty bool + wantOut string + wantErrOut string + wantErr bool + }{ + { + name: "tty no arg", + opts: &EnableOptions{ + Prompt: true, + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/456/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) + }, + wantOut: "✓ Enabled a disabled workflow\n", + }, + { + name: "tty name arg", + opts: &EnableOptions{ + Selector: "terrible workflow", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/terrible workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.UniqueDisabledWorkflow, + shared.AnotherWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1314/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + wantOut: "✓ Enabled terrible workflow\n", + }, + { + name: "tty name arg nonunique", + opts: &EnableOptions{ + Selector: "a disabled workflow", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/a disabled workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.AnotherDisabledWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1213/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "✓ Enabled a disabled workflow\n", + }, + { + name: "tty ID arg", + opts: &EnableOptions{ + Selector: "456", + }, + tty: true, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/456"), + httpmock.JSONResponse(shared.DisabledWorkflow)) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/456/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) + }, + wantOut: "✓ Enabled a disabled workflow\n", + }, + { + name: "nontty ID arg", + opts: &EnableOptions{ + Selector: "456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/456"), + httpmock.JSONResponse(shared.DisabledWorkflow)) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/456/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "nontty name arg", + opts: &EnableOptions{ + Selector: "terrible workflow", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/terrible workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.AnotherDisabledWorkflow, + shared.UniqueDisabledWorkflow, + }, + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/actions/workflows/1314/enable"), + httpmock.StatusStringResponse(204, "{}")) + }, + }, + { + name: "nontty name arg nonunique", + opts: &EnableOptions{ + Selector: "a disabled workflow", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/a disabled workflow"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + shared.AWorkflow, + shared.DisabledWorkflow, + shared.AnotherWorkflow, + shared.AnotherDisabledWorkflow, + shared.UniqueDisabledWorkflow, + }, + })) + }, + wantErr: true, + wantErrOut: "could not resolve to a unique workflow; found: disabled.yml anotherDisabled.yml", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + io.SetStdinTTY(tt.tty) + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + t.Run(tt.name, func(t *testing.T) { + err := runEnable(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.wantErrOut, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/workflow/list/list.go b/pkg/cmd/workflow/list/list.go index 7da3b7f60..bf14431e0 100644 --- a/pkg/cmd/workflow/list/list.go +++ b/pkg/cmd/workflow/list/list.go @@ -6,18 +6,14 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) -const ( - defaultLimit = 10 - - Active WorkflowState = "active" - DisabledManually WorkflowState = "disabled_manually" -) +const defaultLimit = 10 type ListOptions struct { IO *iostreams.IOStreams @@ -79,7 +75,7 @@ func listRun(opts *ListOptions) error { client := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() - workflows, err := getWorkflows(client, repo, opts.Limit) + workflows, err := shared.GetWorkflows(client, repo, opts.Limit) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("could not get workflows: %w", err) @@ -107,55 +103,3 @@ func listRun(opts *ListOptions) error { return tp.Render() } - -type WorkflowState string - -type Workflow struct { - Name string - ID int - State WorkflowState -} - -func (w *Workflow) Disabled() bool { - return w.State != Active -} - -type WorkflowsPayload struct { - Workflows []Workflow -} - -func getWorkflows(client *api.Client, repo ghrepo.Interface, limit int) ([]Workflow, error) { - perPage := limit - page := 1 - if limit > 100 { - perPage = 100 - } - - workflows := []Workflow{} - - for len(workflows) < limit { - var result WorkflowsPayload - - path := fmt.Sprintf("repos/%s/actions/workflows?per_page=%d&page=%d", ghrepo.FullName(repo), perPage, page) - - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) - if err != nil { - return nil, err - } - - for _, workflow := range result.Workflows { - workflows = append(workflows, workflow) - if len(workflows) == limit { - break - } - } - - if len(result.Workflows) < perPage { - break - } - - page++ - } - - return workflows, nil -} diff --git a/pkg/cmd/workflow/list/list_test.go b/pkg/cmd/workflow/list/list_test.go index 6a7306db2..77d0fa5fb 100644 --- a/pkg/cmd/workflow/list/list_test.go +++ b/pkg/cmd/workflow/list/list_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/workflow/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -97,24 +98,24 @@ func Test_NewCmdList(t *testing.T) { } func TestListRun(t *testing.T) { - workflows := []Workflow{ + workflows := []shared.Workflow{ { Name: "Go", - State: Active, + State: shared.Active, ID: 707, }, { Name: "Linter", - State: Active, + State: shared.Active, ID: 666, }, { Name: "Release", - State: DisabledManually, + State: shared.DisabledManually, ID: 451, }, } - payload := WorkflowsPayload{Workflows: workflows} + payload := shared.WorkflowsPayload{Workflows: workflows} tests := []struct { name string @@ -146,22 +147,22 @@ func TestListRun(t *testing.T) { Limit: 101, }, stubs: func(reg *httpmock.Registry) { - workflows := []Workflow{} + workflows := []shared.Workflow{} for flowID := 0; flowID < 103; flowID++ { - workflows = append(workflows, Workflow{ + workflows = append(workflows, shared.Workflow{ ID: flowID, Name: fmt.Sprintf("flow %d", flowID), - State: Active, + State: shared.Active, }) } reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), - httpmock.JSONResponse(WorkflowsPayload{ + httpmock.JSONResponse(shared.WorkflowsPayload{ Workflows: workflows[0:100], })) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), - httpmock.JSONResponse(WorkflowsPayload{ + httpmock.JSONResponse(shared.WorkflowsPayload{ Workflows: workflows[100:], })) }, @@ -176,7 +177,7 @@ func TestListRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), - httpmock.JSONResponse(WorkflowsPayload{}), + httpmock.JSONResponse(shared.WorkflowsPayload{}), ) }, wantOut: "", @@ -190,7 +191,7 @@ func TestListRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), - httpmock.JSONResponse(WorkflowsPayload{}), + httpmock.JSONResponse(shared.WorkflowsPayload{}), ) }, wantOut: "", diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go new file mode 100644 index 000000000..6b4aa348d --- /dev/null +++ b/pkg/cmd/workflow/shared/shared.go @@ -0,0 +1,218 @@ +package shared + +import ( + "errors" + "fmt" + "path" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" +) + +const ( + Active WorkflowState = "active" + DisabledManually WorkflowState = "disabled_manually" +) + +type WorkflowState string + +type Workflow struct { + Name string + ID int + Path string + State WorkflowState +} + +type WorkflowsPayload struct { + Workflows []Workflow +} + +func (w *Workflow) Disabled() bool { + return w.State != Active +} + +func (w *Workflow) Base() string { + return path.Base(w.Path) +} + +func GetWorkflows(client *api.Client, repo ghrepo.Interface, limit int) ([]Workflow, error) { + perPage := limit + page := 1 + if limit > 100 || limit == 0 { + perPage = 100 + } + + workflows := []Workflow{} + + for { + if limit > 0 && len(workflows) == limit { + break + } + var result WorkflowsPayload + + path := fmt.Sprintf("repos/%s/actions/workflows?per_page=%d&page=%d", ghrepo.FullName(repo), perPage, page) + + err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + if err != nil { + return nil, err + } + + for _, workflow := range result.Workflows { + workflows = append(workflows, workflow) + if limit > 0 && len(workflows) == limit { + break + } + } + + if len(result.Workflows) < perPage { + break + } + + page++ + } + + return workflows, nil +} + +type FilteredAllError struct { + error +} + +func SelectWorkflow(workflows []Workflow, promptMsg string, states []WorkflowState) (*Workflow, error) { + filtered := []Workflow{} + candidates := []string{} + for _, workflow := range workflows { + for _, state := range states { + if workflow.State == state { + filtered = append(filtered, workflow) + candidates = append(candidates, fmt.Sprintf("%s (%s)", workflow.Name, workflow.Base())) + break + } + } + } + + if len(candidates) == 0 { + return nil, FilteredAllError{errors.New("")} + } + + var selected int + + err := prompt.SurveyAskOne(&survey.Select{ + Message: promptMsg, + Options: candidates, + PageSize: 15, + }, &selected) + if err != nil { + return nil, err + } + + return &filtered[selected], nil +} + +func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector string, states []WorkflowState) ([]Workflow, error) { + if workflowSelector == "" { + return nil, errors.New("empty workflow selector") + } + + workflow, err := getWorkflowByID(client, repo, workflowSelector) + if err == nil { + return []Workflow{*workflow}, nil + } else { + var httpErr api.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { + return nil, err + } + } + + return getWorkflowsByName(client, repo, workflowSelector, states) +} + +func getWorkflowByID(client *api.Client, repo ghrepo.Interface, ID string) (*Workflow, error) { + var workflow Workflow + + err := client.REST(repo.RepoHost(), "GET", + fmt.Sprintf("repos/%s/actions/workflows/%s", ghrepo.FullName(repo), ID), + nil, &workflow) + + if err != nil { + return nil, err + } + + return &workflow, nil +} + +func getWorkflowsByName(client *api.Client, repo ghrepo.Interface, name string, states []WorkflowState) ([]Workflow, error) { + workflows, err := GetWorkflows(client, repo, 0) + if err != nil { + return nil, fmt.Errorf("couldn't fetch workflows for %s: %w", ghrepo.FullName(repo), err) + } + filtered := []Workflow{} + + for _, workflow := range workflows { + desiredState := false + for _, state := range states { + if workflow.State == state { + desiredState = true + break + } + } + + if !desiredState { + continue + } + + // TODO consider fuzzy or prefix match + if strings.EqualFold(workflow.Name, name) { + filtered = append(filtered, workflow) + } + } + + return filtered, nil +} + +func ResolveWorkflow(io *iostreams.IOStreams, client *api.Client, repo ghrepo.Interface, prompt bool, workflowSelector string, states []WorkflowState) (*Workflow, error) { + if prompt { + workflows, err := GetWorkflows(client, repo, 0) + if len(workflows) == 0 { + err = errors.New("no workflows are enabled") + } + + if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { + err = errors.New("no workflows are enabled") + } + + return nil, fmt.Errorf("could not fetch workflows for %s: %w", ghrepo.FullName(repo), err) + } + + return SelectWorkflow(workflows, "Select a workflow", states) + } + + workflows, err := FindWorkflow(client, repo, workflowSelector, states) + if err != nil { + return nil, err + } + + if len(workflows) == 0 { + return nil, fmt.Errorf("could not find any workflows named %s", workflowSelector) + } + + if len(workflows) == 1 { + return &workflows[0], nil + } + + if !io.CanPrompt() { + errMsg := "could not resolve to a unique workflow; found:" + for _, workflow := range workflows { + errMsg += fmt.Sprintf(" %s", workflow.Base()) + } + return nil, errors.New(errMsg) + } + + return SelectWorkflow(workflows, "Which workflow do you mean?", states) +} diff --git a/pkg/cmd/workflow/shared/test.go b/pkg/cmd/workflow/shared/test.go new file mode 100644 index 000000000..deb02e8c7 --- /dev/null +++ b/pkg/cmd/workflow/shared/test.go @@ -0,0 +1,45 @@ +package shared + +var AWorkflow = Workflow{ + Name: "a workflow", + ID: 123, + Path: ".github/workflows/flow.yml", + State: Active, +} +var AWorkflowContent = `{"content":"bmFtZTogYSB3b3JrZmxvdwo="}` + +var DisabledWorkflow = Workflow{ + Name: "a disabled workflow", + ID: 456, + Path: ".github/workflows/disabled.yml", + State: DisabledManually, +} + +var AnotherDisabledWorkflow = Workflow{ + Name: "a disabled workflow", + ID: 1213, + Path: ".github/workflows/anotherDisabled.yml", + State: DisabledManually, +} + +var UniqueDisabledWorkflow = Workflow{ + Name: "terrible workflow", + ID: 1314, + Path: ".github/workflows/terrible.yml", + State: DisabledManually, +} + +var AnotherWorkflow = Workflow{ + Name: "another workflow", + ID: 789, + Path: ".github/workflows/another.yml", + State: Active, +} +var AnotherWorkflowContent = `{"content":"bmFtZTogYW5vdGhlciB3b3JrZmxvdwo="}` + +var YetAnotherWorkflow = Workflow{ + Name: "another workflow", + ID: 1011, + Path: ".github/workflows/yetanother.yml", + State: Active, +} diff --git a/pkg/cmd/workflow/workflow.go b/pkg/cmd/workflow/workflow.go index cd51b1345..b3632ce17 100644 --- a/pkg/cmd/workflow/workflow.go +++ b/pkg/cmd/workflow/workflow.go @@ -1,6 +1,8 @@ package workflow import ( + cmdDisable "github.com/cli/cli/pkg/cmd/workflow/disable" + cmdEnable "github.com/cli/cli/pkg/cmd/workflow/enable" cmdList "github.com/cli/cli/pkg/cmd/workflow/list" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -18,6 +20,8 @@ func NewCmdWorkflow(f *cmdutil.Factory) *cobra.Command { cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdEnable.NewCmdEnable(f, nil)) + cmd.AddCommand(cmdDisable.NewCmdDisable(f, nil)) return cmd } diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index 7e117d398..0da86e802 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -9,6 +9,24 @@ import ( type RenderOpts []glamour.TermRendererOption +func WithoutIndentation() glamour.TermRendererOption { + overrides := []byte(` + { + "document": { + "margin": 0 + }, + "code_block": { + "margin": 0 + } + }`) + + return glamour.WithStylesFromJSONBytes(overrides) +} + +func WithoutWrap() glamour.TermRendererOption { + return glamour.WithWordWrap(0) +} + func render(text string, opts RenderOpts) (string, error) { // Glamour rendering preserves carriage return characters in code blocks, but // we need to ensure that no such characters are present in the output.