diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go new file mode 100644 index 000000000..8499dbaa6 --- /dev/null +++ b/pkg/cmd/run/cancel/cancel.go @@ -0,0 +1,136 @@ +package cancel + +import ( + "errors" + "fmt" + "net/http" + + "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" +) + +type CancelOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + Prompt bool + + 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.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + 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) + } + + 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) + } + + 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 cancel") + } + 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 { + 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 + } + } + + err = cancelWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID)) + 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") + } + } + + return err + } + + fmt.Fprintf(opts.IO.Out, "%s Request to cancel workflow submitted.\n", 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..9304ee8fb --- /dev/null +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -0,0 +1,218 @@ +package cancel + +import ( + "bytes" + "io/ioutil" + "net/http" + "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" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdCancel(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants CancelOptions + wantsErr bool + }{ + { + name: "blank tty", + tty: true, + wants: CancelOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + 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(tt.tty) + io.SetStdoutTTY(tt.tty) + + 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) { + 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) + askStubs func(*prompt.AskStubber) + 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("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: "✓ Request to cancel workflow submitted.\n", + }, + { + 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("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.StatusStringResponse(404, "")) + }, + }, + { + name: "completed", + opts: &CancelOptions{ + RunID: "4567", + }, + wantErr: true, + errMsg: "Cannot cancel a workflow run that is completed", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + 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, ""), + ) + }, + }, + { + 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 { + 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) + io.SetStdinTTY(true) + 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 := runCancel(tt.opts) + if tt.wantErr { + assert.Error(t, err) + 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) + }) + } +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index 1fc1b6fb9..649d70f0d 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/v2/pkg/cmd/run/cancel" cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download" cmdList "github.com/cli/cli/v2/pkg/cmd/run/list" cmdRerun "github.com/cli/cli/v2/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 }