From e87b87cfd00122586165c88f1f840fe40c680d61 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 28 Jul 2026 13:37:10 +0200 Subject: [PATCH 1/7] Route autolink requests through api.Client Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 598c3576-22dc-4d7f-a72d-4f84922174a1 --- pkg/cmd/repo/autolink/create/http.go | 49 ++++------------ pkg/cmd/repo/autolink/create/http_test.go | 23 +++++--- pkg/cmd/repo/autolink/delete/http.go | 24 ++++---- pkg/cmd/repo/autolink/delete/http_test.go | 14 ++++- pkg/cmd/repo/autolink/list/http.go | 29 ++++------ pkg/cmd/repo/autolink/list/http_test.go | 68 ++++++++++++++++++----- pkg/cmd/repo/autolink/view/http.go | 31 ++++------- pkg/cmd/repo/autolink/view/http_test.go | 17 ++++++ 8 files changed, 138 insertions(+), 117 deletions(-) diff --git a/pkg/cmd/repo/autolink/create/http.go b/pkg/cmd/repo/autolink/create/http.go index 1de86e42cb8..73a84e0cff4 100644 --- a/pkg/cmd/repo/autolink/create/http.go +++ b/pkg/cmd/repo/autolink/create/http.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/cli/cli/v2/api" - "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/repo/autolink/shared" @@ -24,7 +23,7 @@ type AutolinkCreateRequest struct { } func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks") + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks") if err != nil { return nil, err } @@ -35,47 +34,19 @@ func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRe } requestBody := bytes.NewReader(requestByte) - req, err := http.NewRequest(http.MethodPost, url.String(), requestBody) - if err != nil { - return nil, err - } - - resp, err := a.HTTPClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - - err = handleAutolinkCreateError(resp) - - if err != nil { - return nil, err - } - var autolink shared.Autolink - - err = json.NewDecoder(resp.Body).Decode(&autolink) + // 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 + err = api.NewClientFromHTTP(a.HTTPClient).REST(repo.RepoHost(), http.MethodPost, path.String(), requestBody, &autolink) if err != nil { - return nil, err - } - - return &autolink, nil -} - -func handleAutolinkCreateError(resp *http.Response) error { - switch resp.StatusCode { - case http.StatusCreated: - return nil - case http.StatusNotFound: - err := api.HandleHTTPError(resp) var httpErr api.HTTPError - if errors.As(err, &httpErr) { + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { httpErr.Message = "Must have admin rights to Repository." - return httpErr + return nil, httpErr } - return err - default: - return api.HandleHTTPError(resp) + return nil, err } + + return &autolink, nil } diff --git a/pkg/cmd/repo/autolink/create/http_test.go b/pkg/cmd/repo/autolink/create/http_test.go index f5e2f53656d..1fc913d8fbf 100644 --- a/pkg/cmd/repo/autolink/create/http_test.go +++ b/pkg/cmd/repo/autolink/create/http_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" @@ -25,6 +26,7 @@ func TestAutolinkCreator_Create(t *testing.T) { expectedAutolink *shared.Autolink expectErr bool expectedErrMsg string + expectedStatus int }{ { name: "201 successful creation", @@ -68,7 +70,8 @@ func TestAutolinkCreator_Create(t *testing.T) { "documentation_url": "https://docs.github.com/rest/repos/autolinks#create-an-autolink-reference-for-a-repository", "status": "422" }`, - expectErr: true, + expectErr: true, + expectedStatus: http.StatusUnprocessableEntity, expectedErrMsg: heredoc.Doc(` HTTP 422: Validation Failed (https://api.github.com/repos/OWNER/REPO/autolinks) url_template must be an absolute URL`), @@ -88,6 +91,7 @@ func TestAutolinkCreator_Create(t *testing.T) { }`, expectErr: true, expectedErrMsg: "HTTP 404: Must have admin rights to Repository. (https://api.github.com/repos/OWNER/REPO/autolinks)", + expectedStatus: http.StatusNotFound, }, { name: "422 URL template missing ", @@ -96,9 +100,10 @@ func TestAutolinkCreator_Create(t *testing.T) { KeyPrefix: "TICKET-", URLTemplate: "https://example.com/TICKET", }, - stubStatus: http.StatusUnprocessableEntity, - stubRespJSON: `{"message":"Validation Failed","errors":[{"resource":"KeyLink","code":"custom","field":"url_template","message":"url_template is missing a token"}],"documentation_url":"https://docs.github.com/rest/repos/autolinks#create-an-autolink-reference-for-a-repository","status":"422"}`, - expectErr: true, + stubStatus: http.StatusUnprocessableEntity, + stubRespJSON: `{"message":"Validation Failed","errors":[{"resource":"KeyLink","code":"custom","field":"url_template","message":"url_template is missing a token"}],"documentation_url":"https://docs.github.com/rest/repos/autolinks#create-an-autolink-reference-for-a-repository","status":"422"}`, + expectErr: true, + expectedStatus: http.StatusUnprocessableEntity, expectedErrMsg: heredoc.Doc(` HTTP 422: Validation Failed (https://api.github.com/repos/OWNER/REPO/autolinks) url_template is missing a token`), @@ -110,9 +115,10 @@ func TestAutolinkCreator_Create(t *testing.T) { KeyPrefix: "TICKET-", URLTemplate: "https://example.com/TICKET?query=", }, - stubStatus: http.StatusUnprocessableEntity, - stubRespJSON: `{"message":"Validation Failed","errors":[{"resource":"KeyLink","code":"already_exists","field":"key_prefix"}],"documentation_url":"https://docs.github.com/rest/repos/autolinks#create-an-autolink-reference-for-a-repository","status":"422"}`, - expectErr: true, + stubStatus: http.StatusUnprocessableEntity, + stubRespJSON: `{"message":"Validation Failed","errors":[{"resource":"KeyLink","code":"already_exists","field":"key_prefix"}],"documentation_url":"https://docs.github.com/rest/repos/autolinks#create-an-autolink-reference-for-a-repository","status":"422"}`, + expectErr: true, + expectedStatus: http.StatusUnprocessableEntity, expectedErrMsg: heredoc.Doc(` HTTP 422: Validation Failed (https://api.github.com/repos/OWNER/REPO/autolinks) KeyLink.key_prefix already exists`), @@ -146,6 +152,9 @@ func TestAutolinkCreator_Create(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) + var httpErr api.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) } else { require.NoError(t, err) assert.Equal(t, tt.expectedAutolink, autolink) diff --git a/pkg/cmd/repo/autolink/delete/http.go b/pkg/cmd/repo/autolink/delete/http.go index ed35d6328df..54e25148d59 100644 --- a/pkg/cmd/repo/autolink/delete/http.go +++ b/pkg/cmd/repo/autolink/delete/http.go @@ -1,11 +1,11 @@ package delete import ( + "errors" "fmt" "net/http" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/safeurl" ) @@ -15,26 +15,22 @@ type AutolinkDeleter struct { } func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodDelete, url.String(), nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) if err != nil { return err } - resp, err := a.HTTPClient.Do(req) + // 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 + err = api.NewClientFromHTTP(a.HTTPClient).REST(repo.RepoHost(), http.MethodDelete, path.String(), nil, nil) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return fmt.Errorf("error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", httpErr.RequestURL) + } return err } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) - } else if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) - } return nil } diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go index a0aec5e131a..8615c498ed7 100644 --- a/pkg/cmd/repo/autolink/delete/http_test.go +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -5,9 +5,11 @@ import ( "net/http" "testing" + cliapi "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" - "github.com/cli/go-gh/v2/pkg/api" + ghapi "github.com/cli/go-gh/v2/pkg/api" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,6 +24,7 @@ func TestAutolinkDeleter_Delete(t *testing.T) { expectErr bool expectedErrMsg string + expectedStatus int }{ { name: "204 successful delete", @@ -38,12 +41,13 @@ func TestAutolinkDeleter_Delete(t *testing.T) { { name: "500 unexpected error", id: "123", - stubResp: api.HTTPError{ + stubResp: ghapi.HTTPError{ Message: "arbitrary error", }, stubStatus: http.StatusInternalServerError, expectErr: true, expectedErrMsg: "HTTP 500: arbitrary error (https://api.github.com/repos/OWNER/REPO/autolinks/123)", + expectedStatus: http.StatusInternalServerError, }, } @@ -67,6 +71,12 @@ func TestAutolinkDeleter_Delete(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) + if tt.expectedStatus != 0 { + var httpErr cliapi.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) + assert.Contains(t, err.Error(), "HTTP 500") + } } else { require.NoError(t, err) } diff --git a/pkg/cmd/repo/autolink/list/http.go b/pkg/cmd/repo/autolink/list/http.go index 210495c7613..3058807bc58 100644 --- a/pkg/cmd/repo/autolink/list/http.go +++ b/pkg/cmd/repo/autolink/list/http.go @@ -1,12 +1,11 @@ package list import ( - "encoding/json" + "errors" "fmt" "net/http" "github.com/cli/cli/v2/api" - "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/repo/autolink/shared" @@ -17,29 +16,21 @@ type AutolinkLister struct { } func (a *AutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks") + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks") if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, url.String(), nil) - if err != nil { - return nil, err - } - - resp, err := a.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) - } else if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } var autolinks []shared.Autolink - err = json.NewDecoder(resp.Body).Decode(&autolinks) + // 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 + err = api.NewClientFromHTTP(a.HTTPClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &autolinks) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", httpErr.RequestURL) + } return nil, err } diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index 065444a89b3..8ef928d3aca 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" @@ -14,16 +15,20 @@ import ( func TestAutolinkLister_List(t *testing.T) { tests := []struct { - name string - repo ghrepo.Interface - resp []shared.Autolink - status int + name string + repo ghrepo.Interface + resp any + status int + expectedAutolinks []shared.Autolink + expectedErrMsg string + expectedStatus int }{ { - name: "no autolinks", - repo: ghrepo.New("OWNER", "REPO"), - resp: []shared.Autolink{}, - status: http.StatusOK, + name: "no autolinks", + repo: ghrepo.New("OWNER", "REPO"), + resp: []shared.Autolink{}, + status: http.StatusOK, + expectedAutolinks: []shared.Autolink{}, }, { name: "two autolinks", @@ -43,11 +48,39 @@ func TestAutolinkLister_List(t *testing.T) { }, }, status: http.StatusOK, + expectedAutolinks: []shared.Autolink{ + { + ID: 1, + IsAlphanumeric: true, + KeyPrefix: "key", + URLTemplate: "https://example.com", + }, + { + ID: 2, + IsAlphanumeric: false, + KeyPrefix: "key2", + URLTemplate: "https://example2.com", + }, + }, + }, + { + name: "404 repo not found", + repo: ghrepo.New("OWNER", "REPO"), + resp: map[string]any{ + "message": "Not Found", + }, + status: http.StatusNotFound, + expectedErrMsg: "error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks)", }, { - name: "http error", - repo: ghrepo.New("OWNER", "REPO"), - status: http.StatusNotFound, + name: "500 unexpected error", + repo: ghrepo.New("OWNER", "REPO"), + resp: map[string]any{ + "message": "arbitrary error", + }, + status: http.StatusInternalServerError, + expectedErrMsg: "HTTP 500: arbitrary error (https://api.github.com/repos/OWNER/REPO/autolinks)", + expectedStatus: http.StatusInternalServerError, }, } @@ -64,12 +97,17 @@ func TestAutolinkLister_List(t *testing.T) { HTTPClient: &http.Client{Transport: reg}, } autolinks, err := autolinkLister.List(tt.repo) - if tt.status == http.StatusNotFound { - require.Error(t, err) - assert.Equal(t, "error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks)", err.Error()) + if tt.expectedErrMsg != "" { + require.EqualError(t, err, tt.expectedErrMsg) + if tt.expectedStatus != 0 { + var httpErr api.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) + assert.Contains(t, err.Error(), "HTTP 500") + } } else { require.NoError(t, err) - assert.Equal(t, tt.resp, autolinks) + assert.Equal(t, tt.expectedAutolinks, autolinks) } }) } diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go index a604fb8f24d..1d9ef16dfde 100644 --- a/pkg/cmd/repo/autolink/view/http.go +++ b/pkg/cmd/repo/autolink/view/http.go @@ -1,12 +1,11 @@ package view import ( - "encoding/json" + "errors" "fmt" "net/http" "github.com/cli/cli/v2/api" - "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/repo/autolink/shared" @@ -17,31 +16,21 @@ type AutolinkViewer struct { } func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, url.String(), nil) - if err != nil { - return nil, err - } - - resp, err := a.HTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", url) - } else if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } var autolink shared.Autolink - err = json.NewDecoder(resp.Body).Decode(&autolink) - + // 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 + err = api.NewClientFromHTTP(a.HTTPClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &autolink) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("HTTP 404: Perhaps you are missing admin rights to the repository? (%s)", httpErr.RequestURL) + } return nil, err } diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go index b792c975051..d8974ae298d 100644 --- a/pkg/cmd/repo/autolink/view/http_test.go +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -5,6 +5,7 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" @@ -24,6 +25,7 @@ func TestAutolinkViewer_View(t *testing.T) { expectedAutolink *shared.Autolink expectErr bool expectedErrMsg string + expectedStatus int }{ { name: "200 successful alphanumeric view", @@ -71,6 +73,15 @@ func TestAutolinkViewer_View(t *testing.T) { expectErr: true, expectedErrMsg: "HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks/123)", }, + { + name: "500 unexpected error", + id: "123", + stubStatus: http.StatusInternalServerError, + stubRespJSON: `{"message":"arbitrary error"}`, + expectErr: true, + expectedErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO/autolinks/123)", + expectedStatus: http.StatusInternalServerError, + }, } for _, tt := range tests { @@ -93,6 +104,12 @@ func TestAutolinkViewer_View(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) + if tt.expectedStatus != 0 { + var httpErr api.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) + assert.Contains(t, err.Error(), "HTTP 500") + } } else { require.NoError(t, err) assert.Equal(t, tt.expectedAutolink, autolink) From b31174791978a7db044916ee90ff87a8b317e67d Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 10:47:40 +0200 Subject: [PATCH 2/7] Add repo autolink acceptance test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5151527-3286-43fd-a420-38f496a076fa --- acceptance/testdata/repo/repo-autolink.txtar | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 acceptance/testdata/repo/repo-autolink.txtar diff --git a/acceptance/testdata/repo/repo-autolink.txtar b/acceptance/testdata/repo/repo-autolink.txtar new file mode 100644 index 00000000000..6310eed1ac3 --- /dev/null +++ b/acceptance/testdata/repo/repo-autolink.txtar @@ -0,0 +1,31 @@ +# Create a repository to hold the autolink references +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 + +# List the autolinks. There should be none +exec gh repo autolink list --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=keyPrefix +! stdout keyPrefix + +# Create an alphanumeric autolink +exec gh repo autolink create --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING TICKET- 'https://example.com/TICKET?query=' + +# Ensure the autolink was created +exec gh repo autolink list --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=keyPrefix --jq='.[].keyPrefix' +stdout 'TICKET-' + +# Get the autolink id +exec gh repo autolink list --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=keyPrefix,id --jq='.[] | select(.keyPrefix == "TICKET-") | .id' +stdout2env AUTOLINK_ID + +# View the autolink and ensure the url template round tripped +exec gh repo autolink view --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING $AUTOLINK_ID --json=urlTemplate --jq='.urlTemplate' +stdout 'https://example\.com/TICKET\?query=' + +# Delete the autolink +exec gh repo autolink delete --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING $AUTOLINK_ID --yes + +# Ensure the autolink was deleted +exec gh repo autolink list --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --json=id --jq='.[].id' +! stdout $AUTOLINK_ID From c47bc92dd109ec881321d3a0e43ed87cca685f20 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 11:09:00 +0200 Subject: [PATCH 3/7] Remove redundant HTTP error assertions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 598c3576-22dc-4d7f-a72d-4f84922174a1 --- pkg/cmd/repo/autolink/delete/http_test.go | 1 - pkg/cmd/repo/autolink/list/http_test.go | 1 - pkg/cmd/repo/autolink/view/http_test.go | 1 - 3 files changed, 3 deletions(-) diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go index 8615c498ed7..5451ed1ca5f 100644 --- a/pkg/cmd/repo/autolink/delete/http_test.go +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -75,7 +75,6 @@ func TestAutolinkDeleter_Delete(t *testing.T) { var httpErr cliapi.HTTPError require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) - assert.Contains(t, err.Error(), "HTTP 500") } } else { require.NoError(t, err) diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index 8ef928d3aca..a52dcf00b06 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -103,7 +103,6 @@ func TestAutolinkLister_List(t *testing.T) { var httpErr api.HTTPError require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) - assert.Contains(t, err.Error(), "HTTP 500") } } else { require.NoError(t, err) diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go index d8974ae298d..7ebc0326624 100644 --- a/pkg/cmd/repo/autolink/view/http_test.go +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -108,7 +108,6 @@ func TestAutolinkViewer_View(t *testing.T) { var httpErr api.HTTPError require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) - assert.Contains(t, err.Error(), "HTTP 500") } } else { require.NoError(t, err) From 73eb0a727774dd1b308f31dc91601ee0e542fcdc Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 28 Jul 2026 13:42:26 +0200 Subject: [PATCH 4/7] Route extension requests through api.Client Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a237433f-9657-4224-bad4-c4dc1c0fdc31 --- pkg/cmd/extension/http.go | 116 +++++++----------- pkg/cmd/extension/http_test.go | 194 ++++++++++++++++++++++++++++++ pkg/cmd/extension/manager_test.go | 4 +- 3 files changed, 239 insertions(+), 75 deletions(-) create mode 100644 pkg/cmd/extension/http_test.go diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 4ff8fa65cfb..8599d37ed87 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -14,53 +14,43 @@ import ( ) func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) - if err != nil { - return false, err - } - req, err := http.NewRequest("GET", url.String(), nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) if err != nil { return false, err } - resp, err := httpClient.Do(req) + var data struct{} + // 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 + err = api.NewClientFromHTTP(httpClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &data) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return false, nil + } return false, err } - defer resp.Body.Close() - switch resp.StatusCode { - case 200: - return true, nil - case 404: - return false, nil - default: - return false, api.HandleHTTPError(resp) - } + return true, nil } func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "contents", repo.RepoName()) - if err != nil { - return false, err - } - req, err := http.NewRequest("GET", url.String(), nil) + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "contents", repo.RepoName()) if err != nil { return false, err } - resp, err := httpClient.Do(req) + var data struct{} + // 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 + err = api.NewClientFromHTTP(httpClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &data) if err != nil { - return false, err - } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return false, nil - } - - if resp.StatusCode > 299 { - err = api.HandleHTTPError(resp) + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return false, nil + } return false, err } @@ -117,36 +107,26 @@ var repositoryNotFoundErr = errors.New("repository not found") // fetchLatestRelease finds the latest published release for a repository. func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "latest") - if err != nil { - return nil, err - } - req, err := http.NewRequest("GET", url.String(), nil) - if err != nil { - return nil, err - } - - resp, err := httpClient.Do(req) + path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "latest") if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return nil, releaseNotFoundErr - } - if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } - b, err := io.ReadAll(resp.Body) + var data json.RawMessage + // 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 + err = api.NewClientFromHTTP(httpClient).REST(baseRepo.RepoHost(), http.MethodGet, path.String(), nil, &data) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return nil, releaseNotFoundErr + } return nil, err } var r release - err = json.Unmarshal(b, &r) - if err != nil { + if err := json.Unmarshal(data, &r); err != nil { return nil, err } @@ -155,36 +135,26 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re // fetchReleaseFromTag finds release by tag name for a repository func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "tags", tagName) - if err != nil { - return nil, err - } - req, err := http.NewRequest("GET", url.String(), nil) + path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "tags", tagName) if err != nil { return nil, err } - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - if resp.StatusCode == 404 { - return nil, releaseNotFoundErr - } - if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } - - b, err := io.ReadAll(resp.Body) + var data json.RawMessage + // 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 + err = api.NewClientFromHTTP(httpClient).REST(baseRepo.RepoHost(), http.MethodGet, path.String(), nil, &data) if err != nil { + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return nil, releaseNotFoundErr + } return nil, err } var r release - err = json.Unmarshal(b, &r) - if err != nil { + if err := json.Unmarshal(data, &r); err != nil { return nil, err } diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go new file mode 100644 index 00000000000..fb7539f81a9 --- /dev/null +++ b/pkg/cmd/extension/http_test.go @@ -0,0 +1,194 @@ +package extension + +import ( + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func extensionHTTPClient(t *testing.T, path string, status int, body string) *http.Client { + t.Helper() + + reg := &httpmock.Registry{} + t.Cleanup(func() { + reg.Verify(t) + }) + reg.Register( + httpmock.REST(http.MethodGet, path), + httpmock.StatusStringResponse(status, body), + ) + return &http.Client{Transport: reg} +} + +func requireExtensionHTTPError(t *testing.T, err error, status int) { + t.Helper() + + var httpErr api.HTTPError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, status, httpErr.StatusCode) + assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", status)) +} + +func TestRepoExists(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + t.Run("success", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusOK, `{}`) + + exists, err := repoExists(client, repo) + + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("not found", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusNotFound, `{"message":"Not Found"}`) + + exists, err := repoExists(client, repo) + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("server error", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) + + exists, err := repoExists(client, repo) + + assert.False(t, exists) + requireExtensionHTTPError(t, err, http.StatusInternalServerError) + }) +} + +func TestHasScript(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + t.Run("success", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusOK, `{"type":"file"}`) + + hasScript, err := hasScript(client, repo) + + require.NoError(t, err) + assert.True(t, hasScript) + }) + + t.Run("not found", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusNotFound, `{"message":"Not Found"}`) + + hasScript, err := hasScript(client, repo) + + require.NoError(t, err) + assert.False(t, hasScript) + }) + + t.Run("server error", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) + + hasScript, err := hasScript(client, repo) + + assert.False(t, hasScript) + requireExtensionHTTPError(t, err, http.StatusInternalServerError) + }) +} + +func TestFetchLatestRelease(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + t.Run("success", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/latest", http.StatusOK, `{"tag_name":"v1.2.3","assets":[{"name":"asset","url":"https://example.com/asset"}]}`) + + got, err := fetchLatestRelease(client, repo) + + require.NoError(t, err) + assert.Equal(t, &release{ + Tag: "v1.2.3", + Assets: []releaseAsset{{ + Name: "asset", + APIURL: "https://example.com/asset", + }}, + }, got) + }) + + t.Run("not found", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/latest", http.StatusNotFound, `{"message":"Not Found"}`) + + got, err := fetchLatestRelease(client, repo) + + assert.Nil(t, got) + require.Same(t, releaseNotFoundErr, err) + }) + + t.Run("server error", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/latest", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) + + got, err := fetchLatestRelease(client, repo) + + assert.Nil(t, got) + requireExtensionHTTPError(t, err, http.StatusInternalServerError) + }) + + for _, status := range []int{http.StatusNoContent, http.StatusResetContent} { + t.Run(http.StatusText(status), func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/latest", status, "") + + got, err := fetchLatestRelease(client, repo) + + assert.Nil(t, got) + require.EqualError(t, err, "unexpected end of JSON input") + }) + } +} + +func TestFetchReleaseFromTag(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + t.Run("success", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusOK, `{"tag_name":"v1.2.3","assets":[{"name":"asset","url":"https://example.com/asset"}]}`) + + got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + + require.NoError(t, err) + assert.Equal(t, &release{ + Tag: "v1.2.3", + Assets: []releaseAsset{{ + Name: "asset", + APIURL: "https://example.com/asset", + }}, + }, got) + }) + + t.Run("not found", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusNotFound, `{"message":"Not Found"}`) + + got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + + assert.Nil(t, got) + require.Same(t, releaseNotFoundErr, err) + }) + + t.Run("server error", func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) + + got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + + assert.Nil(t, got) + requireExtensionHTTPError(t, err, http.StatusInternalServerError) + }) + + for _, status := range []int{http.StatusNoContent, http.StatusResetContent} { + t.Run(http.StatusText(status), func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", status, "") + + got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + + assert.Nil(t, got) + require.EqualError(t, err, "unexpected end of JSON input") + }) + } +} diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 1e8f8248324..ceb1597be3b 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -854,7 +854,7 @@ func TestManager_Install_git(t *testing.T) { })) reg.Register( httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"), - httpmock.StringResponse("script")) + httpmock.JSONResponse(map[string]string{"type": "file"})) repo := ghrepo.New("owner", fakeExtensionName) @@ -934,7 +934,7 @@ func TestManager_Install_git_pinned(t *testing.T) { httpmock.StringResponse("abcd1234")) reg.Register( httpmock.REST("GET", "repos/owner/gh-cool-ext/contents/gh-cool-ext"), - httpmock.StringResponse("script")) + httpmock.JSONResponse(map[string]string{"type": "file"})) _ = os.MkdirAll(filepath.Join(m.installDir(), "gh-cool-ext"), 0700) repo := ghrepo.New("owner", "gh-cool-ext") From d3addd0d61f964e6cca2b4caed6396f1ccf1b514 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 12:58:28 +0200 Subject: [PATCH 5/7] Defer repo existence API migration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a237433f-9657-4224-bad4-c4dc1c0fdc31 --- pkg/cmd/extension/http.go | 27 ++++++++++++++++----------- pkg/cmd/extension/http_test.go | 33 +++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 8599d37ed87..0281ce66443 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -14,25 +14,30 @@ import ( ) func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) + url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) if err != nil { return false, err } - var data struct{} - // 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 - err = api.NewClientFromHTTP(httpClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &data) + req, err := http.NewRequest(http.MethodGet, url.String(), nil) if err != nil { - var httpErr api.HTTPError - if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { - return false, nil - } return false, err } - return true, nil + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + default: + return false, api.HandleHTTPError(resp) + } } func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go index fb7539f81a9..20f10d1dfcb 100644 --- a/pkg/cmd/extension/http_test.go +++ b/pkg/cmd/extension/http_test.go @@ -38,14 +38,20 @@ func requireExtensionHTTPError(t *testing.T, err error, status int) { func TestRepoExists(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") - t.Run("success", func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusOK, `{}`) - - exists, err := repoExists(client, repo) - - require.NoError(t, err) - assert.True(t, exists) - }) + for name, body := range map[string]string{ + "JSON body": `{}`, + "empty body": "", + "non-JSON body": "repository", + } { + t.Run("success with "+name, func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusOK, body) + + exists, err := repoExists(client, repo) + + require.NoError(t, err) + assert.True(t, exists) + }) + } t.Run("not found", func(t *testing.T) { client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusNotFound, `{"message":"Not Found"}`) @@ -64,6 +70,17 @@ func TestRepoExists(t *testing.T) { assert.False(t, exists) requireExtensionHTTPError(t, err, http.StatusInternalServerError) }) + + for _, status := range []int{http.StatusCreated, http.StatusNoContent} { + t.Run("unexpected "+http.StatusText(status), func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO", status, `{"message":"Unexpected status"}`) + + exists, err := repoExists(client, repo) + + assert.False(t, exists) + requireExtensionHTTPError(t, err, status) + }) + } } func TestHasScript(t *testing.T) { From 3b837cb4b6b592d7656659e14c24a0c7aa4f601d Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 13:01:38 +0200 Subject: [PATCH 6/7] Document deferred API client calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a237433f-9657-4224-bad4-c4dc1c0fdc31 --- pkg/cmd/extension/http.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 0281ce66443..eed905434a8 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -24,6 +24,8 @@ func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { return false, err } + // TODO(api-client-rollout) + // This has been deferred from moving to api.Client due to its exact-status contract and body-blind response handling. resp, err := httpClient.Do(req) if err != nil { return false, err @@ -82,6 +84,8 @@ func downloadAsset(httpClient *http.Client, assetURL safeurl.SafeURL, destPath s req.Header.Set("Accept", "application/octet-stream") var resp *http.Response + // TODO(api-client-rollout) + // This has been deferred from moving to api.Client due to its custom Accept header and binary response streaming. if resp, downloadErr = httpClient.Do(req); downloadErr != nil { return } @@ -178,6 +182,8 @@ func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRe } req.Header.Set("Accept", "application/vnd.github.v3.sha") + // TODO(api-client-rollout) + // This has been deferred from moving to api.Client due to its custom Accept header and bare SHA response body. resp, err := httpClient.Do(req) if err != nil { return "", err From 420a538331addd0607e0f180edad18215714c9dc Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 13:11:16 +0200 Subject: [PATCH 7/7] Preserve hasScript directory listing handling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a237433f-9657-4224-bad4-c4dc1c0fdc31 --- pkg/cmd/extension/http.go | 5 +++-- pkg/cmd/extension/http_test.go | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index eed905434a8..333564d29a4 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -48,11 +48,12 @@ func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { return false, err } - var data struct{} + // The response body is not decoded, because a script is considered present for any + // successful response regardless of the content type reported. // 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 - err = api.NewClientFromHTTP(httpClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, &data) + err = api.NewClientFromHTTP(httpClient).REST(repo.RepoHost(), http.MethodGet, path.String(), nil, nil) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go index 20f10d1dfcb..7a262f78026 100644 --- a/pkg/cmd/extension/http_test.go +++ b/pkg/cmd/extension/http_test.go @@ -95,6 +95,27 @@ func TestHasScript(t *testing.T) { assert.True(t, hasScript) }) + // The contents endpoint returns an array when the requested path is a directory, and + // objects with a non-file type for symlinks and submodules. None of these are treated as + // a missing script, so they must not surface a decoding error. + t.Run("success for non-file content", func(t *testing.T) { + for name, body := range map[string]string{ + "directory listing": `[{"type":"file","name":"REPO"}]`, + "directory": `{"type":"dir"}`, + "symlink": `{"type":"symlink"}`, + "submodule": `{"type":"submodule"}`, + } { + t.Run(name, func(t *testing.T) { + client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusOK, body) + + hasScript, err := hasScript(client, repo) + + require.NoError(t, err) + assert.True(t, hasScript) + }) + } + }) + t.Run("not found", func(t *testing.T) { client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusNotFound, `{"message":"Not Found"}`)