diff --git a/pkg/cmd/release/delete-asset/delete_asset.go b/pkg/cmd/release/delete-asset/delete_asset.go new file mode 100644 index 00000000000..f506590210a --- /dev/null +++ b/pkg/cmd/release/delete-asset/delete_asset.go @@ -0,0 +1,127 @@ +package deleteasset + +import ( + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/spf13/cobra" +) + +type DeleteAssetOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + SkipConfirm bool + AssetName string +} + +func NewCmdDeleteAsset(f *cmdutil.Factory, runF func(*DeleteAssetOptions) error) *cobra.Command { + opts := &DeleteAssetOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete-asset ", + Short: "Delete an asset from a release", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + opts.TagName = args[0] + opts.AssetName = args[1] + if runF != nil { + return runF(opts) + } + return deleteAssetRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.SkipConfirm, "yes", "y", false, "Skip the confirmation prompt") + + return cmd +} + +func deleteAssetRun(opts *DeleteAssetOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + if !opts.SkipConfirm && opts.IO.CanPrompt() { + var confirmed bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Delete asset %s in release %s in %s?", opts.AssetName, release.TagName, ghrepo.FullName(baseRepo)), + Default: true, + }, &confirmed) + if err != nil { + return err + } + + if !confirmed { + return cmdutil.CancelError + } + } + + var assetURL string + for _, a := range release.Assets { + if a.Name == opts.AssetName { + assetURL = a.APIURL + break + } + } + if assetURL == "" { + return fmt.Errorf("asset %s not found in release %s", opts.AssetName, release.TagName) + } + + err = deleteAsset(httpClient, assetURL) + if err != nil { + return err + } + + if !opts.IO.IsStdoutTTY() || !opts.IO.IsStderrTTY() { + return nil + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted asset %s from release %s\n", cs.SuccessIconWithColor(cs.Red), opts.AssetName, release.TagName) + + return nil +} + +func deleteAsset(httpClient *http.Client, assetURL string) error { + req, err := http.NewRequest("DELETE", assetURL, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + return nil +} diff --git a/pkg/cmd/release/delete-asset/delete_asset_test.go b/pkg/cmd/release/delete-asset/delete_asset_test.go new file mode 100644 index 00000000000..56aae3b359b --- /dev/null +++ b/pkg/cmd/release/delete-asset/delete_asset_test.go @@ -0,0 +1,173 @@ +package deleteasset + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "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" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdDeleteAsset(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want DeleteAssetOptions + wantErr string + }{ + { + name: "tag and asset arguments", + args: "v1.2.3 test-asset", + isTTY: true, + want: DeleteAssetOptions{ + TagName: "v1.2.3", + SkipConfirm: false, + AssetName: "test-asset", + }, + }, + { + name: "skip confirm", + args: "v1.2.3 test-asset -y", + isTTY: true, + want: DeleteAssetOptions{ + TagName: "v1.2.3", + SkipConfirm: true, + AssetName: "test-asset", + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + wantErr: "accepts 2 arg(s), received 0", + }, + { + name: "one arguments", + args: "v1.2.3", + isTTY: true, + wantErr: "accepts 2 arg(s), received 1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *DeleteAssetOptions + cmd := NewCmdDeleteAsset(f, func(o *DeleteAssetOptions) error { + opts = o + return nil + }) + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.SkipConfirm, opts.SkipConfirm) + assert.Equal(t, tt.want.AssetName, opts.AssetName) + }) + } +} + +func Test_deleteAssetRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts DeleteAssetOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "skipping confirmation", + isTTY: true, + opts: DeleteAssetOptions{ + TagName: "v1.2.3", + SkipConfirm: true, + AssetName: "test-asset", + }, + wantStdout: ``, + wantStderr: "✓ Deleted asset test-asset from release v1.2.3\n", + }, + { + name: "non-interactive", + isTTY: false, + opts: DeleteAssetOptions{ + TagName: "v1.2.3", + SkipConfirm: false, + AssetName: "test-asset", + }, + wantStdout: ``, + wantStderr: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StringResponse(`{ + "tag_name": "v1.2.3", + "draft": false, + "url": "https://api.github.com/repos/OWNER/REPO/releases/23456", + "assets": [ + { + "url": "https://api.github.com/repos/OWNER/REPO/releases/assets/1", + "id": 1, + "name": "test-asset" + } + ] + }`)) + fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/releases/assets/1"), httpmock.StatusStringResponse(204, "")) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := deleteAssetRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 01132404e18..2d68418f0c4 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -3,6 +3,7 @@ package release import ( cmdCreate "github.com/cli/cli/v2/pkg/cmd/release/create" cmdDelete "github.com/cli/cli/v2/pkg/cmd/release/delete" + cmdDeleteAsset "github.com/cli/cli/v2/pkg/cmd/release/delete-asset" cmdDownload "github.com/cli/cli/v2/pkg/cmd/release/download" cmdList "github.com/cli/cli/v2/pkg/cmd/release/list" cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" @@ -24,6 +25,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdDeleteAsset.NewCmdDeleteAsset(f, nil)) cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil))