diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go new file mode 100644 index 000000000..ade0b9d65 --- /dev/null +++ b/pkg/cmd/run/delete/delete.go @@ -0,0 +1,148 @@ +package delete + +import ( + "errors" + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "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" +) + +const ( + defaultLimit = 20 +) + +type DeleteOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Prompter prompter.Prompter + Prompt bool + RunID string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete []", + Short: "Delete a workflow run", + Example: heredoc.Doc(` + # Interactively select a run to delete, optionally selecting a single job + $ gh run delete + + # Delete a specific run + $ gh run delete 12345 + `), + 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.FlagErrorf("run ID required when not running interactively") + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + + return runDelete(opts) + }, + } + + return cmd +} + +func runDelete(opts *DeleteOptions) 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 { + payload, err := shared.GetRuns(client, repo, nil, defaultLimit) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + + runs := payload.WorkflowRuns + if len(runs) == 0 { + return fmt.Errorf("found no runs to delete") + } + + runID, err = shared.SelectRun(opts.Prompter, cs, runs) + if err != nil { + return err + } + + for _, r := range runs { + if fmt.Sprintf("%d", r.ID) == runID { + run = &r + break + } + } + } else { + run, err = shared.GetRun(client, repo, runID, 0) + 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 = deleteWorkflowRun(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 delete a workflow run that is completed") + } + } + + return err + } + + fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon()) + + return nil +} + +func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error { + path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID) + err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/run/delete/delete_test.go b/pkg/cmd/run/delete/delete_test.go new file mode 100644 index 000000000..0033cb9e3 --- /dev/null +++ b/pkg/cmd/run/delete/delete_test.go @@ -0,0 +1,196 @@ +package delete + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/run/shared" + workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" + "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" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants DeleteOptions + wantsErr bool + prompterStubs func(*prompter.PrompterMock) + }{ + { + name: "blank tty", + tty: true, + wants: DeleteOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "with arg", + cli: "1234", + wants: DeleteOptions{ + RunID: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.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 TestRunDelete(t *testing.T) { + tests := []struct { + name string + opts *DeleteOptions + httpStubs func(*httpmock.Registry) + prompterStubs func(*prompter.PrompterMock) + wantErr bool + wantOut string + errMsg string + }{ + { + name: "delete run", + opts: &DeleteOptions{ + RunID: "1234", + }, + wantErr: false, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)), + httpmock.StatusStringResponse(204, "")) + }, + wantOut: "✓ Request to delete workflow submitted.\n", + }, + { + name: "not found", + opts: &DeleteOptions{ + 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: "prompt", + opts: &DeleteOptions{ + Prompt: true, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021") + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + TotalCount: 0, + WorkflowRuns: []shared.Run{shared.SuccessfulRun}, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse( + workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{shared.TestWorkflow}, + }, + )) + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)), + httpmock.StatusStringResponse(204, "")) + }, + wantOut: "✓ Request to delete 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 + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + t.Run(tt.name, func(t *testing.T) { + err := runDelete(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 f65400595..d62f03328 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -2,6 +2,7 @@ package run import ( cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/run/delete" 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(cmdDownload.NewCmdDownload(f, nil)) cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) return cmd } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index a165f94a3..aba3ca492 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -12,6 +12,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/prompt" @@ -441,6 +442,28 @@ func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, erro return &result, nil } +func SelectRun(p prompter.Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) { + now := time.Now() + + candidates := []string{} + + for _, run := range runs { + symbol, _ := Symbol(cs, run.Status, run.Conclusion) + candidates = append(candidates, + // TODO truncate commit message, long ones look terrible + fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime()))) + } + + selected, err := p.Select("Select a workflow run", "", candidates) + + if err != nil { + return "", err + } + + return fmt.Sprintf("%d", runs[selected].ID), nil +} + +// Deprecated: use SelectRun func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { var selected int now := time.Now()