Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add command gh run delete #7254

Merged
merged 3 commits into from Apr 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
147 changes: 147 additions & 0 deletions pkg/cmd/run/delete/delete.go
@@ -0,0 +1,147 @@
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 (
defaultRunGetLimit = 10
)

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 [<run-id>]",
Short: "Delete a workflow run",
Example: heredoc.Doc(`
# Interactively select a run to delete
$ 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, defaultRunGetLimit)
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
}
200 changes: 200 additions & 0 deletions pkg/cmd/run/delete/delete_test.go
@@ -0,0 +1,200 @@
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 {
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}

// mock http
reg := &httpmock.Registry{}
tt.httpStubs(reg)
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}

// mock IO
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(true)
ios.SetStdinTTY(true)
tt.opts.IO = ios

// mock prompter
pm := &prompter.PrompterMock{}
if tt.prompterStubs != nil {
tt.prompterStubs(pm)
}
tt.opts.Prompter = pm

// execute test
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)
})
}
}
2 changes: 2 additions & 0 deletions pkg/cmd/run/run.go
Expand Up @@ -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"
Expand All @@ -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
}