Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions acceptance/testdata/release/release-delete.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private

# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING

# Create a release in the repo
exec gh release create v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --notes 'awesome release' --latest

# Upload an asset to the release
exec gh release upload v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING asset.txt

# Delete the asset from the release
exec gh release delete-asset v1.2.3 asset.txt --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes

# Verify the release has no assets
exec gh release view v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --json assets --jq '.assets | length'
stdout '0'

# Downloading the deleted asset should fail
! exec gh release download v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING
stderr 'no assets to download'

# Delete the release and its tag
exec gh release delete v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes --cleanup-tag

# Wait for tag deletion to become visible through the ref lookup
sleep 5

# Verify the release is gone
! exec gh release view v1.2.3 --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING
stderr 'release not found'

# Verify the tag is gone
! exec gh api repos/$ORG/$SCRIPT_NAME-$RANDOM_STRING/git/ref/tags/v1.2.3
stderr 'Not Found'

-- asset.txt --
Hello, world!
23 changes: 6 additions & 17 deletions pkg/cmd/release/delete-asset/delete_asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func deleteAssetRun(opts *DeleteAssetOptions) error {
return fmt.Errorf("asset %s not found in release %s", opts.AssetName, release.TagName)
}

err = deleteAsset(httpClient, safeurl.NewImmutableSafeURL(assetURL))
err = deleteAsset(httpClient, baseRepo.RepoHost(), safeurl.NewImmutableSafeURL(assetURL))
if err != nil {
return err
}
Expand All @@ -112,20 +112,9 @@ func deleteAssetRun(opts *DeleteAssetOptions) error {
return nil
}

func deleteAsset(httpClient *http.Client, assetURL safeurl.SafeURL) error {
req, err := http.NewRequest("DELETE", assetURL.String(), 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
func deleteAsset(httpClient *http.Client, host string, assetURL safeurl.SafeURL) error {
// TODO(api-client-rollout)
// This line of code is part of a mechanical roll out of the api client.
// As a follow up, consider whether the api client can be injected to this call site, rather than constructed
return api.NewClientFromHTTP(httpClient).REST(host, http.MethodDelete, assetURL.String(), nil, nil)
}
23 changes: 23 additions & 0 deletions pkg/cmd/release/delete-asset/delete_asset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"net/http"
"testing"

"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/internal/safeurl"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
Expand Down Expand Up @@ -199,3 +201,24 @@ func Test_deleteAssetRun(t *testing.T) {
})
}
}

func Test_deleteAsset_httpError(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodDelete &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/assets/1" &&
req.URL.Host == "api.github.com"
},
httpmock.StatusStringResponse(404, `{"message":"Not Found"}`),
)

httpClient := &http.Client{Transport: reg}
err := deleteAsset(httpClient, "example.com", safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/releases/assets/1"))

var httpErr api.HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
assert.Contains(t, err.Error(), "HTTP 404")
}
45 changes: 11 additions & 34 deletions pkg/cmd/release/delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safeurl"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
Expand Down Expand Up @@ -93,7 +92,7 @@ func deleteRun(opts *DeleteOptions) error {
}
}

err = deleteRelease(httpClient, safeurl.NewImmutableSafeURL(release.APIURL))
err = deleteRelease(httpClient, baseRepo.RepoHost(), safeurl.NewImmutableSafeURL(release.APIURL))
if err != nil {
return err
}
Expand Down Expand Up @@ -122,42 +121,20 @@ func deleteRun(opts *DeleteOptions) error {
return nil
}

func deleteRelease(httpClient *http.Client, releaseURL safeurl.SafeURL) error {
req, err := http.NewRequest("DELETE", releaseURL.String(), 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
func deleteRelease(httpClient *http.Client, host string, releaseURL safeurl.SafeURL) error {
// TODO(api-client-rollout)
// This line of code is part of a mechanical roll out of the api client.
// As a follow up, consider whether the api client can be injected to this call site, rather than constructed
return api.NewClientFromHTTP(httpClient).REST(host, http.MethodDelete, releaseURL.String(), nil, nil)
}

func deleteTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) error {
url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "git", "refs", fmt.Sprintf("tags/%s", tagName))
path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "git", "refs", fmt.Sprintf("tags/%s", tagName))
if err != nil {
return err
}
req, err := http.NewRequest("DELETE", url.String(), 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
// TODO(api-client-rollout)
// This line of code is part of a mechanical roll out of the api client.
// As a follow up, consider whether the api client can be injected to this call site, rather than constructed
return api.NewClientFromHTTP(httpClient).REST(baseRepo.RepoHost(), http.MethodDelete, path.String(), nil, nil)
}
41 changes: 41 additions & 0 deletions pkg/cmd/release/delete/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"testing"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/safeurl"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
Expand Down Expand Up @@ -241,3 +243,42 @@ func Test_deleteRun(t *testing.T) {
})
}
}

