From 6ba70d4a1e75e6c715a8c3d1348bef2081e09505 Mon Sep 17 00:00:00 2001 From: Cristian Dominguez Date: Mon, 14 Jun 2021 08:57:06 -0300 Subject: [PATCH 1/7] Add `run cancel` command --- pkg/cmd/run/cancel/cancel.go | 92 ++++++++++++++++++ pkg/cmd/run/cancel/cancel_test.go | 153 ++++++++++++++++++++++++++++++ pkg/cmd/run/run.go | 2 + 3 files changed, 247 insertions(+) create mode 100644 pkg/cmd/run/cancel/cancel.go create mode 100644 pkg/cmd/run/cancel/cancel_test.go diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go new file mode 100644 index 000000000..074f7becb --- /dev/null +++ b/pkg/cmd/run/cancel/cancel.go @@ -0,0 +1,92 @@ +package cancel + +import ( + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type CancelOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RunID string +} + +func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Command { + opts := &CancelOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "cancel ", + Short: "Cancel a workflow run", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.RunID = args[0] + + if runF != nil { + return runF(opts) + } + + return runCancel(opts) + }, + } + + return cmd +} + +func runCancel(opts *CancelOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + client := api.NewClientFromHTTP(httpClient) + + cs := opts.IO.ColorScheme() + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repo: %w", err) + } + + err = cancelWorkflowRun(client, repo, opts.RunID) + if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusConflict { + err = fmt.Errorf("Cannot cancel a workflow run that is completed") + } else if httpErr.StatusCode == http.StatusNotFound { + err = fmt.Errorf("Could not find any workflow run with ID %s", opts.RunID) + } + } + + return err + } + + fmt.Fprintf(opts.IO.Out, "%s You have successfully requested the workflow to be canceled.", cs.SuccessIcon()) + + return nil +} + +func cancelWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error { + path := fmt.Sprintf("repos/%s/actions/runs/%s/cancel", ghrepo.FullName(repo), runID) + + err := client.REST(repo.RepoHost(), "POST", path, nil, nil) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go new file mode 100644 index 000000000..89aebe664 --- /dev/null +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -0,0 +1,153 @@ +package cancel + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdCancel(t *testing.T) { + tests := []struct { + name string + cli string + wants CancelOptions + wantsErr bool + }{ + { + name: "blank", + wantsErr: true, + }, + { + name: "with arg", + cli: "1234", + wants: CancelOptions{ + RunID: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *CancelOptions + cmd := NewCmdCancel(f, func(opts *CancelOptions) 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.RunID, gotOpts.RunID) + }) + } +} + +func TestRunCancel(t *testing.T) { + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + opts *CancelOptions + wantErr bool + wantOut string + errMsg string + }{ + { + name: "cancel run", + opts: &CancelOptions{ + RunID: "1234", + }, + wantErr: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), + httpmock.StatusStringResponse(202, "{}"), + ) + }, + wantOut: "✓ You have successfully requested the workflow to be canceled.", + }, + { + name: "not found", + opts: &CancelOptions{ + RunID: "1234", + }, + wantErr: true, + errMsg: "Could not find any workflow run with ID 1234", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), + httpmock.StatusStringResponse(404, ""), + ) + }, + }, + { + name: "completed", + opts: &CancelOptions{ + RunID: "1234", + }, + wantErr: true, + errMsg: "Cannot cancel a workflow run that is completed", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), + httpmock.StatusStringResponse(409, ""), + ) + }, + }, + } + + 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(true) + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + t.Run(tt.name, func(t *testing.T) { + err := runCancel(tt.opts) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Equal(t, tt.errMsg, err.Error()) + } + } + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index d61e350d3..85ef76558 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -1,6 +1,7 @@ package run import ( + cmdCancel "github.com/cli/cli/pkg/cmd/run/cancel" cmdDownload "github.com/cli/cli/pkg/cmd/run/download" cmdList "github.com/cli/cli/pkg/cmd/run/list" cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun" @@ -26,6 +27,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) + cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil)) return cmd } From 49652cdefa175cb62616d8cf8958f1bb7fc2e88e Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Thu, 2 Sep 2021 12:57:50 -0500 Subject: [PATCH 2/7] Update pkg/cmd/run/cancel/cancel.go Co-authored-by: Josh Soref <2119212+jsoref@users.noreply.github.com> --- pkg/cmd/run/cancel/cancel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 074f7becb..91ab3b2bd 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -75,7 +75,7 @@ func runCancel(opts *CancelOptions) error { return err } - fmt.Fprintf(opts.IO.Out, "%s You have successfully requested the workflow to be canceled.", cs.SuccessIcon()) + fmt.Fprintf(opts.IO.Out, "%s Request to cancel workflow submitted.", cs.SuccessIcon()) return nil } From f329ebd7cafd6a8492b7f924422107a457270a0d Mon Sep 17 00:00:00 2001 From: nate smith Date: Fri, 15 Oct 2021 14:19:16 -0500 Subject: [PATCH 3/7] add interactive prompt for in progress runs --- pkg/cmd/run/cancel/cancel.go | 56 ++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 91ab3b2bd..16b00da4e 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -5,10 +5,11 @@ import ( "fmt" "net/http" - "github.com/cli/cli/api" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/run/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" ) @@ -17,6 +18,8 @@ type CancelOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Prompt bool + RunID string } @@ -27,14 +30,20 @@ func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "cancel ", + Use: "cancel []", Short: "Cancel a workflow run", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.RunID = args[0] + if len(args) > 0 { + opts.RunID = args[0] + } else if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")} + } else { + opts.Prompt = true + } if runF != nil { return runF(opts) @@ -61,7 +70,38 @@ func runCancel(opts *CancelOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } - err = cancelWorkflowRun(client, repo, opts.RunID) + runID := opts.RunID + var run *shared.Run + + if opts.Prompt { + runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { + return run.Status != shared.Completed + }) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + if len(runs) == 0 { + return fmt.Errorf("found no in progress runs to watch") + } + runID, err = shared.PromptForRun(cs, runs) + if err != nil { + return err + } + // TODO silly stopgap until dust settles and PromptForRun can just return a run + for _, r := range runs { + if fmt.Sprintf("%d", r.ID) == runID { + run = &r + break + } + } + } else { + run, err = shared.GetRun(client, repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + } + + err = cancelWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID)) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) { From b81eda0c46baa6fd70ba4453ea0f66a9e5676c62 Mon Sep 17 00:00:00 2001 From: nate smith Date: Fri, 15 Oct 2021 14:28:28 -0500 Subject: [PATCH 4/7] newline --- pkg/cmd/run/cancel/cancel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 16b00da4e..d9711c420 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -115,7 +115,7 @@ func runCancel(opts *CancelOptions) error { return err } - fmt.Fprintf(opts.IO.Out, "%s Request to cancel workflow submitted.", cs.SuccessIcon()) + fmt.Fprintf(opts.IO.Out, "%s Request to cancel workflow submitted.\n", cs.SuccessIcon()) return nil } From 18975e61d135fc0d20049efea721dcad63d09652 Mon Sep 17 00:00:00 2001 From: nate smith Date: Fri, 15 Oct 2021 14:31:29 -0500 Subject: [PATCH 5/7] fix imports --- pkg/cmd/run/cancel/cancel_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go index 89aebe664..9fbf60a5a 100644 --- a/pkg/cmd/run/cancel/cancel_test.go +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) From f381a804fce659ec26ee02e27bd47afd0893a7d7 Mon Sep 17 00:00:00 2001 From: nate smith Date: Fri, 15 Oct 2021 14:57:20 -0500 Subject: [PATCH 6/7] fix tests --- pkg/cmd/run/cancel/cancel.go | 10 ++++++--- pkg/cmd/run/cancel/cancel_test.go | 37 +++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index d9711c420..545f7f3f2 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -97,7 +97,13 @@ func runCancel(opts *CancelOptions) error { } else { run, err = shared.GetRun(client, repo, runID) if err != nil { - return fmt.Errorf("failed to get run: %w", err) + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if httpErr.StatusCode == http.StatusNotFound { + err = fmt.Errorf("Could not find any workflow run with ID %s", opts.RunID) + } + } + return err } } @@ -107,8 +113,6 @@ func runCancel(opts *CancelOptions) error { if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusConflict { err = fmt.Errorf("Cannot cancel a workflow run that is completed") - } else if httpErr.StatusCode == http.StatusNotFound { - err = fmt.Errorf("Could not find any workflow run with ID %s", opts.RunID) } } diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go index 9fbf60a5a..cb198eae3 100644 --- a/pkg/cmd/run/cancel/cancel_test.go +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -18,11 +19,19 @@ func TestNewCmdCancel(t *testing.T) { tests := []struct { name string cli string + tty bool wants CancelOptions wantsErr bool }{ { - name: "blank", + name: "blank tty", + tty: true, + wants: CancelOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", wantsErr: true, }, { @@ -37,8 +46,8 @@ func TestNewCmdCancel(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { io, _, _, _ := iostreams.Test() - io.SetStdinTTY(true) - io.SetStdoutTTY(true) + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) f := &cmdutil.Factory{ IOStreams: io, @@ -72,6 +81,8 @@ func TestNewCmdCancel(t *testing.T) { } func TestRunCancel(t *testing.T) { + inProgressRun := shared.TestRun("more runs", 1234, shared.InProgress, "") + completedRun := shared.TestRun("more runs", 4567, shared.Completed, shared.Failure) tests := []struct { name string httpStubs func(*httpmock.Registry) @@ -87,12 +98,15 @@ func TestRunCancel(t *testing.T) { }, wantErr: false, httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(inProgressRun)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), httpmock.StatusStringResponse(202, "{}"), ) }, - wantOut: "✓ You have successfully requested the workflow to be canceled.", + wantOut: "✓ Request to cancel workflow submitted.\n", }, { name: "not found", @@ -103,21 +117,23 @@ func TestRunCancel(t *testing.T) { errMsg: "Could not find any workflow run with ID 1234", httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), - httpmock.StatusStringResponse(404, ""), - ) + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.StatusStringResponse(404, "")) }, }, { name: "completed", opts: &CancelOptions{ - RunID: "1234", + RunID: "4567", }, wantErr: true, errMsg: "Cannot cancel a workflow run that is completed", httpStubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/4567"), + httpmock.JSONResponse(completedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/4567/cancel"), httpmock.StatusStringResponse(409, ""), ) }, @@ -133,6 +149,7 @@ func TestRunCancel(t *testing.T) { io, _, stdout, _ := iostreams.Test() io.SetStdoutTTY(true) + io.SetStdinTTY(true) tt.opts.IO = io tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") @@ -145,6 +162,8 @@ func TestRunCancel(t *testing.T) { if tt.errMsg != "" { assert.Equal(t, tt.errMsg, err.Error()) } + } else { + assert.NoError(t, err) } assert.Equal(t, tt.wantOut, stdout.String()) reg.Verify(t) From a4015b7f09438cfe1729a0112b55aa0939393787 Mon Sep 17 00:00:00 2001 From: nate smith Date: Fri, 15 Oct 2021 15:08:53 -0500 Subject: [PATCH 7/7] prompt tests --- pkg/cmd/run/cancel/cancel.go | 2 +- pkg/cmd/run/cancel/cancel_test.go | 50 +++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 545f7f3f2..8499dbaa6 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -81,7 +81,7 @@ func runCancel(opts *CancelOptions) error { return fmt.Errorf("failed to get runs: %w", err) } if len(runs) == 0 { - return fmt.Errorf("found no in progress runs to watch") + return fmt.Errorf("found no in progress runs to cancel") } runID, err = shared.PromptForRun(cs, runs) if err != nil { diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go index cb198eae3..9304ee8fb 100644 --- a/pkg/cmd/run/cancel/cancel_test.go +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -86,6 +87,7 @@ func TestRunCancel(t *testing.T) { tests := []struct { name string httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) opts *CancelOptions wantErr bool wantOut string @@ -103,8 +105,7 @@ func TestRunCancel(t *testing.T) { httpmock.JSONResponse(inProgressRun)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), - httpmock.StatusStringResponse(202, "{}"), - ) + httpmock.StatusStringResponse(202, "{}")) }, wantOut: "✓ Request to cancel workflow submitted.\n", }, @@ -138,6 +139,45 @@ func TestRunCancel(t *testing.T) { ) }, }, + { + name: "prompt, no in progress runs", + opts: &CancelOptions{ + Prompt: true, + }, + wantErr: true, + errMsg: "found no in progress runs to cancel", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + completedRun, + }, + })) + }, + }, + { + name: "prompt, cancel", + opts: &CancelOptions{ + Prompt: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + inProgressRun, + }, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"), + httpmock.StatusStringResponse(202, "{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) + }, + wantOut: "✓ Request to cancel workflow submitted.\n", + }, } for _, tt := range tests { @@ -155,6 +195,12 @@ func TestRunCancel(t *testing.T) { 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 := runCancel(tt.opts) if tt.wantErr {