Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Route release edits through api.Client
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cd6661ad-7e77-46d0-b0e7-0a296f4e252b
  • Loading branch information
williammartin and Copilot committed Aug 5, 2026
commit 2ec12d3ade6ba0da4b8339a95f0fc3640435d1b5
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, "DELETE", assetURL.String(), nil, nil)
Comment thread
williammartin marked this conversation as resolved.
Outdated
}
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, "DELETE", releaseURL.String(), nil, nil)
Comment thread
williammartin marked this conversation as resolved.
Outdated
}

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(), "DELETE", 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")
}
98 changes: 97 additions & 1 deletion 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 @@ -444,7 +446,8 @@ func Test_editRun(t *testing.T) {
defer fakeHTTP.Verify(t)
shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", "v1.2.3", `{
"id": 12345,
"tag_name": "v1.2.3"
"tag_name": "v1.2.3",
"url": "https://api.github.com/repos/OWNER/REPO/releases/12345"
}`)
if tt.httpStubs != nil {
tt.httpStubs(t, fakeHTTP)
Expand Down Expand Up @@ -480,6 +483,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
Loading