func Test_deleteRelease_httpError(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodDelete &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/23456" &&
req.URL.Host == "api.github.com"
},
httpmock.StatusStringResponse(404, `{"message":"Not Found"}`),
)

httpClient := &http.Client{Transport: reg}
err := deleteRelease(httpClient, "example.com", safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/releases/23456"))

var httpErr api.HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
assert.Contains(t, err.Error(), "HTTP 404")
}

func Test_deleteTag_httpError(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/tags%2Fv1.2.3"),
httpmock.StatusStringResponse(404, `{"message":"Not Found"}`),
)

httpClient := &http.Client{Transport: reg}
baseRepo, _ := ghrepo.FromFullName("OWNER/REPO")
err := deleteTag(httpClient, baseRepo, "v1.2.3")

var httpErr api.HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
assert.Contains(t, err.Error(), "HTTP 404")
}
95 changes: 95 additions & 0 deletions pkg/cmd/release/edit/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package edit

import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"testing"

"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"
Expand Down Expand Up @@ -480,6 +482,99 @@ func mockSuccessfulEditResponse(reg *httpmock.Registry, cb func(params map[strin
reg.Register(matcher, responder)
}

func Test_editRelease_httpError(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodPatch &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/12345" &&
req.URL.Host == "api.github.com"
},
httpmock.StatusStringResponse(404, `{"message":"Not Found"}`),
)

httpClient := &http.Client{Transport: reg}
release, err := editRelease(httpClient, ghrepo.New("OWNER", "REPO"), 12345, map[string]interface{}{"tag_name": "v1.2.3"})

var httpErr api.HTTPError
require.ErrorAs(t, err, &httpErr)
assert.Equal(t, http.StatusNotFound, httpErr.StatusCode)
assert.Contains(t, err.Error(), "HTTP 404")
assert.Nil(t, release)
}

func Test_editRelease_decodeError(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodPatch &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/12345" &&
req.URL.Host == "api.github.com"
},
httpmock.StatusStringResponse(200, `{`),
)

httpClient := &http.Client{Transport: reg}
release, err := editRelease(httpClient, ghrepo.New("OWNER", "REPO"), 12345, map[string]interface{}{"tag_name": "v1.2.3"})

require.Error(t, err)
assert.NotNil(t, release) // decode was attempted - non-nil pointer even on decode error
}

func Test_editRelease_204(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodPatch &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/12345" &&
req.URL.Host == "api.github.com"
},
httpmock.StatusStringResponse(204, ""),
)

httpClient := &http.Client{Transport: reg}
release, err := editRelease(httpClient, ghrepo.New("OWNER", "REPO"), 12345, map[string]interface{}{"tag_name": "v1.2.3"})

require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected end of JSON input")
assert.NotNil(t, release)
}

func Test_editRelease_bodyReadError(t *testing.T) {
readErr := errors.New("read: connection reset by peer")
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
func(req *http.Request) bool {
return req.Method == http.MethodPatch &&
req.URL.EscapedPath() == "/repos/OWNER/REPO/releases/12345" &&
req.URL.Host == "api.github.com"
},
func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(errorReader{err: readErr}),
Header: http.Header{},
}, nil
},
)

httpClient := &http.Client{Transport: reg}
release, err := editRelease(httpClient, ghrepo.New("OWNER", "REPO"), 12345, map[string]interface{}{"tag_name": "v1.2.3"})

require.Error(t, err)
assert.ErrorIs(t, err, readErr)
assert.Nil(t, release)
}

// errorReader always returns the given error on Read, used to simulate body read failures.
type errorReader struct{ err error }

func (e errorReader) Read(_ []byte) (int, error) { return 0, e.err }

func boolPtr(b bool) *bool {
return &b
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/release/edit/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64

req.Header.Set("Content-Type", "application/json; charset=utf-8")

// TODO(api-client-rollout)
// This has been deferred from moving to api.Client because its return shape depends on the response status code, which api.Client.REST does not expose on success.
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
Expand Down